Building a Design System with shadcn/ui — Component Design That Returns Ownership to You
When I first encountered shadcn/ui, I honestly thought, "Wait, is this actually a library?" No npm install, nothing to find inside node_modules — run the CLI and source code files just get copied straight into your project. If you're used to how traditional UI libraries work, it'll feel unfamiliar at first.
But once you understand what problem this unfamiliar approach solves in practice, your reaction changes. You've probably experienced styles suddenly breaking on a library update, the frustration of wanting to touch component internals but having to dig into node_modules, and a globals.css that ends up buried under layers of style overrides. If any of that sounds familiar, the philosophy shadcn/ui proposes is quite compelling. This design approach has something to say not just to frontend developers, but also to backend or full-stack developers thinking about UI consistency across their team, and to team leads who need to decide on a new project's tech stack. shadcn/ui changes the paradigm of design system development by returning component ownership from the library to the developer.
This article covers everything from shadcn/ui's core design philosophy to the Tailwind v4-based theme system, consistent component variant management using CVA patterns, and building a Private Registry for team-scale operations.
Core Concepts
Why Source Code Comes Instead of npm
shadcn/ui is a copy-paste component collection. Based on Radix UI's highly accessible headless components, styled with Tailwind CSS, the code lands inside your project directory with a single CLI command.
npx shadcn@latest add buttonRunning this creates a components/ui/button.tsx file. That's it. That file is entirely your code. You can modify it, delete it, or refactor it to fit your team's conventions — no problem.
One thing worth clarifying: I said "no npm install," but if you open the code you'll find lines like import { Slot } from "@radix-ui/react-slot". That might seem contradictory, but to be precise — the source files containing component logic and styles are copied into your project, while low-level dependencies like Radix UI and CVA are installed via npm. Think of it as importing a "unit of functionality" and directly owning it, rather than being locked into a library API.
The difference becomes clear when you compare it to traditional UI libraries.
| Item | Traditional UI Libraries (MUI, Chakra, etc.) | shadcn/ui |
|---|---|---|
| Installation | npm install → node_modules |
CLI → files in your project |
| Customization | Overrides, theme extensions | Direct file modification |
| Upgrade | npm update |
Manual merge of changed components |
| Bundle size | Depends on tree shaking | Only the components you use exist |
| Ownership | The library | The developer |
Headless Component: A component that only handles functionality and accessibility logic, providing no visual styles whatsoever. Radix UI is the prime example — "how it looks" is entirely up to the consumer. Think of it as a pure "behavioral skeleton" with no styles.
components.json and the Registry System
The key to shadcn/ui functioning as design system infrastructure — not just a simple component collection — is the Registry system. The components.json file generated when you run npx shadcn@latest init is where it all begins.
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui"
}
}The CLI reads this config to place components in the right paths and automatically installs required dependencies (class-variance-authority, clsx, tailwind-merge, etc.). The Registry isn't limited to official components. You can also build a Private Registry to distribute internal team components the same way — we'll look at that in detail in the practical examples section.
The Tailwind v4 Color Token System
Starting in 2025, shadcn/ui switched to Tailwind CSS v4 as its default. The color representation method also changed from HSL to OKLCH, and the difference is significant when building dark mode palettes. Because the same lightness value looks similar in brightness regardless of hue, it becomes much easier to create consistent color scales that work for both light and dark modes.
The theming approach has also been simplified. You can manage all design tokens with just globals.css, with no tailwind.config.ts needed.
/* app/globals.css */
@import "tailwindcss";
@theme inline {
--color-primary: oklch(0.205 0 0);
--color-primary-foreground: oklch(0.985 0 0);
--color-secondary: oklch(0.97 0 0);
--color-secondary-foreground: oklch(0.205 0 0);
--color-destructive: oklch(0.577 0.245 27.325);
--radius-lg: 0.625rem;
--radius-md: calc(var(--radius-lg) - 2px);
--radius-sm: calc(var(--radius-md) - 4px);
}
@layer base {
:root {
color-scheme: light;
}
.dark {
color-scheme: dark;
--color-primary: oklch(0.922 0 0);
--color-primary-foreground: oklch(0.205 0 0);
}
}It's worth noting why the inline keyword in @theme inline is necessary. Without it, using just @theme causes Tailwind to statically expand color values at build time. With inline, bg-primary compiles to background-color: var(--color-primary) instead of a hardcoded color value — so when the CSS variable changes at runtime (dark mode toggle, dynamic theming, etc.), it's automatically reflected. inline is essential for dynamic theming.
OKLCH: A color space introduced in CSS Color Level 4 that reflects how human vision perceives color. Expressed as
oklch(lightness chroma hue), colors with the same lightness value look similar in brightness regardless of hue. If you've ever worked in HSL and thought "why does this color look so jarring in dark mode?" — switching to OKLCH largely resolves that frustration.
Practical Application
Example 1: Setting Up a Design System Foundation in a Next.js 15 Project
I stumbled around quite a bit during initial setup, but following the right order makes it much smoother. Start from a project that already has Next.js 15 and Tailwind v4 configured.
# Initialize shadcn — answer a few questions and components.json will be generated
npx shadcn@latest init
# Add base components
npx shadcn@latest add button card input labelOpen the added button.tsx and you'll find this structure:
// components/ui/button.tsx
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 gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90",
outline: "border bg-background shadow-xs hover:bg-accent",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}These four elements look unfamiliar at first, but they actually stem from a single principle: "declare variants, compose classes without conflicts."
| Code Element | Role |
|---|---|
cva() |
Declares class combinations per variant in a type-safe way. Constrained to types rather than plain strings — e.g., variant="destructive" |
cn() |
A clsx + tailwind-merge combo. When both bg-primary and bg-red-500 are passed, the latter wins |
Slot |
When the asChild prop is true, renders the child element instead of Button while transferring styles |
VariantProps |
Automatically infers variant types from buttonVariants and merges them into ButtonProps |
How asChild works becomes intuitive with a Next.js Link example:
// ❌ Without asChild: <a> nested inside <button> causes an HTML error
<Button>
<Link href="/dashboard">Dashboard</Link>
</Button>
// ✅ With asChild: Link renders as <a> while inheriting Button's styles
<Button asChild>
<Link href="/dashboard">Dashboard</Link>
</Button>The Slot component merges the parent's (Button's) props with the child (Link) for rendering. This is behavior you could never touch in an external library. Because the file exists as your own code, you can fully understand and extend its internals — and that matters quite a lot in practice.
One more advantage of cva() over plain clsx: variant combinations are enforced as types. A typo like variant="destrcutive" gets caught by TypeScript at build time, not at runtime. The larger the codebase, the more this difference compounds.
Example 2: Building Consistent Custom Components with CVA
The real appeal of the shadcn/ui pattern isn't in consuming official components — it's in how this pattern can be extended across your entire team. Let's use a Status Badge component as an example, since it comes up constantly in practice.
One important point: you shouldn't write color values directly as Tailwind utility classes (bg-green-100). It's far better to define design tokens as CSS variables in globals.css first. That way, when the theme changes, you don't need to touch your components.
/* Add to app/globals.css */
@theme inline {
/* existing tokens ... */
--color-status-active: oklch(0.92 0.12 145);
--color-status-active-fg: oklch(0.28 0.12 145);
--color-status-pending: oklch(0.95 0.1 85);
--color-status-pending-fg: oklch(0.35 0.1 85);
--color-status-inactive: oklch(0.94 0 0);
--color-status-inactive-fg: oklch(0.45 0 0);
}
.dark {
--color-status-active: oklch(0.28 0.1 145);
--color-status-active-fg: oklch(0.88 0.1 145);
--color-status-pending: oklch(0.35 0.08 85);
--color-status-pending-fg: oklch(0.9 0.08 85);
--color-status-inactive: oklch(0.3 0 0);
--color-status-inactive-fg: oklch(0.75 0 0);
}Then reference those tokens in the component:
// components/ui/status-badge.tsx
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const statusBadgeVariants = cva(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold",
{
variants: {
status: {
active: "bg-[var(--color-status-active)] text-[var(--color-status-active-fg)]",
pending: "bg-[var(--color-status-pending)] text-[var(--color-status-pending-fg)]",
inactive: "bg-[var(--color-status-inactive)] text-[var(--color-status-inactive-fg)]",
error: "bg-destructive/10 text-destructive",
},
},
}
)
interface StatusBadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof statusBadgeVariants> {}
export function StatusBadge({ status, className, ...props }: StatusBadgeProps) {
return (
<span className={cn(statusBadgeVariants({ status }), className)} {...props} />
)
}You can naturally reference existing system tokens like --color-destructive as well. Even if your team's brand colors change, just swap the token values in globals.css and all components update automatically.
Example 3: Building a Private Registry for Your Team
A Private Registry really shines when your team grows or when you need to share the same custom components across multiple projects.
The official Registry system is managed via a separate registry.json file, not components.json. Define component metadata in your project root:
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "my-design-system",
"homepage": "https://registry.yourcompany.com",
"items": [
{
"name": "status-badge",
"type": "registry:ui",
"title": "Status Badge",
"description": "Shared team status badge component",
"files": [
{
"path": "registry/ui/status-badge.tsx",
"type": "registry:ui"
}
]
},
{
"name": "data-table",
"type": "registry:ui",
"title": "Data Table",
"files": [
{
"path": "registry/ui/data-table.tsx",
"type": "registry:ui"
}
]
}
]
}Once defined, build the Registry and export it as static files:
# Read registry.json and build into a deployable form
npx shadcn@latest registry buildHost the build output on an internal server or CDN, and any team member can pull internal components the same way:
# Install custom components from the internal registry
npx shadcn@latest add https://registry.yourcompany.com/status-badge.json
npx shadcn@latest add https://registry.yourcompany.com/data-table.jsonThe advantage of this approach is that design tokens (--color-primary, etc.) are automatically inherited by the components. Multiple projects can share the same component code even if they each use different brand colors.
Pros and Cons Analysis
Advantages
| Item | Details |
|---|---|
| Complete ownership | Components are your code, so you're entirely free from the library's update cycle |
| Bundle optimization | Only added components are included in the bundle; no separate tree-shaking configuration needed |
| Built-in accessibility | Radix UI's WAI-ARIA implementation is built in; keyboard navigation and focus management handled automatically |
| Theme consistency | CSS variable-based tokens for unified management of dark mode and brand colors |
| AI collaboration optimized | Open code structure makes it easy for AI tools like GitHub Copilot and Cursor to understand patterns |
| Rich ecosystem | 1,300+ official blocks + awesome-shadcn-ui community resources |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Upgrade complexity | Cannot update to a new component version all at once like npm update |
Use npx shadcn diff button to see the diff between your local file and the latest version, then manually merge only what you need |
| Learning curve | You need a decent understanding of Tailwind v4, Radix UI, and CVA to customize | Lower the barrier with internal team documentation + Private Registry |
| Design homogeneity | Projects can look similar if not customized enough | Define color tokens and typography for your brand up front |
| React dependency | Official support is React ecosystem only | For Vue/Svelte projects, consider ported libraries like shadcn-vue |
| Initial setup complexity | First-time setup with Tailwind v4 + Next.js 15 can feel unfamiliar | Use npx shadcn create's Visual Builder for GUI-based configuration |
WAI-ARIA: Stands for Web Accessibility Initiative - Accessible Rich Internet Applications. A standard for adding roles and states to HTML so that assistive technologies like screen readers can correctly interpret dynamic web content.
The Most Common Mistakes in Practice
-
Breaking the variable system by adding arbitrary CSS to
globals.css: If you hardcode colors outside the@theme inlineblock, you'll end up with dark mode partially broken. It's strongly recommended to abstract all color values as CSS variables. You'll be grateful when it's time to change your brand colors later. -
Concatenating classNames as strings without
cn(): Directly joining classes likeclassName={`${existingClass} bg-red-500`}causes Tailwind class conflicts. Sincecn()hastailwind-mergebuilt in to automatically resolve conflicts, the habit of always going throughcn()will serve you well. -
Sharing components via Slack instead of building a Private Registry: Once slightly-different versions of components scatter across multiple repos through individual team members, nobody knows which version is canonical. Once your team reaches a certain size, building an internal Registry is far more beneficial in the long run.
Closing Thoughts
The core value of shadcn/ui isn't "no npm install" — it's that your team gains genuine control over the UI layer. When components exist as your own code, instead of racing to keep up with library major version bumps, you can evolve them at your own team's pace and direction.
Here are 3 steps you can start with right now:
-
Add shadcn/ui to an existing project: Initialize with
npx shadcn@latest init, then add a couple of components withnpx shadcn@latest add button card. Then open the generated files directly and look over thecva()declaration and theexport function Button(...)assembly. The whole pattern clicks quickly once you see it. Reading the files beats reading command output as the fastest way to understand this library. -
Customize color tokens in
globals.css: Try changing--color-primaryto your brand color. For OKLCH conversion, tools like oklch.com are handy. Input a HEX value and it converts to OKLCH notation — just copy the result and paste it inside the@theme inlineblock to see it immediately. -
Check upgrade diffs with
npx shadcn diff: Runnpx shadcn@latest diff buttonand it shows the difference between your localbutton.tsxand the official latest version as a Git diff. You'll find manual merging is less of a hassle than you might expect.
References
- shadcn/ui Official Documentation | shadcn
- shadcn/ui Tailwind v4 Migration Guide | shadcn
- shadcn Registry Official Documentation | shadcn
- Why We Chose shadcn/ui for Our Design System | Medium
- Good Frontend Component Patterns Through shadcn/ui | chaesunbak.com
- React UI Library Comparison 2025 | Makers Den
- awesome-shadcn-ui Curated List | GitHub
- The Frontend Cheat Code: Accelerate Development with shadcn/ui | S-Core