Own the source, not the dependency
How shadcn/ui delivers accessible React components as source files you own in your repo, rather than 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 get on the page this week. Two instincts will fire immediately, and for a SaaS product in 2026, both of them are wrong.
The first instinct is to build the widgets yourself, and you should not. A correct Dialog traps keyboard focus while it’s open, returns focus to the trigger when it closes, closes on Escape, and announces itself to a screen reader. A correct Select is a full roving-tabindex keyboard widget with type-ahead. These are weeks of work, and you will ship focus-trap and keyboard bugs on the first pass. Accessible primitives are genuinely hard to get right, which is why almost nobody hand-builds them anymore.
The second instinct is to reach for a styled component library: install Material UI, Mantine, or Chakra and get all five widgets in an afternoon. That is faster, but you’ve just adopted someone else’s design language. Restyling to match your brand means fighting the library’s own styles at every turn, and when the maintainers ship a breaking major version, you have to rework your app onto it on their schedule rather than yours.
There is a third path, and it’s the 2026 default for a product UI: shadcn/ui. It splits the work into two halves. The behavior, which is the focus trap, the keyboard handling, and the ARIA wiring, comes from an audited headless primitive . The markup, which is the styled JSX, 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. By the end of this lesson you’ll have added a Dialog to a project, read the file the tool produced, composed it, and themed it, and you’ll know when to wrap it and when to fork it.
None of the machinery here is new to you, and that’s deliberate. Two earlier lessons gave you the exact pieces shadcn ships. “Polymorphism with Slot and CVA” gave you the cva variant table and the asChild/Slot polymorphism. “Dark mode via semantic tokens” gave you the semantic-token CSS variables and the .dark class. This lesson is where those pieces snap together into a system.
Build, buy, or own
Section titled “Build, buy, or own”Before any of the syntax, there’s a decision to make. The three options above 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.
Build it yourself. You own everything: the markup, the styling, and the accessibility bugs. The cost is the weeks of work and the bug surface just described. This is the right call in exactly one situation, a genuinely novel widget that no existing primitive covers. For a Dialog or a Select, it never is.
Install a styled library. The vendor owns the markup and the design language, and you rent them. The cost is the coupling: you’re tied to the vendor’s visual decisions and release schedule, and restyling means working against those decisions. This is sometimes the correct call. For an internal admin tool, design is not a competitive surface and you’d rather not maintain UI at all, so renting is right. Owning the markup there would be a liability you don’t want.
shadcn’s copy-into-repo model. The primitive vendor owns the behavior and ships accessibility fixes that you pull on your own terms, while you own the markup. This is the call for a product surface, where design is a competitive differentiator and you need to restyle freely without fighting a dependency on every screen.
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: it’s opaque, versioned, and theirs, so you interact with it through props and theme overrides and hope the escape hatches are deep enough. shadcn moves the seam into your repository: the styled source is a file you open and edit, while the hard behavioral part stays an audited dependency underneath. You’re not choosing more code or less code. You’re choosing where the line between yours and theirs falls.
The walker below steps through the decision in the order an experienced engineer asks the questions. Start at the root and follow a branch down to a verdict.
You own the behavior and the accessibility. Reserve this for a widget no primitive covers, since it’s weeks of work and a real bug surface. It’s almost never the answer for standard widgets.
Rent the markup and the design. The coupling to the vendor’s schedule is fine here because you’d rather not maintain UI at all. Ownership would be a liability you don’t want.
Own the source, with audited behavior underneath. You can restyle freely because the markup is your file, and the accessibility stays solved by the primitive.
The walk lands on a clear split: shadcn for a product, a library for an internal tool. The rest of the lesson is the mechanics behind that choice.
What shadcn actually is, and is not
Section titled “What shadcn actually is, and is not”One misconception is worth clearing up first: shadcn/ui is not a component library. There is no shadcn package that ships <Dialog> and <Button> to import at runtime. Instead, shadcn is a CLI plus a registry : you run a command, it reaches the registry for a component’s source, and it copies that source as a file into your project.
You can see this model at work in where your imports point. You import a button from @/components/ui/button, a path inside your own src/, not from a node module. The components do not live in node_modules. If you ever go looking for dialog.tsx under node_modules, that’s a sign the mental model hasn’t clicked yet: it isn’t there, and it never will be, because the file lives in your repository where you can read and edit it.
To see what “into your repository” looks like in practice, here’s the shape of a project after you’ve added a couple of components. The bold files are the ones shadcn copied in, the ones you now own.
Directorysrc/
Directorycomponents/
Directoryui/ shadcn primitives, copied in, you own these
- button.tsx source, 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 if there’s no shadcn package, what does end up in package.json when you add a component? The copied files are real source that import things, so the tool installs the runtime dependencies those files reach for. You’ve met almost all of them already:
radix-uiis the primitive package that supplies the behavior and accessibility. As of early 2026 this is a single unified package, and adialog.tsxopens withimport { Dialog as DialogPrimitive } from "radix-ui". (If the project picked Base UI at setup instead, this slot is@base-ui-components/react, covered shortly.)class-variance-authority(cva),tailwind-merge, andclsxsupply the variant tables from “Polymorphism with Slot and CVA” and thecn()helper from “Composing classes with cn()”. They’re already in your vocabulary.tw-animate-cssis the animation engine the dialog, sheet, and accordion choreography lean on, from “Motion — transitions, keyframes, and tw-animate-css”.lucide-reactis the default icon set: tree-shakeable, one component per icon.
The point to hold onto: these are peers the copied components import, not “the shadcn library” arriving under a different name. There is no shadcn runtime. There are source files you own and the ordinary dependencies they happen to use.
The three consequences of owning the source
Section titled “The three consequences of owning the source”Copying source into your repo instead of importing a package isn’t laziness or a shortcut. It’s a deliberate trade that buys three specific things, and each one comes with a matching cost. Keeping both halves of each trade in view is what lets you use the model well.
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. There’s no waiting on a maintainer to accept a pull request, and no patch-package hack layered over a node module. The cost is that the moment you edit that file, you’ve cut a branch from upstream, which means you lose any future improvements to it, accessibility fixes among them. The section on forking is where this gets resolved.
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. There’s no sourcemap archaeology into a minified bundle and no guessing at internals from the outside. The cost is that you actually have to read it. Treating a shadcn component as an opaque black box you never open is the single most common way teams misuse this model, because it throws away the main thing you paid for.
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 is that an upgrade is a deliberate, reviewed act rather than a number bumping in a lockfile.
These land on the same conclusion as the decision walk, now grounded in mechanics rather than principle. For a product, this trade is correct: design is a competitive surface, and ownership is leverage. For an internal tool, it’s wrong: ownership is a maintenance liability you’d rather not carry. Same verdict, two angles.
Before moving on, pin down which side of the trade each statement sits on. Some of these 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: the CLI workflow
Section titled “Adding a component: the CLI workflow”With the model in place, here is the hands-on part. The shadcn CLI has a wide surface, but the daily workflow is two commands, and you only ever run the first one 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 2026 CLI (v4) can also scaffold a whole project template, and it’s where you pick the primitive engine, Radix (the default) or Base UI, with a --base flag. You’ll set this up once at the start of a project and rarely think about it again.
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.
The discipline here is to add on demand, not upfront. Running add for every component the moment you start a project, so they’re “ready,” is an anti-pattern: it bloats your bundle with widgets you don’t use yet and clutters your diffs with files no one reviewed in context. Add a component the first time a screen actually needs it.
That pnpm dlx prefix may be new. pnpm dlx fetches the CLI, runs it a single time, and leaves nothing behind. The tool itself never becomes a dependency of your project, which is fitting, since it’s only a delivery mechanism for source.
Here’s the full setup-then-add flow:
-
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/. That closes the loop. You ran a command, and a file you own showed up in your source. From here it’s yours.
The rest of the CLI is worth recognizing, not drilling. Three commands you’ll see referenced:
apply <preset>switches presets on an existing project: a theme, a font set, a design-system preset. It does not change the primitive engine, which is fixed atinit.migrateruns mechanical codemods.migrate radixis what moved 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 of its fields you’ll never touch. Here are the ones an experienced engineer reads to understand a project, and why each matters; the rest are there to recognize.
{ "style": "new-york", "tsx": true, "tailwind": { "css": "src/app/globals.css", "cssVariables": true }, "aliases": { "components": "@/components", "utils": "@/lib/utils" }, "iconLibrary": "lucide", "registries": {}}The baseline two settings. 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 selects the semantic-token theming model, on by default. It’s the switch that makes color a property of CSS variables, which is the bridge to the theming section below. Flip it to false and the components inline raw color utilities instead, which leaves you with 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": {}}This is 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 default 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 sources beyond shadcn’s own registry. It’s empty by default, and the closing section returns to it.
The takeaway is that components.json isn’t magic. Every field maps to something you already understand: the alias that resolves your imports, the CSS-variable theming model, the icon set. Reading it is how you demystify what the CLI does on your behalf.
One field is worth a closer look. The primitive engine, Radix versus Base UI, is recorded in components.json, and it was chosen back at init. Switching it later is not an apply operation; it means re-running init, or hand-editing this file plus a migrate pass. That’s by design, because the engine is a foundation rather than a preset, and it’s why 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”So which engine do you pick at init? The choice is which audited behavior layer sits under your owned markup. Both expose the same shadcn component API, so your markup and your imports barely change between them; only the primitive doing the work underneath differs.
- Radix UI is the broad default: the most components, the longest track record, the path of least resistance. It was unified into a single
radix-uipackage in 2026. - Base UI is leaner and headless-first, from the team behind Material UI. It ships a lighter bundle, which makes it attractive on a public marketing surface, and it’s shipping actively.
For a SaaS app dashboard in 2026, default to Radix: breadth wins, and you’ll 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 rather than a fixed rule. Radix’s pace slowed after its acquisition while Base UI has been shipping hard, so re-check the landscape when you start a new project rather than assuming today’s answer holds forever.
Composing a primitive with asChild
Section titled “Composing a primitive with asChild”This one piece of syntax is worth slowing down on, because it recurs in every dialog, dropdown, popover, menu, and sheet the rest of the course writes. You met asChild and Slot as a concept in “Polymorphism with Slot and CVA”. Here you see them as the daily composition idiom on a real component.
Start with the shape of a dialog. A shadcn Dialog isn’t one element. It’s 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 , and it’s split this way on purpose. Each part is a styled slot you arrange however the screen needs, and the split is what 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.
Here is the part that trips people up. The 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 element, 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 of them 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 your 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 not to render a wrapper element, but to take its behavior, its ref, its event handlers, and its ARIA attributes and forward them all onto the single child you gave it. The result is a real shadcn Button that is the trigger, not a button nested inside a trigger. The prop is asChild, and the mechanism underneath is Slot from radix-ui, the same Slot from “Polymorphism with Slot and CVA”, which is what does the forwarding.
Here’s the dialog you added two sections ago, now wired up 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, on the example where it first shows up. The rest of the course reaches for <...Trigger asChild> constantly, and each time it should read as recognition rather than 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 into globals.css, including --background, --foreground, --primary, --primary-foreground, --muted, --destructive, --ring, --border, and a handful more, and maps Tailwind utilities onto them through @theme, exactly the machinery from “Dark mode via semantic tokens”. The component files then reference bg-primary text-primary-foreground and never a raw color. This is the payoff of that earlier lesson, and shadcn is just a consumer of it.
That indirection buys two things worth stating plainly:
- Theming means editing the variables, not the components. Change
--primaryonce and every primitive in the app follows, because they all read the token rather than a hardcoded color. The components are theme-agnostic by construction, so you restyle the whole product without opening a single one of them. - Dark mode is the same 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. No component edits, no dark-mode-specific markup. Same names, two sets of values.
Here’s “same names, two values” as concrete CSS:
: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 don’t usually hand-author these. Visual theme generators, such as the shadcn theme editor and tools like tweakcn, let you design a palette in a UI and emit a block of CSS variables you paste straight into globals.css. It’s worth knowing this exists, though not worth a tutorial here.
One note points ahead. Some token pairs have to pass a contrast check against each other, such as --primary-foreground sitting on --primary. That is an accessibility commitment, and it gets its own treatment in the next lesson. Here, the job was only to locate where the colors live.
When to wrap, when to fork
Section titled “When to wrap, when to fork”This brings you to the central judgment call. You own the file, so when your design needs something the component doesn’t give you out of the box, should you edit it? The instinct says yes, since it’s your file. The experienced answer is usually no, and there’s a ladder to climb first.
Reach for the least invasive option that solves the problem, and only escalate when it genuinely can’t. Here are the four rungs, least to most invasive:
- A
classNameoverride at the call site. This handles a one-off spacing, color, or size tweak right where you use the component. Becausecn()putsclassNamelast (from “Composing classes with cn()”), your override wins over the component’s own classes. Reach here first, always. - 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’re extending the component, not rewriting it, and you stay compatible with upstream. - Wrap and compose. Product behavior layered 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 itself stays pristine and upgradeable, and your behavior sits around it. - Fork, the last resort. Edit the file in
components/ui/only when the primitive’s abstraction itself is wrong, meaning its API genuinely can’t express a state your product needs. When you do, comment the edit with the reason the fork was necessary, and accept that you’ve left the upgrade path for that file, future accessibility fixes included.
Notice where the line falls, because it’s tighter than instinct suggests. A redesigned button, a custom-positioned select, and a differently-spaced dialog are not forks. They’re rungs 1 through 3: an override, a variant, or a wrapper. You fork only when the component’s shape is wrong for you, not when its look is.
A spacing or color tweak right where you use the component. cn() puts your class last, so it wins over the component’s own classes. 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 itself stays pristine and upgradeable, and your behavior sits around it.
The last resort, used only when the abstraction is wrong and 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.
It’s worth making the cost of forking concrete, because rung 4 is where teams cause themselves the most trouble. A fork is a standing liability: from then on, every upstream improvement to that component has to be merged into your edited copy by hand, and accessibility fixes are the ones you least want to miss. The git diff on the forked file is its only documentation, which is why the comment explaining why you forked is not optional. Audit before forking, and try rungs 1 through 3 first, every time.
This is also where the upgrade discipline becomes concrete. Re-running add overwrites the file with the current registry version, so on a forked file the workflow is to review the diff the overwrite produced, re-apply your fork edits, test the component’s 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”To close, here is the wider picture. Everything so far pulled from shadcn’s own registry, but that’s a default rather than a limit, and the vocabulary here is something you’ll meet in the wild almost immediately.
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 from a different source. The team-private registry is where this pays off most: a team can distribute a branded OrgSwitcher or a ProTable to every app it owns, installed the exact same way you install a Button. Authoring a registry is its own skill and out of scope here; 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 a starting point for a screen and then trim it down to fit, the same copy-into-repo model you’ve used all lesson, just 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, no detour required:
package.json#importstarget aliases: shadcn can resolve the#nameprivate-alias syntax, so a team can keep a stable alias even if files move. The default@/components/uiis fine for almost everyone.lucide-reacticons each carry the typeLucideIcon, which is importable. Reach for that type when a prop or a registry slot needs to 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.