I spent two years maintaining a hand-rolled component library. Storybook, semantic versioning, changesets, the whole ceremony. Last month I deleted it and moved every consumer to shadcn ui. Nobody complained. The design tokens are cleaner, the bundle is smaller, and I stopped getting Slack pings about breaking changes in v3.7.2.
This is the shadcn CLI v2 workflow on Tailwind v4 that made that possible — theming, private registry hosting, and the update strategy that actually preserves your local edits.
Why owning the source beats npm-installing a design system
Owning the source means your components live in your repo as plain React + Tailwind files, not behind a package version. shadcn ui 2026 doubled down on this: the CLI copies files, generates types against your local Tailwind theme, and treats updates as diffs you review — not black-box breaking changes.
The pain of an npm-installed UI kit shows up on day 400, not day 1. You want to tweak a Dialog overlay's blur. You can't. You fork it. Now you maintain a fork. You upgrade — merge conflicts. Owning the source skips that entire class of problem. Your components/ui/dialog.tsx is yours. Editing it is a normal PR.
- Scaffold a React + Vite + TypeScript project and install Tailwind v4 with the Vite plugin.
- Run
npx shadcn@latest initto set upcomponents.json, CSS variables, and the base theme. - Add components with
npx shadcn@latest add button dialog form— files land insrc/components/ui. - Define your design tokens in
@themeinsidesrc/index.css(v4's CSS-native config). - Host a private registry — a JSON file behind auth — for internal, shared components.
- Run
npx shadcn@latest diffbefore every upstream sync to review upstream changes against your local edits. - Commit component files to git and treat them like normal source, not vendor code.
How do you set up shadcn CLI v2 with Tailwind v4?
Answer: install Tailwind v4 via @tailwindcss/vite, then run npx shadcn@latest init — the CLI now detects v4 automatically and skips the old tailwind.config.ts generation step.
Concrete commands, assuming a fresh Vite scaffold:
pnpm create vite@latest my-app --template react-ts
cd my-app
pnpm add tailwindcss @tailwindcss/vite
pnpm add -D @types/node
Wire up vite.config.ts:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'node:path'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: { alias: { '@': path.resolve(__dirname, './src') } },
})
Then src/index.css:
@import "tailwindcss";
@theme {
--color-background: hsl(0 0% 100%);
--color-foreground: hsl(240 10% 3.9%);
--color-primary: hsl(240 5.9% 10%);
--radius: 0.5rem;
}
Now npx shadcn@latest init. When it asks about your CSS variables file, point it at src/index.css. It writes components.json and you're done. The docs don't emphasise this enough: on v4 you skip tailwind.config entirely. Everything lives in CSS.
What changed in shadcn CLI v2?
Answer: the v2 CLI adds first-class registry support, per-component dependency resolution, and a diff command that shows upstream changes without clobbering your local edits.
The three flags I actually use:
shadcn add <url>— pulls a component from any HTTP(S) URL that returns registry-shaped JSON. This is how private registries work.shadcn diff <component>— three-way diff between your local file, the version you originally added, and the latest upstream. Killer feature.shadcn build— compiles a directory of your own components into registry JSON that other repos can consume.
The diff command is what finally made shadcn viable at team scale. Before v2, updating meant re-running add and hoping. Now it's the same workflow as reviewing a dependency bump.
How do you host a private shadcn registry?
Answer: a shadcn registry is a static JSON endpoint. Host it on any static file provider — a private S3 bucket, a GitHub Pages repo, or a Next.js app on Vercel with an API route that gates access by header.
The registry format is a JSON file per component listing files, dependencies, and Tailwind tokens. Minimum viable version:
{
"name": "data-table",
"type": "registry:component",
"dependencies": ["@tanstack/react-table"],
"registryDependencies": ["button", "input"],
"files": [
{ "path": "data-table.tsx", "content": "...", "type": "registry:component" }
]
}
Consume it from any project:
npx shadcn@latest add https://registry.internal.acme.dev/data-table.json
The CLI resolves registryDependencies against your public shadcn setup automatically, so internal components can build on button and input without vendoring them again. This is how you replace an internal UI library: your team's registry sits alongside the public one, and consumers cherry-pick.
How do you update components without breaking local diffs?
Answer: run shadcn diff before shadcn add --overwrite. The diff shows exactly what changed upstream, so you apply upstream changes as manual edits rather than losing your customisations to a blind overwrite.
My actual workflow, weekly:
npx shadcn@latest diff
# review changes per component
npx shadcn@latest diff button --apply
# or manually patch the bits you want
The --apply flag does a three-way merge and leaves conflict markers where your edits collide with upstream. Same mental model as a git rebase. Anyone who's survived a Stack Overflow rabbit hole trying to salvage a mangled Radix upgrade will appreciate this workflow. If you've followed our Tailwind v4 upgrade guide, this slots straight in on top.
Should you migrate an existing UI library to shadcn?
Answer: yes if your library is <5 developers maintaining <30 components and your consumers all live in the same monorepo or org. No if you're shipping a public design system used by external teams — that still wants versioned npm packages.
My migration path was boring on purpose:
- Week 1 — stood up a private registry with our 12 most-used components (Button, Input, Dialog, etc.) mapped to our design tokens.
- Week 2 — one consumer app ran
shadcn addagainst every internal registry URL, deleted the old@acme/uiimport, and shipped. - Week 3 — remaining apps migrated. Deprecated the npm package.
Total: about 40 hours of work across three sprints. What we got back: no more version negotiation across apps, no more "can you cut a patch release for this one prop?", designers can PR directly against component files. If you want to level up your test story around vendored components, wire in Playwright component testing — real DOM in CI catches things unit tests miss.
The verdict
Ship shadcn ui + Tailwind v4 for any React project where you control the consumers. The shadcn CLI v2 workflow — init, add, diff, private registry — is the smallest amount of tooling ceremony I've seen for shared components. Own the source. Skip the semver theatre.
FAQs
Is shadcn ui a component library?
No — it's a CLI that copies component source files into your project. There's no npm package to install for the components themselves. You get the source, you own it, and you edit it like any other file in your repo.
Does shadcn work with Tailwind v4?
Yes. The v2 CLI detects Tailwind v4 automatically and generates components that use the CSS-native @theme block instead of the old tailwind.config.ts. No PostCSS setup needed.
How do I share shadcn components across multiple projects?
Publish a private registry — a JSON endpoint that describes your components and their dependencies. Any project can then run npx shadcn add <your-registry-url> to pull them in, exactly like the public registry.
Can I update shadcn components after I've customised them?
Yes, that's what the shadcn diff command exists for. It performs a three-way merge between your local file, the original, and the latest upstream, leaving conflict markers where your customisations disagree with upstream changes.
Is shadcn better than Material UI or Mantine?
Different tool for a different problem. MUI and Mantine give you a versioned npm package with theming APIs — good for teams that want zero maintenance surface. shadcn gives you source ownership — better when you need custom design tokens, small bundle sizes, or want to avoid framework lock-in from a runtime dependency.