I22 · Part 2 · Intermediate
shadcn/ui
Not a component library you install, but components whose source you copy into your project and own.
07On this page
What it actually is
Most UI kits are packages: you install @mui/material, import a
Button, and customise it through props and theme overrides.
shadcn/ui is the opposite. There is no package to import. A command-line tool
copies the component's source file into your repository, and from that
moment it is your code. In its own words, it is "how you build your component
library".
npx shadcn@latest init // writes components.json, adds the utilities and CSS variables
npx shadcn@latest add button dialog // copies components/ui/button.tsx and dialog.tsx
If you want a button to behave differently, you open button.tsx and
change it. There is no override API to learn, no wrapper to write, and no
waiting for a maintainer to expose a prop. That single property explains both
why people like it and where its costs are.
The stack underneath
| Layer | What does it |
|---|---|
| Behaviour and accessibility | A headless primitives library — Base UI or Radix UI. Focus trapping, keyboard handling, ARIA and portals live here. |
| Styling | Tailwind CSS classes, driven by semantic CSS variables such as --background and --primary |
| Variants | Usually class-variance-authority (cva) for size and variant props |
| Class merging | A small cn() helper: clsx to combine, tailwind-merge to resolve conflicts |
// lib/utils.ts
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
cn("px-2 py-1", "px-4") // "py-1 px-4" — the later padding wins, instead of both applying
cn exists because Tailwind classes are not ordered by where they
appear in the attribute; two conflicting utilities are resolved by stylesheet
order, which is arbitrary from your point of view. tailwind-merge
knows which classes conflict and keeps the last.
Base UI is now the default
Since July 2026 a new project initialises with Base UI as its
primitives; Radix remains fully supported and every update ships for
both. Choose it with npx shadcn@latest init -b radix, and the
docs show tabs for each. Existing Radix projects need no migration. The
practical difference you will meet is composition: Radix components take an
asChild prop to render as your own element, while Base UI uses a
render prop for the same job.
components.json
{
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": { "css": "app/globals.css", "baseColor": "neutral", "cssVariables": true },
"aliases": { "components": "@/components", "ui": "@/components/ui", "utils": "@/lib/utils" }
}
The file tells the CLI where things go and how to write them. rsc
makes it add "use client" where a component needs it. The
aliases decide where files land and how imports are rewritten.
cssVariables: true produces semantic tokens —
background, foreground, primary —
which is what makes a dark theme a second set of values rather than a second
set of components. The baseColor cannot be changed after
initialisation.
A component you own
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium focus-visible:ring-2 disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: "border bg-background hover:bg-accent",
ghost: "hover:bg-accent",
},
size: { default: "h-9 px-4", sm: "h-8 px-3", lg: "h-10 px-6" },
},
defaultVariants: { variant: "default", size: "default" },
}
);
function Button({ className, variant, size, ...props }) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}
The whole component is a table of class names and a function that picks from
it. Adding a destructive variant is one more line in a file in
your own repo. And because className goes through cn
last, a caller can still override a single style at the use site.
Registries: how it is distributed
The CLI does not talk to a private list; it reads a registry, a set of
JSON files describing components, their dependencies and where they install.
The registries field in components.json lets you add
others, including private ones with authentication. That is how a company
ships its own design system to every internal app with the same
add command — and it is why the project describes itself as
a distribution system as much as a component set.
The trade, stated honestly
| Package (MUI, Mantine, Chakra) | shadcn/ui | |
|---|---|---|
| Customising | props, theme, overrides | edit the file |
| Bug fixes and new features | bump the version | diff and merge, like any code you own |
| Bundle | tree-shaken from a dependency | only the components you added |
| Accessibility | maintained for you | inherited from the primitives; your edits are on you |
| Consistency | enforced by the API | enforced by discipline; copies drift |
| Lock-in | the library's API everywhere | none — it is your code |
The trade is control against maintenance. It suits a product with its own design language, and a team that will actually read the diff when the upstream component changes. It suits a prototype that needs to look finished by Friday just as well, which is much of why it spread. It is a poor fit when nobody wants to own UI code, or when strict consistency across many teams matters more than flexibility — that is what a versioned package is for.
Habits that keep it healthy
- Keep
components/uigeneric. Put product-specific behaviour in wrapper components beside it, so the copied files stay close to upstream and easy to update. - Do not delete the accessibility behaviour while restyling. The focus ring, the
aria-*attributes and the keyboard handlers are why you chose primitives. - Re-add to update. There is no version to bump; to take an upstream fix, run
addagain on a branch and read the diff before accepting it. - Understand what you copied. The files are short. If a dialog behaves oddly, the answer is in a file you can open, which is the whole advantage.
Interview answer, one sentence
"shadcn/ui is a distribution mechanism, not a library: a CLI copies accessible components — built on Base UI or Radix primitives, styled with Tailwind and CSS variables — into my repo, so I customise by editing source rather than through overrides, and the price is that updates and drift become my job."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series