shadcn/ui: source files you own
How shadcn/ui ships accessible React components as source files you own in your repo, not as a dependency you import.
The next screen on your roadmap needs a Dialog, a DropdownMenu, a Select, a Toast, and a Calendar: five interactive widgets you have to ship this week.
Two instincts fire immediately, and both are wrong.
The first instinct is to build them yourself.
A correct Dialog traps keyboard focus while open, returns focus to the trigger on close, closes on Escape, and announces itself to a screen reader; a correct Select is a full roving-tabindex keyboard widget with type-ahead .
That is weeks of work, and your first pass will ship focus-trap and keyboard bugs.
The second instinct is to install a styled component library like Material UI, Mantine, or Chakra and get all five in an afternoon. That is faster, but you have adopted someone else’s design language: restyling to your brand means fighting the library’s own styles, and a breaking major version forces you to rework your app on the maintainers’ schedule, not yours.
There is a third path: shadcn/ui.
It splits each widget in two.
The behavior, meaning the focus trap, keyboard handling, and ARIA wiring, comes from an audited headless primitive .
The markup comes to you as Tailwind-styled source files that land in your repository and become yours to edit.
That split is the idea the whole chapter rests on: you own the code, not a dependency.
This lesson walks you from adding a Dialog through reading, composing, and theming it, up to knowing when to wrap it and when to fork it, reusing the cva variants, asChild polymorphism, and semantic-token theming you already know.
Build, buy, or own the markup
Section titled “Build, buy, or own the markup”The three options aren’t a ranked list where one always wins. They’re a spectrum, and what separates them is who owns the markup and who owns the upgrade cadence.
Building it yourself means you own all three: the markup, the styling, and the accessibility bugs. Installing a styled library rents you the vendor’s markup and design language, which ties you to their visual decisions and release schedule. shadcn’s copy-into-repo model splits the difference: the primitive vendor owns the behavior and ships accessibility fixes you pull on your own terms, while the markup is yours to edit.
The clearest way to compare them is to ask where the seam between your code and theirs sits. A styled library puts the seam at the npm boundary: opaque, versioned, and theirs, so you reach it only through props and theme overrides and hope the escape hatches go deep enough. shadcn moves the seam into your repository, where the styled source is a file you open and edit and the hard behavioral part stays an audited dependency underneath. You’re not choosing more code or less code, but where the line between yours and theirs falls.
The walker steps through that choice in the order an experienced engineer asks the questions. Start at the root and follow a branch to a verdict.
You own the behavior and the accessibility. Reserve it for a widget no primitive covers: it’s weeks of work and a real bug surface, almost never the answer for standard widgets.
Rent the markup and the design. The coupling to the vendor’s schedule is fine here, since you’d rather not maintain UI at all and ownership would be a liability.
Own the source, with audited behavior underneath. You restyle freely because the markup is your file, and the accessibility stays solved by the primitive.
What shadcn actually is, and is not
Section titled “What shadcn actually is, and is not”Clear up one misconception first: shadcn/ui is not a component library.
No shadcn package ships <Dialog> and <Button> for you to import at runtime.
shadcn is a CLI plus a registry : you run a command, it fetches a component’s source from the registry, and it writes that source into your project as a file.
Your imports show the model at work.
You import a button from @/components/ui/button, a path inside your own src/, not a node module.
The component files live in your repository, where you can read and edit them, not in node_modules.
Here is the shape of a project after you have added a couple of components. The bold files are the ones shadcn copied in, the ones you now own.
Directorysrc/
Directorycomponents/
Directoryui/ shadcn primitives you own
- button.tsx yours to read and edit
- dialog.tsx
Directorylib/
- utils.ts
cn()lives here
- utils.ts
Directoryapp/
- globals.css semantic-token CSS variables
- components.json the config the CLI reads
So what lands in package.json when you add a component?
The copied files are real source that import things, so the CLI installs the runtime dependencies they reach for.
You have met almost all of them:
radix-uisupplies the behavior and accessibility. As of early 2026 it is a single unified package, and adialog.tsxopens withimport { Dialog as DialogPrimitive } from "radix-ui". (If the project chose Base UI at setup, this slot is@base-ui-components/react, covered shortly.)class-variance-authority(cva),tailwind-merge, andclsxsupply the variant tables from “Slot and CVA” and thecn()helper from “Composing with cn()”.tw-animate-cssdrives the dialog, sheet, and accordion animations from “Motion and animation”.lucide-reactis the default icon set: tree-shakeable, one component per icon.
These are peers the copied components import, not the shadcn library arriving under a different name.
What owning the source buys, and what it costs
Section titled “What owning the source buys, and what it costs”Copying source into your repo instead of importing a package is a deliberate trade. It buys three specific things, and each comes with a matching cost.
You can fork without filing a PR upstream.
When your design diverges past what a token change can express, you open the file and edit it.
No waiting on a maintainer to accept a pull request, no patch-package hack layered over a node module.
The cost: the moment you edit that file you’ve cut a branch from upstream, so you lose any future improvements to it, accessibility fixes among them.
The source is the documentation.
When a dropdown misbehaves, you debug it by reading dropdown-menu.tsx, the actual implementation, open in your editor at the exact path you import from.
No sourcemap archaeology into a minified bundle, no guessing at internals from the outside.
The cost: you have to read it.
Treating a shadcn component as an opaque black box throws away the main thing you paid for, and it’s the most common way teams misuse this model.
You own the upgrades.
There is no npm update shadcn, because there’s nothing to update.
To upgrade a component you re-run add for it: the tool re-fetches the current version from the registry and overwrites your file.
You read the diff, then re-apply any local edits you’d made.
The cost: an upgrade is a deliberate, reviewed act rather than a number bumping in a lockfile.
Grounded in these mechanics, the earlier verdict holds: ownership is leverage for a product and a liability for an internal tool.
Before moving on, pin down which side of the trade each statement sits on. Some are things ownership buys you; others are things it costs you.
Sort each statement into the side of the ownership trade it belongs to. Drag each item into the bucket it belongs to, then press Check.
npm update for your componentsAdding a component with the shadcn CLI
Section titled “Adding a component with the shadcn CLI”The CLI has a wide surface, but the daily workflow is two commands, and you run the first only once.
init runs once per project.
It scaffolds components.json, writes the cn() helper into lib/utils.ts, and wires the semantic-token CSS variables into globals.css.
The v4 CLI can also scaffold a whole project template, and it’s where you pick the primitive engine with a --base flag: Radix (the default) or Base UI.
add is the move you make every day.
pnpm dlx shadcn@latest add dialog copies dialog.tsx into components/ui/ and installs its peer dependencies.
The command takes a list, so add button dialog select pulls three at once.
Add on demand, not upfront.
Running add for every component at the start of a project, so they’re “ready,” bloats your bundle with widgets you don’t use and clutters your diffs with files no one reviewed in context.
Add a component the first time a screen needs it.
pnpm dlx runs the CLI without installing it, so the tool never becomes a dependency of your project.
That’s fitting, since it’s only a delivery mechanism for source.
-
Initialize shadcn in the project, just once.
Terminal window pnpm dlx shadcn@latest init -
Add a component whenever a screen needs it.
Terminal window pnpm dlx shadcn@latest add dialog
Run that second command and look back at the file tree from earlier: dialog.tsx is the file that just appeared in components/ui/.
You ran a command, and a file you own showed up in your source.
The rest of the CLI is worth recognizing, not drilling. Three commands you’ll see referenced:
apply <preset>switches presets on an existing project, such as a theme, a font set, or a design-system preset. It does not change the primitive engine, which is fixed atinit.migrateruns mechanical codemods.migrate radixmoved older projects onto the unifiedradix-uipackage, andmigrate iconsswaps icon libraries.--dry-runand--diffpreview what a command would change before it touches a file.
components.json: the config the CLI reads
Section titled “components.json: the config the CLI reads”Every add you run is steered by one file: components.json.
Most fields you’ll never touch.
Here are the ones an engineer reads to understand a project, and why each matters; recognize the rest.
{ "style": "new-york", "tsx": true, "tailwind": { "css": "src/app/globals.css", "cssVariables": true }, "aliases": { "components": "@/components", "utils": "@/lib/utils" }, "iconLibrary": "lucide", "registries": {}}style picks the visual preset the generated markup starts from, and tsx: true means the files copied into your repo are TypeScript.
{ "style": "new-york", "tsx": true, "tailwind": { "css": "src/app/globals.css", "cssVariables": true }, "aliases": { "components": "@/components", "utils": "@/lib/utils" }, "iconLibrary": "lucide", "registries": {}}The field that matters most. cssVariables: true, on by default, selects the semantic-token theming model: color becomes a property of CSS variables, the bridge to the theming section below. Set it to false and components inline raw color utilities instead, leaving no central place to retheme.
{ "style": "new-york", "tsx": true, "tailwind": { "css": "src/app/globals.css", "cssVariables": true }, "aliases": { "components": "@/components", "utils": "@/lib/utils" }, "iconLibrary": "lucide", "registries": {}}Why @/components/ui/button resolves and where cn() is found. Change these aliases and every generated import changes with them.
{ "style": "new-york", "tsx": true, "tailwind": { "css": "src/app/globals.css", "cssVariables": true }, "aliases": { "components": "@/components", "utils": "@/lib/utils" }, "iconLibrary": "lucide", "registries": {}}The icon set the generated components import from, lucide here.
{ "style": "new-york", "tsx": true, "tailwind": { "css": "src/app/globals.css", "cssVariables": true }, "aliases": { "components": "@/components", "utils": "@/lib/utils" }, "iconLibrary": "lucide", "registries": {}}The namespace map for pulling components from registries beyond shadcn’s own. Empty by default; the closing section returns to it.
One field is worth a closer look.
The primitive engine, Radix versus Base UI, is recorded here and was chosen back at init.
Switching it later isn’t an apply: you re-run init, or hand-edit this file plus a migrate pass.
The engine is a foundation, not a preset, so the choice is worth getting right the first time.
Radix or Base UI: the engine under the markup
Section titled “Radix or Base UI: the engine under the markup”Which engine do you pick at init?
Both expose the same shadcn component API, so your markup and imports barely change between them; the choice is only which audited behavior layer does the work under your owned markup.
- Radix UI is the broad default: the most components, the longest track record, the least friction. It was unified into a single
radix-uipackage in 2026. - Base UI is leaner and headless-first, from the team behind Material UI. Its lighter bundle makes it attractive on a public marketing surface, and it ships actively.
Default to Radix for a web app dashboard: breadth wins, and you hit fewer “that component doesn’t exist yet” walls. Reach for Base UI when bundle size on a public, content-heavy page is the binding constraint. Treat this as a default, not a fixed rule. Radix’s pace slowed after its acquisition while Base UI keeps shipping, so re-check the landscape when you start a new project rather than assuming today’s answer holds.
Composing a primitive with asChild
Section titled “Composing a primitive with asChild”This idiom recurs in every dialog, dropdown, popover, menu, and sheet the rest of the course writes, so slow down here.
You met asChild and Slot as a concept in “Slot and CVA”; here they become the daily composition idiom on a real component.
Start with the shape of a dialog.
A shadcn Dialog isn’t one element but a family of cooperating parts:
<Dialog> <DialogTrigger>Open</DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Title</DialogTitle> <DialogDescription>Description</DialogDescription> </DialogHeader> <DialogFooter>{/* actions */}</DialogFooter> </DialogContent></Dialog>This is a compound component , split this way on purpose. Each part is a styled slot you arrange however the screen needs, and the split lets the primitive wire the ARIA relationships for you, connecting the title to the dialog and the trigger to the content so a screen reader announces the right thing. That wiring is the part you’d get wrong by hand.
Now the part that trips people up.
DialogTrigger renders its own element by default, a <button>.
So what happens when you want the trigger to be a styled shadcn Button instead?
The naive version nests one inside the other, and that’s a bug.
<DialogTrigger> <Button variant="outline">Open</Button></DialogTrigger>A button inside a button. The trigger emits its own button, and you’ve nested a Button inside it: two interactive elements where you wanted one. A button nested in a button is invalid HTML, and the two now disagree about focus and clicks.
<DialogTrigger asChild> <Button variant="outline">Open</Button></DialogTrigger>One element. With asChild, the trigger renders no element of its own. It merges its behavior, its ref, and its ARIA wiring onto your Button, so you get the Button’s styling with the trigger’s behavior. This is the form you’ll write every time.
asChild merges instead of wrapping.
It tells the trigger to render no wrapper and instead forward its behavior, ref, event handlers, and ARIA attributes onto the single child you gave it.
The result is a real shadcn Button that is the trigger.
The mechanism underneath is Slot from radix-ui, the same Slot from “Slot and CVA”, which does the forwarding.
Here’s the dialog you added two sections ago, now wired end to end with a trigger, content, title, description, and a close button:
<Dialog> <DialogTrigger asChild> <Button variant="destructive">Delete project</Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Delete this project?</DialogTitle> <DialogDescription> This permanently removes the project and all of its data. This action cannot be undone. </DialogDescription> </DialogHeader> <DialogFooter> <DialogClose asChild> <Button variant="outline">Cancel</Button> </DialogClose> <Button variant="destructive">Delete</Button> </DialogFooter> </DialogContent></Dialog>Fix this pattern in your mind here, where it first shows up.
The rest of the course reaches for <...Trigger asChild> constantly, and each time it should read as recognition, not novelty.
Theming through semantic tokens
Section titled “Theming through semantic tokens”Where does customization actually live? Not in the component files.
shadcn writes a family of CSS variables, --background, --primary, --muted, --destructive, and the rest, into globals.css and maps Tailwind utilities onto them through @theme.
The components then reference bg-primary text-primary-foreground, never a raw color.
This is the semantic-token machinery from the dark-mode lesson, and shadcn is just a consumer of it.
That indirection buys two things:
- Theming means editing the variables, not the components. Change
--primaryonce and every primitive follows, because they all read the token instead of a hardcoded color. You restyle the whole product without opening a single component. - Dark mode is the same token names under a
.darkclass. Flip the class on the root and the variables re-resolve to their dark values, so every component re-themes itself with no dark-mode-specific markup.
In CSS, the same names with two sets of values:
:root { --background: oklch(1 0 0); --foreground: oklch(0.14 0 0); --primary: oklch(0.21 0.01 286);}
.dark { --background: oklch(0.14 0 0); --foreground: oklch(0.98 0 0); --primary: oklch(0.92 0 0);}You rarely hand-author these.
Visual theme generators like the shadcn theme editor and tweakcn let you design a palette in a UI and emit a block of CSS variables to paste into globals.css.
Some token pairs must pass a contrast check against each other, such as --primary-foreground on --primary; that accessibility commitment gets its own treatment in the next lesson.
When to wrap, when to fork
Section titled “When to wrap, when to fork”You own the file, so when your design needs something the component doesn’t give you, you can just edit it. Usually you shouldn’t. Reach for the least invasive option that solves the problem and escalate only when it genuinely can’t. Here are the four rungs, least to most invasive:
- A
classNameoverride at the call site. A one-off spacing, color, or size tweak, right where you use the component. Becausecn()putsclassNamelast (from “Composing with cn()”), your override wins over the component’s own classes. - A new
cvavariant. A repeated visual variation, say avariant="brand"button used across the app, goes into the variant table on the existing file. You extend the component and stay compatible with upstream. - Wrap and compose. Product behavior on top, such as a
<SubmitButton>that wraps<Button>with a pending spinner anddisabledwiring, lives in its own file atcomponents/<feature>-button.tsxand imports the primitive. The primitive stays pristine and upgradeable. - Fork, the last resort. Edit the file in
components/ui/only when the primitive’s API genuinely can’t express a state your product needs. Comment the edit with the reason, and accept that you’ve left the upgrade path for that file, future accessibility fixes included.
The line falls at the shape of the component, not its look. A redesigned button, a custom-positioned select, a differently-spaced dialog: these are rungs 1 through 3, not forks. You fork only when the abstraction itself is wrong for you.
A spacing or color tweak right where you use the component. cn() puts your class last, so it wins, and the file in components/ui/ stays untouched. Reach here first, always.
A repeated visual variation, like a variant="brand" used across the app, joins the variant table on the existing file. You extend the component rather than rewrite it, and stay compatible with upstream.
Product behavior, such as a pending spinner or disabled wiring, wraps the primitive in its own file and imports it. The primitive stays pristine and upgradeable, with your behavior around it.
The last resort, used only when the API genuinely can’t express the state you need. Comment the edit with the reason, and accept that you’ve left the upgrade path for this file, future accessibility fixes included.
Rung 4 is where teams cause themselves the most trouble, so make its cost concrete.
A fork is a standing liability: every upstream improvement to that component, accessibility fixes most of all, now has to be merged into your edited copy by hand.
The git diff is the fork’s only documentation, which is why the comment explaining it is not optional.
The discipline shows up on every re-run of add: the overwrite drops the registry version onto your file, so you review the diff it produced, re-apply your fork edits, test the behavior, then commit.
That manual re-application is the recurring tax rung 4 charges you.
Where components come from: registries and blocks
Section titled “Where components come from: registries and blocks”Everything so far pulled from shadcn’s own registry, but that’s a default, not a limit.
The registry and namespace model.
add defaults to shadcn’s registry, but the registries field in components.json maps namespaces to other sources: third-party registries like @shadcnblocks or @kibo, or a team-private registry of your own shared patterns.
You then install with add @namespace/component, the same command pointed at a different source.
The team-private case pays off most: a team can distribute a branded OrgSwitcher or ProTable to every app it owns, installed exactly like a Button.
Authoring a registry is out of scope; consuming one is the daily move.
Blocks, not just components.
A component is atomic, like <Button> or <Dialog>.
A block is a whole composed section, like dashboard-01, login-04, or pricing-02.
You copy a block in as the starting point for a screen, then trim it to fit: the same copy-into-repo model, at a larger grain.
The 2026 registry ships these in bulk, and the next chapter’s project leans on them.
Two more terms to recognize when you hit them:
package.json#importstarget aliases: shadcn resolves the#nameprivate-alias syntax, so a team can keep a stable alias even if files move. The default@/components/uiworks for almost everyone.lucide-reacticons each carry the importable typeLucideIcon. Reach for it when a prop or registry slot must accept any icon by reference rather than a specific one.
External resources
Section titled “External resources”The official CLI, components.json, and theming reference — the source of truth for everything in this lesson.
Design a shadcn palette in a live UI and copy out the oklch CSS variables — the generator the theming section pointed at.
The audited, unstyled behavior layer under the default engine — focus traps, keyboard wiring, ARIA, done for you.
The leaner alternative engine from the Radix and MUI team — reach for it when bundle size is the binding constraint.