Skip to main content

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.

Comments

Stay Updated

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