shadcn/ui: Copy-Paste Components as an Architecture

Photo by Tombartal on Wikimedia Commons
shadcn/ui does not ship as an npm package you import at runtime. Its CLI copies the actual source code of a component into your own repository, so there is no shared dependency to version, patch, or fight with a theme API. You end up editing plain files rather than overriding a black box.
Radix UI Primitives supply the hard part of a component, unstyled, accessible interaction logic that follows WAI-ARIA patterns for keyboard navigation, focus management, and screen reader roles. shadcn/ui wraps those primitives with Tailwind classes, so accessibility behavior is inherited from Radix while the visual styling stays fully editable in your copied code.
Theming runs through CSS variables rather than a JavaScript theme object. Tailwind's config maps utility classes like bg-primary to a variable such as hsl(var(--primary)), so changing one CSS variable updates every component that reads it. Dark mode works the same way by swapping the active variable set when a dark class toggles.
Yes, and that is one of the main advantages of owning the code. Since the component is a plain file in your repository rather than a package export, you can strip out class-variance-authority, Radix Slot, or any other piece and replace it with your own logic without waiting on an upstream release.
Not universally. It fits teams that expect deep visual customization and have a design system owner reviewing changes to shared components. It fits less well for teams that need a security or accessibility fix to propagate instantly across every consuming app, since that scenario favors a version-bumped dependency over manually patched copied files.

Photo by Tombartal on Wikimedia Commons
Every few years a component library ships a major version, and every team that depended on it inherits the same afternoon: reading a changelog, rewriting theme overrides, and discovering that the button they never touched now renders two pixels off. That is the cost of installing someone else's design decisions as a dependency.
shadcn/ui takes a different position. Instead of shipping a package you import, it hands you the actual source of each component and lets a CLI copy that source into your own repository. The result is not a smaller library, it is a different architecture entirely, one built on Radix UI primitives for behavior and Tailwind CSS tokens for theming, and it changes how a design system scales as a codebase grows.
The shadcn/ui documentation states its position plainly: it is not a component library, it is how you build your own component library. Running the CLI's add command does not add an entry to package.json that resolves to a compiled bundle. It writes a plain TypeScript file into your components folder, fully readable, fully yours, with no version to track and no black box to fight when a design requirement does not match the library's assumptions.
Traditional libraries like Material UI or Ant Design solve a different problem: consistent behavior across thousands of consumers, achieved through prop APIs and theme objects that must stay backward compatible. That backward compatibility constraint is exactly why upgrades hurt. shadcn/ui sidesteps the constraint by never promising compatibility in the first place, because after the copy step there is no shared package left to be compatible with.
The CLI stays useful even after you own the code. Treat it as a scaffolding tool you run once per component, not a dependency manager you run on every install. Re-running add on a component you have already customized will overwrite your edits, so commit before you run it again.
Interaction logic is the hardest part of a component to get right, not the visual styling. Radix UI Primitives supply that logic as unstyled, accessible building blocks that follow WAI-ARIA authoring patterns, handling keyboard navigation, focus trapping inside dialogs, and correct roles for screen readers. shadcn/ui wraps each Radix primitive in Tailwind utility classes, so the accessibility work is inherited rather than reimplemented, while the visual layer stays fully editable.
The button below shows the pattern in miniature. It is not wired to a Radix primitive directly, but it demonstrates the two pieces every shadcn/ui component leans on: Slot from Radix, which lets the component render as a different underlying element without an extra wrapper node, and class-variance-authority, which turns variant props into a typed lookup of Tailwind class strings instead of a pile of conditional expressions.
// components/ui/button.tsx (installed via CLI, then edited freely)
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 transition-colors",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: "border border-input bg-background hover:bg-accent",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3",
},
},
defaultVariants: { variant: "default", size: "default" },
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot : "button"
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />
)
}
export { Button, buttonVariants }Because this file lives in your repository, adding a new variant, say a destructive-outline combination the original design never shipped, is a local edit and a pull request, not a feature request against someone else's roadmap. The same applies to removing dependencies you do not want: strip out class-variance-authority and hardcode the class strings if a team prefers fewer moving pieces.
shadcn/ui components never hardcode a hex color. Every visual property routes through a small set of CSS variables, background, foreground, primary, primary foreground, and a handful more, expressed as HSL triples so they can be referenced inside a Tailwind color function. Tailwind's config then maps a class like bg-primary to hsl(var(--primary)), which means the same class name resolves to different pixels depending on which variables are active.
/* app/globals.css — tokens consumed by every shadcn/ui component */
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
}That single indirection layer is what makes rebranding tractable at scale. Changing a product's accent color means editing one CSS variable once, not searching every component file for a stray hex code. Dark mode works the same way: toggling a dark class on the html element swaps the active variable set, and every component that already reads var(--background) picks up the new palette with zero component-level code changes.
The token layer is shared, which means a mistake there is shared too. If a chosen foreground-on-background pairing fails WCAG contrast, every component built on that token inherits the failure at once. Check contrast ratios for both the light and dark variable sets before shipping a token change, not just the light set you happened to be looking at.
The honest comparison against a traditional dependency model is not library versus no library, it is who carries the maintenance cost and where the bundle weight goes. The table below lines up the three dimensions that matter most in practice.
| Dimension | Traditional dependency | shadcn/ui copy-paste |
|---|---|---|
| Updates | A version bump can change behavior or styling without your team choosing to change anything. | Nothing changes until your team edits the file, on your schedule. |
| Bundle size | Ships the library's abstractions even after tree-shaking, since some runtime glue always remains. | Ships only the specific components you copied, with no unused variant logic bundled in. |
| Customization depth | Limited to whatever the theme API or prop overrides expose. | Limited only by what you are willing to rewrite in the source file. |
The tradeoff is that maintenance responsibility moves from the library maintainer to your own team. A security-relevant fix in Radix still arrives through your package manager, but a fix to shadcn/ui's own wrapper code has to be applied by hand, file by file, across however many components you copied it into. That is a reasonable price for a team with code review discipline and a design system owner, and a real risk for a solo project with neither.
Copy-paste ownership only stays manageable if a team treats the copied code with the same discipline as any other internal library. A few habits keep it from turning into unmanaged drift across dozens of components.
shadcn/ui's newer CLI versions support namespaced registries, letting a team publish its own internal set of components the same way the public shadcn/ui registry works. That closes the biggest gap in the copy-paste model: instead of every repository re-copying and re-diverging the same button, a company can maintain one internal registry and have every product pull from it with the same add command.
This architecture is a genuine tradeoff, not a strictly better option, and it fits some situations far better than others.
In practice, most product teams land somewhere in between: they copy shadcn/ui's primitives for full control over the visual layer, while still pulling Radix UI itself as a real npm dependency for the accessibility-critical interaction logic underneath. Ownership where customization matters, dependency where correctness matters.
shadcn/ui is not trying to win a smaller-bundle contest against traditional libraries, it is proposing that a design system should be code your team owns outright rather than an API surface someone else controls. Radix supplies the accessible behavior, Tailwind tokens supply the theme, and the CLI supplies the one-time hand-off. What a team does with the code after that hand-off is what actually determines whether the architecture scales.
Sources and further reading: