← Back to articles
Design & Motion shadcn/uiTailwind CSSReact

shadcn/ui: Building Polished Interfaces Without the Component Library Lock-In

· 4 min read

Component libraries solve a real problem: shipping consistent UI fast. But they create another one. The moment you need to tweak a hover state, change an animation, or override an internal layout, you’re fighting the abstraction instead of writing CSS. shadcn/ui takes a different approach. It’s not a package you install. It’s a collection of components you copy into your project, built on Radix UI primitives and Tailwind CSS, that you own and modify freely.

Components you actually own

The shadcn CLI scaffolds components directly into your codebase. There’s no node_modules dependency to upgrade, no breaking changes to track, no version pinning. The component lives in your repo as a regular TypeScript file.

npx shadcn@latest add button dialog tabs

This generates files in your components/ui/ directory. Each component is a thin wrapper around a Radix primitive with Tailwind classes applied. A Button is ~30 lines. A Dialog is ~50. There’s nothing hidden.

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors',
  {
    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',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
      },
    },
    defaultVariants: { variant: 'default', size: 'default' },
  }
);

cva (class-variance-authority) handles variant logic cleanly. Adding a new variant or modifying an existing one is just editing a plain object.

Theming with CSS variables

shadcn/ui uses CSS variables for theming instead of Tailwind’s config-based approach. This makes runtime theme switching trivial and keeps the design system consistent across every component.

:root {
  --background: 0 0% 100%;
  --foreground: 222.2 84% 4.9%;
  --primary: 222.2 47.4% 11.2%;
  --primary-foreground: 210 40% 98%;
  --muted: 210 40% 96.1%;
  --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%;
}

Every component references these variables through Tailwind classes like bg-primary and text-muted-foreground. Changing the entire color palette means editing a few lines of CSS, not hunting through dozens of component files.

Accessibility built in

Radix UI handles the hard parts of accessible components: focus management, keyboard navigation, ARIA attributes, and screen reader announcements. A shadcn Dialog traps focus correctly, closes on Escape, and announces its content to assistive technology. A Select supports full keyboard navigation with arrow keys, type-ahead, and proper listbox semantics.

<CommandDialog open={open} onOpenChange={setOpen}>
  <CommandInput placeholder="Search..." />
  <CommandList>
    <CommandGroup heading="Pages">
      <CommandItem onSelect={() => navigate('/about')}>About</CommandItem>
      <CommandItem onSelect={() => navigate('/blog')}>Blog</CommandItem>
    </CommandGroup>
  </CommandList>
</CommandDialog>

This command palette is fully keyboard-accessible out of the box. Focus is trapped in the dialog, items are navigable with arrow keys, and the search input filters results in real time. Building this from scratch with correct accessibility would take days.

Composing complex interfaces

Where shadcn/ui shines is composition. Because every component is a regular React component with Tailwind classes, combining them feels natural. A data table with sorting, filtering, and pagination is built by composing Table, Input, Select, and Button components that all share the same design tokens.

<div className="flex items-center gap-2 py-4">
  <Input
    placeholder="Filter by name..."
    value={filter}
    onChange={(e) => setFilter(e.target.value)}
    className="max-w-sm"
  />
  <Select onValueChange={setStatus}>
    <SelectTrigger className="w-40">
      <SelectValue placeholder="Status" />
    </SelectTrigger>
    <SelectContent>
      <SelectItem value="active">Active</SelectItem>
      <SelectItem value="archived">Archived</SelectItem>
    </SelectContent>
  </Select>
</div>

No prop drilling through a monolithic DataTable component. No render props. No slot APIs. Just components composed the way React intended.

Conclusion

shadcn/ui works because it removes the abstraction layer between your design and your code. You get accessible, well-designed components as a starting point, then you own every line from there. For React projects using Tailwind CSS, it’s the fastest path to a polished interface that doesn’t feel generic and doesn’t lock you into someone else’s API.