All posts
9 min read

Why the Copy-Paste Component Model Beats the Library

shadcn/ui didn't win by shipping prettier buttons. It won by changing the unit of distribution from a package you depend on to source code you own. Here's the architecture underneath it, and the honest trade-offs.

shadcnarchitecturereact

Every UI library starts as a gift and ends as a negotiation.

The gift: you run one install command and forty polished components appear. The negotiation: three months later a designer wants the dropdown's chevron to rotate a little slower, the focus ring to be two pixels tighter, and the whole thing to survive a Tailwind major upgrade. Now you're reading the library's source on GitHub anyway, except you can't touch it. You can only wrap it, override it, or fork it.

That gap between using code and owning code is the entire argument. shadcn/ui didn't win the last few years because its components looked better than Material UI or Chakra. It won because it changed what gets distributed. Not a package. The source itself.

Let me make the case properly, including where this model genuinely loses.

The tax you pay for someone else's abstraction

A traditional component library is a compiled dependency. You import from it, you configure it through the surface it chooses to expose, and everything else is sealed. That seal has a price, paid in four currencies.

The versioning tax. The library's internal DOM structure is an API you depend on without a contract. A patch release re-nests a wrapper div, and the selector you were relying on silently stops matching.

The override tax. Because you can't edit the component, you fight it from the outside, and you almost always lose the specificity war on the first try:

css
/* What you hoped would work */
.my-card { border-radius: 4px; }

/* What you actually shipped after the library's styles won */
.my-app .MuiCard-root.MuiPaper-rounded {
  border-radius: 4px !important;
}

Every !important in your codebase is a small confession that you don't control the markup you're styling.

The theming tax. To stay overridable, libraries invent configuration APIs: theme objects, sx props, style engines, provider trees. You end up learning a bespoke abstraction on top of CSS, just to express things CSS already says:

tsx
// Learning the library's dialect to change a color
<ThemeProvider
  theme={createTheme({
    components: {
      MuiButton: {
        styleOverrides: { root: { textTransform: "none" } },
      },
    },
  })}
>
  <App />
</ThemeProvider>

The bundle tax. You installed the whole kit to use six components. Tree-shaking helps, but shared runtime, style engines, and context providers rarely shake all the way out.

The pattern behind all four

Each tax comes from the same root cause: the code that decides how your button looks lives in node_modules, and node_modules is read-only. Every workaround is an attempt to influence code you're not allowed to edit.

A different unit of distribution: source, not packages

The registry model flips the boundary. Instead of publishing a package you install, it publishes source files you copy in. After you add a component, it lives in your repository, in your directory, in your code style:

bash
npx shadcn add button

That command doesn't add a component library to your package.json. It writes a source file into your project. To be precise about what happens: the CLI does install the two small primitives that file imports (@radix-ui/react-slot and class-variance-authority) as ordinary npm dependencies, because those are behavior, not markup. The component itself, the part you'll actually want to change, lands as editable code:

tsx
// components/ui/button.tsx: now yours, in your repo, editable
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 focus-visible:ring-2 disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        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", lg: "h-10 px-6" },
    },
    defaultVariants: { variant: "default", size: "default" },
  }
);

export function Button({
  className,
  variant,
  size,
  ...props
}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>) {
  return (
    <button className={cn(buttonVariants({ variant, size, className }))} {...props} />
  );
}

There's nothing clever hidden here. No style engine, no theme context, no runtime you didn't choose. It's Tailwind classes and a variance helper: code you can read top to bottom in thirty seconds and change without asking anyone.

What "owning the code" actually buys you

Say you need a new brand button variant. In the library world that's a theme-override incantation or a wrapper component. Here it's a two-line diff to a file you already have open:

diff
       variant: {
         default: "bg-primary text-primary-foreground hover:bg-primary/90",
         outline: "border border-input bg-background hover:bg-accent",
         ghost: "hover:bg-accent hover:text-accent-foreground",
+        brand: "bg-emerald-600 text-white hover:bg-emerald-500 shadow-sm",
       },

No specificity war, because there's no external stylesheet to out-rank. No wrapper, because you edited the component itself. No !important, because you own the cascade. The change reads exactly like the code around it, and that is the whole point: your UI kit stops being a foreign object in your codebase and becomes your codebase.

The reason this stays sane rather than turning into chaos is the shared foundation underneath. Every component is Tailwind classes bound to CSS variables. The variables (--primary, --background, --accent) live in one file. Retheme the entire system by editing values in that one place; reach into an individual component only when you want that specific thing to diverge.

How the registry actually works under the hood

The magic word "registry" is less magical than it sounds. A registry item is just JSON describing which files to write and what the component needs:

json
{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hero7",
  "type": "registry:block",
  "registryDependencies": ["button", "badge"],
  "files": [
    {
      "path": "registry/hero7/hero7.tsx",
      "type": "registry:component",
      "target": "components/beste/hero7.tsx"
    }
  ]
}

When you run npx shadcn add <url>, the CLI fetches that JSON, resolves registryDependencies recursively (pulling in button and badge if you don't have them), installs anything listed under dependencies from npm, and writes each file to the target path, respecting the aliases in your components.json. Because the payload is just a URL returning JSON, anyone can host a registry. It isn't a walled marketplace; it's an open protocol. That's why the ecosystem exploded: distribution costs nothing but a public endpoint.

Since CLI 3.0 you don't even need to paste raw URLs. You can register third-party registries under a namespace in components.json and address items the way you'd address npm packages:

json
{
  "registries": {
    "@beste": "https://ui.beste.co/r/{name}"
  }
}
bash
npx shadcn add @beste/hero7

Here's the same idea as a comparison, dimension by dimension:

DimensionLibrary (package)Registry (source)
Lives innode_modules (read-only)Your repo (editable)
Customize byConfig API / overridesEditing the file
Breaking changesArrive on npm updateOnly when you choose to re-copy
BundleShips the whole kitShips what you actually used
StylingBespoke theme layerPlain Tailwind + CSS variables
CeilingThe library's flexibilityWhatever you can write

Proof: a real section, live

This isn't a screenshot. The block below is the real hero7 component, rendered live in an isolated frame. It is the same source you'd get from npx shadcn add:

Live · Centered Hero with Media
View block
hero7, running live. Not a picture of one.

Because you own its source after adding it, a change like tightening the eyebrow spacing or swapping the CTA label is a normal edit to a normal file. Compare that to the equivalent in a sealed library: a wrapper, a prop you hope exists, or a styled-component override that breaks on the next release.

Here's a denser one: a feature grid with icon tiles. Same story: real component, yours to edit line by line.

Live · Service Grid With Icons
View block
feature3: icons, layout, and copy are all just props on source you control.

The honest trade-offs

A model this good would be suspicious if it had no downside. It has real ones, and pretending otherwise is how you get burned.

You inherit maintenance. There is no npm update that quietly ships a bug fix to your copied component. If the upstream fixes an accessibility issue next month, you won't get it unless you re-copy and re-merge. The saving grace is that the files live in git like everything else: re-run the same add command and review the incoming changes as a normal diff. You traded automatic updates for total control. That's a genuine trade, not a free lunch.

Copies drift. The moment you edit a component, it forks from its origin. Multiply that across a team and you can grow twelve slightly different buttons if nobody's watching. The antidote is discipline: treat components/ui as your design system, not a scratchpad, and change primitives in one place.

More surface in your repo. The code is now yours to read in reviews, lint, and test. That's usually a feature (visible instead of hidden), but it is more code under your name.

The failure mode to watch

The registry model punishes teams without conventions. If every engineer copies-and-tweaks in isolation, you get sprawl. Decide up front which components are shared primitives (edit centrally) and which are one-off compositions (copy freely). That single rule prevents 90% of the mess.

When a library is still the right call

Ownership is the right default for markup and styling. It is the wrong tool for behavior and algorithms.

You do not want to copy-paste a date-math implementation, a virtualized-list windowing engine, a data-fetching cache, or the focus-trapping logic behind an accessible dialog. That's dense, well-tested behavior where the internal complexity is the product, and where a subtle bug is expensive. Those belong in packages, versioned and updated normally:

tsx
// Keep behavior in libraries. You don't want to own this logic.
import { useFloating, autoUpdate } from "@floating-ui/react";
import { useQuery } from "@tanstack/react-query";

Notice that shadcn-style components lean on exactly this split: the markup is copied source you own, while the behavior (Radix primitives, Floating UI) stays a dependency. The heuristic that falls out is clean:

Distribute behavior as packages. Distribute markup as source.

Get that line right and you get the best of both: sealed, battle-tested logic where correctness is hard, and open, editable presentation where expression matters.

Where this leaves you

The copy-paste model isn't "shadcn is trendy." It's a deliberate answer to a decade of override wars: move the presentation layer out of node_modules and into your hands, keep the gnarly behavior in packages, and let CSS variables hold the whole thing together.

That's the model Beste UI is built on. Every block on this site (heroes, feature grids, pricing tables, the section you just saw render) is source you install with npx shadcn add and own the moment it lands. Same primitives underneath, same tokens, no lock-in.

Browse the blocks, add one, and change a line. The fact that you can is the entire argument.