Skip to main content

Chapter 11: Design System with shadcn/ui

A design system is the visual and interactive language of your application. This chapter shows how to build one using shadcn/ui — a collection of accessible, customizable components that you own entirely.

Why shadcn/ui

Traditional component libraries (Material UI, Ant Design, Chakra UI) ship as npm packages. You import their components, apply their themes, and hope their opinions align with your design. When they do not, you fight the library.

shadcn/ui takes a different approach: it copies component source code into your project. You own every line. There is no runtime dependency, no version conflicts, no fighting theme systems.

AspectTraditional Libraryshadcn/ui
Installationnpm installnpx shadcn@latest add button
OwnershipLibrary owns the codeYou own the code
CustomizationTheme overrides, CSS-in-JS hacksEdit the source directly
Bundle sizeEntire library includedOnly what you add
UpdatesVersion bumps, breaking changesYou update at your pace
AccessibilityVariesRadix UI primitives (WAI-ARIA)

Built On

  • Radix UI — headless, accessible primitives (dialog, dropdown, tooltip, etc.)
  • Tailwind CSS — utility-first styling
  • class-variance-authority (CVA) — variant management
  • TypeScript — full type safety

Setup

Initialize shadcn/ui

npx shadcn@latest init

The CLI will ask you:

  1. Style: New York (recommended — cleaner, more modern)
  2. Base color: Slate, Gray, Zinc, Neutral, or Stone
  3. CSS variables: Yes (required for theming)

This creates:

src/
├── components/
│ └── ui/ # shadcn/ui components will be added here
├── lib/
│ └── utils.ts # cn() utility function
└── styles/
└── globals.css # CSS variables for theming

The cn() Utility

// src/shared/lib/cn.ts (or wherever shadcn puts it)
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

cn() merges Tailwind classes intelligently:

  • cn("px-4", "px-6")"px-6" (later class wins)
  • cn("text-red-500", condition && "text-blue-500") → conditional classes
  • cn("p-4 bg-white", className) → allows consumer override

Adding Components

# Add individual components
npx shadcn@latest add button
npx shadcn@latest add card
npx shadcn@latest add dialog
npx shadcn@latest add input
npx shadcn@latest add label
npx shadcn@latest add select
npx shadcn@latest add table
npx shadcn@latest add toast
npx shadcn@latest add dropdown-menu
npx shadcn@latest add avatar
npx shadcn@latest add badge
npx shadcn@latest add separator
npx shadcn@latest add skeleton

# Or add multiple at once
npx shadcn@latest add button card dialog input label

Each component is placed in src/shared/components/ui/ as a .tsx file that you can read and modify.

Theming with CSS Variables

shadcn/ui uses CSS custom properties for theming. This makes theme switching (including dark mode) a matter of changing variable values:

/* src/styles/app.css */
@import "tailwindcss";

@theme {
/* Map CSS variables to Tailwind utility classes */
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-border: hsl(var(--border));
--color-ring: hsl(var(--ring));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
}

@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--border: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.75rem;
}

.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--border: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}

Dark Mode Toggle

// features/settings/hooks/use-theme.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";

type Theme = "light" | "dark" | "system";

interface ThemeStore {
theme: Theme;
setTheme: (theme: Theme) => void;
}

export const useThemeStore = create<ThemeStore>()(
persist(
(set) => ({
theme: "system",
setTheme: (theme) => {
set({ theme });
applyTheme(theme);
},
}),
{ name: "theme" }
)
);

function applyTheme(theme: Theme) {
const root = document.documentElement;
root.classList.remove("light", "dark");

if (theme === "system") {
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
root.classList.add(systemDark ? "dark" : "light");
} else {
root.classList.add(theme);
}
}

// Initialize on app load
const savedTheme = useThemeStore.getState().theme;
applyTheme(savedTheme);

Creating Custom Variants with CVA

class-variance-authority (CVA) manages component variants systematically:

// shared/components/ui/button.tsx
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/cn";

const buttonVariants = cva(
// Base classes applied to ALL variants
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
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}
/>
);
}

Usage:

<Button>Default</Button>
<Button variant="destructive">Delete</Button>
<Button variant="outline" size="sm">Small Outline</Button>
<Button variant="ghost" size="icon"><TrashIcon /></Button>

Building Application-Level Components

shadcn/ui provides primitives. You build application-specific components on top:

// shared/components/layout/page-header.tsx
import { cn } from "@/shared/lib/cn";

interface PageHeaderProps {
title: string;
description?: string;
actions?: React.ReactNode;
className?: string;
}

export function PageHeader({ title, description, actions, className }: PageHeaderProps) {
return (
<div className={cn("flex items-center justify-between pb-6", className)}>
<div>
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
{description && (
<p className="text-muted-foreground mt-2">{description}</p>
)}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
}
// shared/components/feedback/empty-state.tsx
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description: string;
action?: React.ReactNode;
}

export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
{icon && <div className="text-muted-foreground mb-4 text-4xl">{icon}</div>}
<h3 className="text-lg font-semibold">{title}</h3>
<p className="text-muted-foreground mt-1 max-w-md">{description}</p>
{action && <div className="mt-6">{action}</div>}
</div>
);
}

Summary

  • shadcn/ui gives you accessible, customizable components that you own
  • ✅ Built on Radix UI primitives for accessibility and Tailwind CSS for styling
  • CSS variables enable theming including dark mode with zero JavaScript overhead
  • CVA manages component variants systematically
  • cn() utility merges Tailwind classes correctly
  • ✅ Build application-level components by composing shadcn/ui primitives
  • ✅ Your design system is part of your codebase, not a dependency