shadcn/ui vs Radix vs Headless UI: Choosing Your Component Base
shadcn/ui, Radix UI, and Headless UI solve the same problem differently. Here's how to pick the right component base for your React project in 2026.
What You're Actually Choosing Between
Honestly, these three libraries don't compete on the same axis. Radix UI gives you unstyled, accessible primitives. Headless UI does roughly the same thing but is tighter-coupled to Tailwind CSS. shadcn/ui sits on top of Radix and ships you copy-paste components with Tailwind styles already baked in. They're different layers of the same stack.
That distinction matters a lot when you're picking a foundation. If you choose Radix directly, you own all the styling from day one — no opinions imposed, no class names to override. If you choose shadcn/ui, you get working components in minutes but you're accepting someone else's design decisions as your starting point.
Neither approach is wrong. The question is what your project actually needs. A solo dev shipping a SaaS MVP has completely different constraints than a design-systems team building for 30 engineers.
Radix UI: The Accessible Primitive Layer
Radix is a collection of unstyled, WAI-ARIA-compliant components. Dialog, Dropdown, Tooltip, Tabs — the primitives you'd otherwise spend weeks getting right on keyboard navigation and screen reader support. It handles all of that. You bring the CSS.
The API is composable. Every component exposes named sub-parts you can style independently. A Dialog.Root, Dialog.Trigger, Dialog.Content pattern means you can slot your own classes at any level without fighting the library. Radix v1.3.x introduced data attributes for state (data-state="open", data-highlighted, etc.) that make CSS targeting clean without JavaScript.
The cost is setup time. You will write every pixel yourself. For teams with a mature design system and strict brand guidelines, that's a feature. For anyone who just needs a modal that doesn't look terrible on Tuesday morning, it's friction.
Headless UI: The Tailwind-Friendly Middle Ground
Headless UI (maintained by the Tailwind Labs team) covers a narrower set of components — Combobox, Disclosure, Listbox, Menu, Dialog, Popover, Switch, Tabs, Transition. That's it. Not trying to be everything.
It's built around the assumption that you're writing Tailwind utility classes. The className prop works exactly as you'd expect, and the render prop / as prop pattern lets you swap out the underlying HTML element. In Headless UI v2.x (released alongside Tailwind v4.0.2), they dropped the dependency on @headlessui/react's internal Transition component in favour of native CSS transitions, which means smaller bundles.
If your team is already living in Tailwind and you only need a handful of interactive components, Headless UI is the least-overhead choice. It doesn't fight you. It also doesn't grow with you — if you need a Date Picker or a Data Table, you're on your own.
shadcn/ui: Copy-Paste Components, Not a Package
shadcn/ui isn't a traditional npm dependency. You run npx shadcn@latest add button and it writes the component source directly into your components/ui/ directory. You own the code. That's the whole idea.
Under the hood it's Radix primitives styled with Tailwind classes and class-variance-authority for variant management. The default design tokens use CSS variables — --background, --foreground, --primary, etc. — so theming is a matter of swapping variable values in your CSS root. It integrates cleanly with Tailwind's config and doesn't need a separate theme file.
The trade-off is that updates are manual. When shadcn ships a fix to the Button component, you diff it yourself and decide what to take. Some teams see that as a problem. Others see it as avoiding surprise breaking changes on deploy. You can decide which camp you're in.
For projects where design consistency matters and you want a working UI fast, shadcn is genuinely hard to beat right now. Check out how it compares to paid alternatives like Tailwind UI if budget is a factor.
Theming and Customisation: Real Differences
Here's where the libraries diverge most obviously in day-to-day use. With raw Radix, customisation is total — you write the CSS, full stop. With Headless UI, you're writing Tailwind classes directly on the component, so customisation is just... writing Tailwind. With shadcn/ui, you have two layers: the CSS variable system for global tokens and the className prop for per-instance overrides.
The shadcn variable system looks like this:
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--border: 217.2 32.6% 17.5%;
}These feed directly into Tailwind's config via hsl(var(--primary)). You can build a dark mode toggle in about 15 lines. For a practical implementation, see this guide on adding a theme toggle in React — the same CSS variable approach applies.
Bundle Size and Performance Numbers
Radix ships individual packages. @radix-ui/react-dialog is around 12kB minified before tree-shaking. You only pay for what you import. Headless UI v2.x ships as a single package (@headlessui/react) at roughly 35kB minified — heavier but still reasonable. shadcn/ui has no bundle footprint of its own because the code lives in your repo.
In practice, the performance difference between these three is not what decides projects. Your image pipeline, your render strategy, your JS waterfall — those matter more. That said, if you're shipping to extremely latency-sensitive markets on mobile, Radix's per-package model gives you the most surgical control.
What actually affects performance is how you integrate these into your framework. Running on Next.js with server components? Only Radix and shadcn/ui components marked 'use client' will hydrate on the client. Headless UI is client-only by design — every component needs the 'use client' directive. If you're choosing between Next.js and Remix for your stack, that distinction matters for your component strategy.
Using Radix Primitives Directly: A Realistic Example
To make this concrete — here's a minimal Radix dialog without shadcn, styled with Tailwind, the way you'd write it if you wanted full control:
import * as Dialog from '@radix-ui/react-dialog';
export function ConfirmDialog({
open,
onOpenChange,
onConfirm,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onConfirm: () => void;
}) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md rounded-xl bg-white p-6 shadow-xl gap-4 flex flex-col">
<Dialog.Title className="text-lg font-semibold text-gray-900">
Are you sure?
</Dialog.Title>
<Dialog.Description className="text-sm text-gray-500">
This action can't be undone.
</Dialog.Description>
<div className="flex justify-end gap-2 mt-2">
<Dialog.Close asChild>
<button className="px-4 py-2 text-sm rounded-lg border border-gray-200 hover:bg-gray-50">
Cancel
</button>
</Dialog.Close>
<button
onClick={onConfirm}
className="px-4 py-2 text-sm rounded-lg bg-red-600 text-white hover:bg-red-700"
>
Confirm
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}Notice there's no magic. No theme provider, no CVA, no global setup. Just Radix's accessibility primitives and your Tailwind classes. The data-[state=open] selectors handle animation states without any JavaScript. This is roughly 40 lines you'd otherwise spend a week getting right with a raw <div>.
Which One Should You Actually Use
For most teams shipping product in 2026: shadcn/ui. It's the shortest path from zero to a working, accessible, themeable UI. The component ownership model turns out to be practical rather than annoying. You'll thank yourself when you need to add a non-standard variant at 11pm before a demo.
For design systems teams or anyone building a component library to distribute: Radix directly. The extra setup pays off when you need to control every pixel across a brand family. Don't pull in shadcn's opinions if you're going to fight them.
For Tailwind-first teams that need just a handful of interactive widgets and want minimal surface area: Headless UI is clean and easy to reason about. It doesn't try to do too much.
Worth noting — if you're looking at more than just the primitive layer, there are full-featured alternatives like Empire UI that ship 40 visual styles including glassmorphism, neon, and brutalism presets on top of accessible foundations. The right tool depends on what you're actually building. And that, ultimately, is the honest answer.
FAQ
Yes. shadcn/ui uses Radix primitives for accessibility and interactivity, then layers Tailwind CSS classes and class-variance-authority on top. When you add a shadcn component, you're getting Radix under the hood with pre-written styles you can edit directly in your own codebase.
Yes. Headless UI v2.x was updated alongside Tailwind v4.0.2 and no longer relies on the older Transition component — it delegates to native CSS transitions instead. You'll want @headlessui/react v2.1 or later to avoid compatibility issues.
Partially. Components that use Radix's interactive primitives (Dialog, Dropdown, Tooltip, etc.) require the 'use client' directive since they rely on browser APIs and React state. Static components like Card or Badge can work in server components without that directive.
Nothing happens automatically — that's by design. You can re-run the add command to see the updated source, then manually diff it against your version. It's more work than a package update but it means a library update can never silently break your UI.
Yes. Radix is built with WAI-ARIA patterns as a first-class requirement. It handles focus trapping in modals, keyboard navigation in dropdowns and tabs, roving tabindex in radio groups, and ARIA attribute management. You're responsible for visual styling but not for accessibility semantics.
Absolutely. Since shadcn/ui just writes component files into your project, there's no conflict with importing Radix packages directly elsewhere. Many teams use shadcn for common widgets and drop down to raw Radix for highly custom components that don't fit the shadcn pattern.