shadcn/ui vs Radix UI: Component Library vs Primitives
·PkgPulse Team
TL;DR
shadcn/ui and Radix UI solve different layers of the same problem. Radix UI provides accessible, unstyled primitives (a dependency you install). shadcn/ui uses those same Radix primitives but copies the components directly into your project — giving you full control over every line. For most production apps, shadcn/ui is the right starting point: you own the code, you can customize without fighting a library's API, and Tailwind integration is seamless. Use Radix UI directly when you want the raw primitives without shadcn's Tailwind opinions.
Key Takeaways
- shadcn/ui is not a component library — it's a code generator that copies components into your project
- Both use Radix UI under the hood — shadcn adds Tailwind styling on top
- shadcn/ui: you own the code, full customization, no library version conflicts
- Radix UI: install once, get updates via npm, bring your own CSS
- Bundle size: only pay for what you install (
npx shadcn-ui add button= one file)
The Key Conceptual Difference
Traditional component library (e.g., MUI, Chakra, Mantine):
node_modules/
@mui/material/ ← library lives here, you don't touch it
Button.tsx ← you can't easily change the internals
Dialog.tsx ← CSS overrides fight the library
...
Your code imports and uses it. You control nothing inside.
Radix UI:
node_modules/
@radix-ui/react-dialog/ ← primitive, completely unstyled
@radix-ui/react-dropdown-menu/
...
You get accessibility + behavior. Bring your own CSS.
Still a dependency — updates come via npm.
shadcn/ui:
src/components/ui/
button.tsx ← YOUR file. In your repo. You own it.
dialog.tsx ← Copy-pasted from shadcn, now yours to modify
dropdown-menu.tsx
node_modules/@radix-ui/... ← still used underneath
The shadcn CLI copies component code into your project.
It's not a dependency. There's nothing to "upgrade."
You own every line of every component.
Installing and Using shadcn/ui
# Initialize shadcn/ui in an existing project:
npx shadcn-ui@latest init
# Prompts: style (Default/New York), base color, CSS variables (yes/no)
# Creates: components.json, adds Tailwind config updates, adds cn() utility
# Add individual components (copies file to src/components/ui/):
npx shadcn-ui@latest add button
npx shadcn-ui@latest add dialog
npx shadcn-ui@latest add dropdown-menu
npx shadcn-ui@latest add form # includes react-hook-form integration
npx shadcn-ui@latest add table
npx shadcn-ui@latest add toast
# What "add button" does:
# 1. Creates src/components/ui/button.tsx in YOUR project
# 2. Installs @radix-ui/react-slot (if not present)
# 3. That's it — the button code is now yours
# The resulting button.tsx:
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium...",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: { variant: "default", size: "default" },
}
)
// You can add a new variant by editing THIS file:
// variant: { brand: "bg-brand-500 text-white hover:bg-brand-600" }
// No library update needed. No PR to the library. Just edit the file.
Radix UI: Primitives Without Opinions
// Using Radix UI directly (without shadcn):
import * as Dialog from '@radix-ui/react-dialog';
// Completely unstyled — you bring all the CSS:
function ConfirmDialog({ onConfirm }: { onConfirm: () => void }) {
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button className="your-button-styles">Delete</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 animate-overlay-show" />
<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 w-full max-w-md">
<Dialog.Title className="text-xl font-bold">Confirm Delete</Dialog.Title>
<Dialog.Description className="text-gray-600 mt-2">
This action cannot be undone.
</Dialog.Description>
<div className="flex gap-3 mt-6 justify-end">
<Dialog.Close asChild>
<button className="your-cancel-button">Cancel</button>
</Dialog.Close>
<button onClick={onConfirm} className="your-danger-button">
Delete
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
// What Radix handles for you (accessibility):
// → Focus trapping within the dialog
// → Escape key closes dialog
// → Screen reader announcements (role="dialog", aria-modal)
// → Scroll lock on body when open
// → Portal rendering outside the DOM hierarchy
// What you provide:
// → All CSS/styling
// → Animation (or use Radix's data-state attributes for CSS animations)
// → Layout and spacing
shadcn/ui vs Radix UI: Direct Comparison
Feature shadcn/ui Radix UI
─────────────────────────────────────────────────────────
Styling Tailwind (CVA) None (unstyled)
Installation File copy (npx add) npm install
Code ownership Yours to edit Library (read-only)
Updates Re-run add command npm update
Customization Edit the file CSS + className
Design system Default + New York None
Dark mode Built-in (CSS vars) Roll your own
Accessibility Inherited from Radix Built-in
TypeScript Full types Full types
Component count 50+ pre-built 30+ primitives
Bundle impact Only what you add Only what you install
Dependency conflicts None (it's your code) Possible with major updates
Learning curve Low (Tailwind knowledge) Moderate (compose parts)
Real Customization: Why Code Ownership Matters
// Real scenario: you need a Button with a loading spinner
// shadcn/ui (you own the code):
// Edit src/components/ui/button.tsx directly:
const buttonVariants = cva("...", {
variants: {
variant: {
// ... existing variants
},
size: {
// ... existing sizes
},
// ADD THIS — no library PR needed:
loading: {
true: "opacity-70 pointer-events-none",
},
},
});
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean; // ADD THIS
loadingText?: string; // ADD THIS
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ loading, loadingText, children, ...props }, ref) => {
return (
<button ref={ref} disabled={loading || props.disabled} {...props}>
{loading && <Spinner className="mr-2 h-4 w-4 animate-spin" />}
{loading ? (loadingText ?? children) : children}
</button>
);
}
);
// In a traditional library, you'd:
// → File a GitHub issue
// → Wait for it to be accepted
// → Wait for the release
// → Update the dependency
// → Deal with any other breaking changes in that release
// OR: wrap the component in your own, fighting the library's styles
// Total time: weeks to months
// shadcn/ui approach: 15 minutes, ships today
When to Use What
Use shadcn/ui when:
→ You're on Tailwind CSS already (it's a natural fit)
→ You want to ship fast with polished components
→ You need to customize components beyond what a library API allows
→ You want zero version conflict risk on UI components
→ Building a product with a custom design system
→ The "New York" or "Default" style matches your aesthetic
Use Radix UI directly (without shadcn) when:
→ You're NOT on Tailwind (CSS Modules, CSS-in-JS, vanilla CSS)
→ You have an existing design system and just need accessible primitives
→ You want updates to flow in via npm automatically
→ You're building a component library of your own to distribute
→ You want maximum control over the HTML structure
Use neither (use Mantine, Chakra, or MUI) when:
→ You need a complete design system with theme tokens out of the box
→ Your team doesn't use Tailwind and doesn't want to manage component files
→ You need enterprise support guarantees
→ You need date pickers, complex data grids, or charts included
The 2026 recommendation:
→ For most new React projects: shadcn/ui
→ It's won the "React component library" debate for SPAs
→ GitHub stars: 65K+ and growing
→ The developer experience of owning your components beats fighting a library
→ When you need primitives for a custom system: Radix UI directly
Compare shadcn/ui, Radix UI, and other React component library download trends at PkgPulse.
See the live comparison
View shadcn vs. radix on PkgPulse →