Skip to main content

Best React Component Libraries in 2026: shadcn vs Radix vs Headless UI

·PkgPulse Team

TL;DR

shadcn/ui for copy-paste components with Tailwind; Radix UI for unstyled accessible primitives; Headless UI for Tailwind-first. shadcn/ui (~1.5M weekly downloads) isn't a traditional library — you copy components into your project and own the code. Radix UI (~4M) provides unstyled, WAI-ARIA-compliant primitives. Headless UI (~2M) is Tailwind Labs' headless library. For most new React projects in 2026, shadcn/ui is the starting point.

Key Takeaways

  • Radix UI: ~4M weekly downloads — unstyled primitives, shadcn/ui is built on top of it
  • Headless UI: ~2M downloads — Tailwind-first headless components, Tailwind Labs
  • shadcn/ui: ~1.5M downloads — copy-paste, owns the code, Radix + Tailwind
  • shadcn/ui — not installed as a dependency; CLI adds component files to your project
  • 2026 trend — headless + styling (shadcn pattern) dominates over pre-styled libraries

shadcn/ui (Copy-Paste)

# shadcn/ui — setup (Next.js)
npx shadcn@latest init
# Choose: TypeScript, Tailwind, CSS variables, color scheme

# Add components
npx shadcn@latest add button
npx shadcn@latest add dialog
npx shadcn@latest add form
npx shadcn@latest add table
npx shadcn@latest add toast
// shadcn/ui — Dialog (you own this code in components/ui/dialog.tsx)
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

export function EditUserDialog({ user, onSave }) {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="outline">Edit User</Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-[425px]">
        <DialogHeader>
          <DialogTitle>Edit User</DialogTitle>
          <DialogDescription>
            Make changes to the user profile here.
          </DialogDescription>
        </DialogHeader>
        <div className="grid gap-4 py-4">
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="name" className="text-right">Name</Label>
            <Input id="name" defaultValue={user.name} className="col-span-3" />
          </div>
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="email" className="text-right">Email</Label>
            <Input id="email" defaultValue={user.email} className="col-span-3" />
          </div>
        </div>
        <DialogFooter>
          <Button type="submit" onClick={() => onSave()}>Save changes</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
// shadcn/ui — Form with React Hook Form + Zod (built-in integration)
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
  Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';

const formSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2, 'Name must be at least 2 characters'),
});

export function UserForm({ onSubmit }) {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: { email: '', name: '' },
  });

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input placeholder="you@example.com" {...field} />
              </FormControl>
              <FormMessage />  {/* Shows Zod validation errors */}
            </FormItem>
          )}
        />
        <Button type="submit">Submit</Button>
      </form>
    </Form>
  );
}

Radix UI (Headless Primitives)

// Radix UI — build your own styled component on top of primitives
import * as Dialog from '@radix-ui/react-dialog';
import * as Select from '@radix-ui/react-select';

// Custom styled dialog (bring your own CSS)
function MyDialog({ children, title }) {
  return (
    <Dialog.Root>
      <Dialog.Trigger className="btn-primary">
        Open
      </Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm" />
        <Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg p-6 shadow-xl max-w-md w-full">
          <Dialog.Title className="text-xl font-bold mb-4">{title}</Dialog.Title>
          {children}
          <Dialog.Close className="absolute top-4 right-4 text-gray-500 hover:text-gray-900"></Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

// Custom styled select (WAI-ARIA compliant automatically)
function MySelect({ options, onChange }) {
  return (
    <Select.Root onValueChange={onChange}>
      <Select.Trigger className="flex items-center gap-2 px-3 py-2 border rounded">
        <Select.Value placeholder="Select option..." />
        <Select.Icon></Select.Icon>
      </Select.Trigger>
      <Select.Portal>
        <Select.Content className="bg-white border rounded-lg shadow-lg">
          <Select.Viewport>
            {options.map(opt => (
              <Select.Item key={opt.value} value={opt.value} className="px-3 py-2 hover:bg-gray-100 cursor-pointer">
                <Select.ItemText>{opt.label}</Select.ItemText>
              </Select.Item>
            ))}
          </Select.Viewport>
        </Select.Content>
      </Select.Portal>
    </Select.Root>
  );
}

Headless UI (Tailwind-Native)

// Headless UI — Tailwind-first, from Tailwind Labs
import { Dialog, Transition, Listbox } from '@headlessui/react';
import { Fragment, useState } from 'react';

function MyDialog({ isOpen, onClose, title, children }) {
  return (
    <Transition appear show={isOpen} as={Fragment}>
      <Dialog onClose={onClose} className="relative z-50">
        <Transition.Child
          as={Fragment}
          enter="ease-out duration-300"
          enterFrom="opacity-0"
          enterTo="opacity-100"
        >
          <div className="fixed inset-0 bg-black/30" />
        </Transition.Child>

        <div className="fixed inset-0 flex items-center justify-center">
          <Transition.Child
            as={Fragment}
            enter="ease-out duration-300"
            enterFrom="opacity-0 scale-95"
            enterTo="opacity-100 scale-100"
          >
            <Dialog.Panel className="bg-white rounded-xl p-6 shadow-xl max-w-md w-full">
              <Dialog.Title className="text-xl font-bold">{title}</Dialog.Title>
              {children}
            </Dialog.Panel>
          </Transition.Child>
        </div>
      </Dialog>
    </Transition>
  );
}

When to Choose

ScenarioPick
New project, want great default stylesshadcn/ui
Build your own design systemRadix UI
Tailwind-first, minimal setupHeadless UI
Already have design tokens/brandRadix UI
Want full component ownershipshadcn/ui
Heavy customization neededRadix UI (most flexible)
Enterprise design systemMUI or Ant Design

Compare component library package health on PkgPulse.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.