Why Developers Are Obsessed with shadcn/ui

I explain what shadcn/ui's copy-paste model actually means, how Radix UI handles accessibility under the hood, and what changes when you own your UI code.

Why Developers Are Obsessed with shadcn/ui

The first time I ran npx shadcn@latest add button, I expected a package to show up in node_modules. Instead, a TypeScript file landed in my src/components/ui/ folder. No new entry in package.json, no hidden dependency cache. Just the actual component code sitting in my project like any file I’d written myself.

That detail is easy to miss. The official docs say it plainly: “This is not a component library. It is how you build your component library.” But that sentence slides off the brain the first time you read it, especially if you’ve spent years doing npm install and import { Button } from 'some-library'. The model is genuinely different, and understanding it changes how you approach building reusable React components entirely.

I’m going to walk through what the copy-paste model actually means, what’s underneath it with Radix UI and Tailwind, and why this approach shifts how you think about owning your UI code.

On this page

What makes it different from traditional component libraries

With most component libraries, the deal is familiar: install a package, import what you need, and the library owns the code. That works well until you need to change something the library didn’t anticipate.

I’ve hit this wall with several libraries. You want a button with a loading spinner, but the loading state only supports the library’s own spinner component. You want a modal to trap focus differently on mobile. You want the select dropdown to close on a specific event that wasn’t part of the spec. So you wrap the component, write CSS overrides, fight the specificity cascade, and end up with something more tangled than if you’d started from scratch.

shadcn/ui’s approach: stop treating UI code you’re going to customise as something you shouldn’t touch. The principles it’s built on are open code (the component source is yours to modify), composition (all components share a consistent, predictable interface), and beautiful defaults (sensible Tailwind classes so you start from a good place rather than zero).

The analogy that clicked for me: traditional libraries hand you a finished product with a warranty. shadcn/ui gives you a well-built starting point and immediately signs the deed over to you.

How the copy-paste model actually works

When you run shadcn add, the CLI reads a component schema from the shadcn registry, fetches the template, resolves its dependencies, and writes the component file into your project. The moment that file lands, it belongs to you.

npx shadcn@latest add button dialog

After this command runs, you’ll find button.tsx and dialog.tsx in src/components/ui/ (or wherever components.json points). These aren’t symlinks. They’re TypeScript source files under your version control, readable and editable like anything else you’ve written.

Here’s a simplified version of what the button looks like after add:

import * as React from "react"
import { Slot } from "radix-ui"
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 transition-colors",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground",
        outline: "border border-input bg-background hover:bg-accent",
        ghost: "hover:bg-accent hover:text-accent-foreground",
      },
      size: {
        default: "h-9 px-4 py-2",
        sm: "h-8 px-3 text-xs",
        lg: "h-10 px-8",
      },
    },
    defaultVariants: { variant: "default", size: "default" },
  }
)

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button"
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)

You can read how this component works. If you need a fifth variant, you add a key to the variants object — no wrapper, no override, no fighting an API. The actual npm dependencies that install are for the primitives (radix-ui) and styling utilities (class-variance-authority). The component code itself is not a package. It’s just code.

Check this before moving on

  • You understand that button.tsx is a file in your project, not a package in node_modules
  • You can add a new variant to buttonVariants directly in the file
  • You know the difference between the component code (yours) and its npm dependencies (Radix, CVA)

Radix UI: the accessible layer you didn’t write

This is the part worth paying attention to, and it’s easy to skim past.

Building a truly accessible dialog from scratch is a multi-day task. You need correct ARIA roles, focus trapping that works across browsers and screen readers, keyboard navigation (Tab, Escape, arrow keys for specific components), a portal that renders outside the trigger’s DOM position, and scroll-lock behavior on the body. Most teams either skip the hard parts or implement them badly. The result is modals that work with a mouse but are unusable with a keyboard.

Radix Primitives are a low-level component library built around one goal: correct accessible behavior without any visual opinion. Each primitive handles the WAI-ARIA patterns for its component type. Dialog, Dropdown, Select, Tooltip, Accordion, Popover — all with correct focus management, keyboard navigation, and ARIA attributes already built in.

shadcn/ui wraps these primitives with Tailwind styling and exposes them as the components in your project. When you run shadcn add dialog, the file you get imports from radix-ui and adds the visual layer on top:

import { Dialog as DialogPrimitive } from "radix-ui"

const DialogContent = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
  <DialogPrimitive.Portal>
    <DialogPrimitive.Overlay className="fixed inset-0 bg-black/80 data-[state=open]:animate-in" />
    <DialogPrimitive.Content
      ref={ref}
      className={cn(
        "fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]",
        "bg-background p-6 shadow-lg duration-200",
        className
      )}
      {...props}
    >
      {children}
    </DialogPrimitive.Content>
  </DialogPrimitive.Portal>
))

The accessibility behavior you’d spend a week getting right is already handled by Radix. Your component file handles the visual layer. That’s a clean division of responsibility, and it means you can customise the styles freely without accidentally breaking the keyboard or screen reader behavior.

Tailwind and CSS variables: the theming story

The question I hear most is: “how do I make it match my brand?” The answer is CSS variables, and the approach is more elegant than I expected going in.

When you run shadcn init, a set of semantic tokens lands in your globals.css:

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --primary-foreground: oklch(0.985 0 0);
  --border: oklch(0.922 0 0);
  --radius: 0.625rem;
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --primary: oklch(0.922 0 0);
  --primary-foreground: oklch(0.205 0 0);
}

These tokens map directly to Tailwind utilities. bg-primary resolves to var(--primary). text-foreground resolves to var(--foreground). Dark mode works by overriding the same variables under a .dark selector — no per-component dark-mode class.

To theme your app, you change these variables. Update --primary to your brand color, adjust --radius for sharper or softer corners, and every component that references primary updates automatically. You never touch the component files to rebrand.

The semantic naming also makes the component code readable. A class like bg-primary text-primary-foreground tells you what the element is supposed to do, not which hex value it happens to use today. Compare that to bg-zinc-900 text-zinc-50 dark:bg-zinc-50 dark:text-zinc-900 — which requires mental decoding every time you encounter it.

For React projects and Next.js applications, this setup is straightforward to wire up. The tailwind.cssVariables: true setting in components.json is the default and the right choice for any project where you plan to maintain a consistent look across more than a handful of screens.

Where the copy-paste model creates friction

I want to be honest about the trade-offs, because they’re real and worth knowing before you commit.

Upstream updates are manual. If shadcn ships a bug fix or improvement to the button component, you don’t receive it automatically. You re-run shadcn add button --overwrite, review the diff between the new template and your modified version, and merge your customizations back. For teams that’ve stayed close to the defaults, this is low-effort. For teams that’ve made deep modifications to many components, it’s meaningful work — especially if you’re not in the habit of clear Git commit messages on your UI changes.

Team conventions matter more than with a library. When a component lives in node_modules, developers instinctively treat it as off-limits. When it lives in src/components/ui/, the boundary is much less obvious. New team members need to understand which files are “generated starting points” versus “owned business logic.” Without a clear convention, the UI folder drifts.

It doesn’t map cleanly to distributed design systems. If you’re building a component library that multiple apps consume — and those apps need to stay in sync — the copy-paste model means maintaining those copies manually. That’s exactly where a proper versioned package makes more sense. shadcn/ui is built for the team that owns both the design system and the app it lives in.

If you’re newer to TypeScript component APIs, the combination of class-variance-authority for variant definitions and CSS variables for tokens adds some learning surface. It’s worth the investment, but the initial setup isn’t zero-friction, and it helps to understand what each piece is doing before wiring it together.

Your codebase now owns the component

There’s a perspective shift that happens after you’ve worked with this model for a while. You stop thinking of UI code as something that exists “outside” your application.

With npm install some-library, there’s an implicit handoff. Their code, your usage. When something doesn’t behave the way you need it to, you either wait for a release, open an issue, or work around it. The library’s decisions about component behavior are not yours to change.

Once the components live in src/, that handoff disappears. The dialog component is as much yours as your data-fetching hooks. You can read it, debug it in your IDE, step through it with a debugger, extend it, test it, and document it the same way you do your own code. There’s no abstraction to fight.

The practical benefit for TypeScript-heavy projects is real: because the files are local, your TypeScript compiler sees the full source, IDE autocomplete works without type stubs, and refactoring across component and usage is a single operation.

I’d reach for shadcn/ui for any project where you expect to build a consistent UI with real customization needs — not just brand colors, but component behavior and variant structure. If you’re building a quick prototype that doesn’t need a design system, plain Tailwind gets you there faster. But for anything that grows past a handful of screens and has a design to maintain, owning your component code ends up being the lower-maintenance option over time, not the higher one. The initial investment pays back the moment you need to change something a library would have locked you out of.

Four geometric rectangles arranged in a two-by-two grid, two filled dark, one filled with an amber accent, and one light, with small dashed-border blocks outside the grid connected by arrows — representing modular UI components being added to a project.

The copy-paste model: discrete component blocks that flow into your project and stay there under your control.

Sources