Loading
Create a complete design system with tokens, themed components, variant systems, and a living documentation page.
A design system is the single source of truth for how your product looks and behaves. It eliminates the drift that happens when every developer makes independent styling decisions. Instead of debating button padding in code review, you define it once and compose from there.
In this tutorial, you will build a design system from the ground up. You will define design tokens (colors, spacing, typography), implement five core components (Button, Input, Card, Modal, Toast), add a theme switching system, build a variant API powered by class-variance-authority, and create a documentation page that serves as both reference and visual regression test. Everything uses React and Tailwind CSS.
What you will build:
Design tokens are the atomic values of your system. Define them as CSS custom properties so they cascade naturally through themes and can be consumed by Tailwind.
The key separation is between primitive tokens (raw color values) and semantic tokens (what the color means). Components only reference semantic tokens. When you switch themes, only the semantic mappings change.
Before building components, set up the variant system. class-variance-authority generates className strings from variant props. tailwind-merge deduplicates conflicting Tailwind classes.
The button is the most used component in any system. It needs multiple variants (primary, secondary, ghost, destructive), sizes, loading states, and full accessibility.
Inputs need consistent sizing with buttons (so they align in forms), clear error states, and support for labels and helper text.
Cards are container components that group related content. They need a consistent surface treatment and composable sub-components.
Modals require careful attention to focus management, scroll locking, and escape key handling. Use a portal to render outside the DOM hierarchy.
Toasts are ephemeral messages that appear and auto-dismiss. They need a queue system so multiple toasts stack without overlapping.
Implement theme switching with a context provider that persists the preference to localStorage and applies the data-theme attribute.
Show how the five components compose together to build real UI. A settings form demonstrates every component working in concert.
Every component must pass WCAG 2.1 AA. Audit the system for focus visibility (2px emerald ring on all interactive elements), color contrast (4.5:1 for body text, 3:1 for large text), keyboard navigation (Tab, Escape, Enter, Space all work correctly), and screen reader announcements (aria-label, aria-describedby, role attributes are present).
Build a living documentation page that renders every component variant. This page doubles as a visual regression test — if something looks wrong here, it is wrong everywhere.
Structure the system for consumption. Export all components from a single entry point, provide TypeScript types, and document the token override pattern for consumers who need custom themes.
Consumers override tokens by redefining CSS custom properties on their root element. No build configuration needed — the cascade handles everything. This is the fundamental advantage of a token-based system over hardcoded values: themes are just a different set of variable assignments.
/* src/styles/tokens.css */
:root {
/* Color primitives */
--color-gray-50: #fafafa;
--color-gray-100: #f4f4f5;
--color-gray-200: #e4e4e7;
--color-gray-700: #3f3f46;
--color-gray-800: #27272a;
--color-gray-900: #18181b;
--color-gray-950: #09090b;
--color-emerald-500: #10b981;
--color-emerald-600: #059669;
--color-red-500: #ef4444;
--color-red-600: #dc2626;
/* Semantic tokens — light theme defaults */
--bg-primary: var(--color-gray-50);
--bg-surface: white;
--bg-surface-hover: var(--color-gray-100);
--border-default: var(--color-gray-200);
--text-primary: var(--color-gray-900);
--text-secondary: var(--color-gray-700);
--accent: var(--color-emerald-500);
--accent-hover: var(--color-emerald-600);
--destructive: var(--color-red-500);
--destructive-hover: var(--color-red-600);
/* Spacing scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
/* Radius */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] {
--bg-primary: var(--color-gray-950);
--bg-surface: var(--color-gray-900);
--bg-surface-hover: var(--color-gray-800);
--border-default: rgba(255, 255, 255, 0.08);
--text-primary: #f0f0f0;
--text-secondary: #a0a0a8;
}// src/lib/cn.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}// src/lib/variants.ts
import { cva, type VariantProps } from "class-variance-authority";
// This is a shared export pattern — each component defines its own cva call
// but all use the same cn() utility for merging
export { cva, type VariantProps };// src/components/ui/Button.tsx
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/cn";
const buttonVariants = cva(
"inline-flex items-center justify-center font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
primary: "bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]",
secondary:
"border border-[var(--border-default)] bg-[var(--bg-surface)] text-[var(--text-primary)] hover:bg-[var(--bg-surface-hover)]",
ghost:
"text-[var(--text-secondary)] hover:bg-[var(--bg-surface-hover)] hover:text-[var(--text-primary)]",
destructive: "bg-[var(--destructive)] text-white hover:bg-[var(--destructive-hover)]",
},
size: {
sm: "h-8 px-3 text-sm rounded-[var(--radius-sm)]",
md: "h-10 px-4 text-sm rounded-[var(--radius-md)]",
lg: "h-12 px-6 text-base rounded-[var(--radius-md)]",
icon: "h-10 w-10 rounded-[var(--radius-md)]",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
}
);
interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
isLoading?: boolean;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, isLoading, children, disabled, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled || isLoading}
{...props}
>
{isLoading && (
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{children}
</button>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };// src/components/ui/Input.tsx
import { forwardRef, type InputHTMLAttributes } from "react";
import { cn } from "@/lib/cn";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
helperText?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, helperText, id, ...props }, ref) => {
const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
return (
<div className="flex flex-col gap-[var(--space-1)]">
{label && (
<label htmlFor={inputId} className="text-sm font-medium text-[var(--text-primary)]">
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={cn(
"h-10 rounded-[var(--radius-md)] border bg-[var(--bg-surface)] px-3 text-sm text-[var(--text-primary)] transition-colors",
"placeholder:text-[var(--text-secondary)]",
"focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
error
? "border-[var(--destructive)] focus-visible:ring-[var(--destructive)]"
: "border-[var(--border-default)]",
className
)}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${inputId}-error` : undefined}
{...props}
/>
{error && (
<p id={`${inputId}-error`} className="text-sm text-[var(--destructive)]" role="alert">
{error}
</p>
)}
{helperText && !error && (
<p className="text-sm text-[var(--text-secondary)]">{helperText}</p>
)}
</div>
);
}
);
Input.displayName = "Input";
export { Input };// src/components/ui/Card.tsx
import { type HTMLAttributes, forwardRef } from "react";
import { cn } from "@/lib/cn";
const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-[var(--radius-lg)] border border-[var(--border-default)] bg-[var(--bg-surface)] shadow-[var(--shadow-sm)]",
className
)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("px-[var(--space-6)] pt-[var(--space-6)] pb-[var(--space-2)]", className)}
{...props}
/>
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-lg font-semibold text-[var(--text-primary)]", className)}
{...props}
/>
)
);
CardTitle.displayName = "CardTitle";
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("px-[var(--space-6)] pb-[var(--space-6)]", className)}
{...props}
/>
)
);
CardContent.displayName = "CardContent";
export { Card, CardHeader, CardTitle, CardContent };// src/components/ui/Modal.tsx
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/cn";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
className?: string;
}
function Modal({ isOpen, onClose, title, children, className }: ModalProps): ReactNode {
const overlayRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent): void => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleEscape);
document.body.style.overflow = "hidden";
// Focus trap: move focus into modal
contentRef.current?.focus();
return () => {
document.removeEventListener("keydown", handleEscape);
document.body.style.overflow = "";
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={(e) => {
if (e.target === overlayRef.current) onClose();
}}
role="presentation"
>
<div
ref={contentRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
className={cn(
"w-full max-w-lg rounded-[var(--radius-lg)] border border-[var(--border-default)] bg-[var(--bg-surface)] p-[var(--space-6)] shadow-[var(--shadow-lg)]",
"animate-in duration-200 zoom-in-95 fade-in",
className
)}
>
<h2 id="modal-title" className="text-lg font-semibold text-[var(--text-primary)]">
{title}
</h2>
<div className="mt-[var(--space-4)]">{children}</div>
</div>
</div>,
document.body
);
}
export { Modal };// src/components/ui/Toast.tsx
"use client";
import { useState, useCallback, type ReactNode } from "react";
import { cn } from "@/lib/cn";
type ToastVariant = "default" | "success" | "error" | "warning";
interface Toast {
id: string;
message: string;
variant: ToastVariant;
}
const variantStyles: Record<ToastVariant, string> = {
default: "border-[var(--border-default)] bg-[var(--bg-surface)]",
success: "border-emerald-500/30 bg-emerald-500/10 text-emerald-400",
error: "border-red-500/30 bg-red-500/10 text-red-400",
warning: "border-amber-500/30 bg-amber-500/10 text-amber-400",
};
function useToast(): {
toasts: Toast[];
addToast: (message: string, variant?: ToastVariant) => void;
removeToast: (id: string) => void;
} {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((message: string, variant: ToastVariant = "default") => {
const id = crypto.randomUUID();
setToasts((prev) => [...prev, { id, message, variant }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 5000);
}, []);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return { toasts, addToast, removeToast };
}
function ToastContainer({
toasts,
removeToast,
}: {
toasts: Toast[];
removeToast: (id: string) => void;
}): ReactNode {
return (
<div className="fixed right-4 bottom-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={cn(
"flex items-center gap-3 rounded-[var(--radius-md)] border px-4 py-3 text-sm shadow-[var(--shadow-md)]",
"animate-in duration-300 slide-in-from-right",
variantStyles[toast.variant]
)}
>
<span className="flex-1">{toast.message}</span>
<button onClick={() => removeToast(toast.id)} className="opacity-50 hover:opacity-100">
×
</button>
</div>
))}
</div>
);
}
export { useToast, ToastContainer, type ToastVariant };// src/components/ThemeProvider.tsx
"use client";
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
type Theme = "light" | "dark";
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function ThemeProvider({ children }: { children: ReactNode }): ReactNode {
const [theme, setTheme] = useState<Theme>("dark");
useEffect(() => {
const stored = localStorage.getItem("theme") as Theme | null;
if (stored) setTheme(stored);
}, []);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = (): void => {
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
};
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>;
}
function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}
export { ThemeProvider, useTheme };function SettingsForm(): ReactNode {
const { addToast } = useToast();
const [isModalOpen, setModalOpen] = useState(false);
return (
<Card>
<CardHeader>
<CardTitle>Account Settings</CardTitle>
</CardHeader>
<CardContent>
<form
className="flex flex-col gap-[var(--space-4)]"
onSubmit={(e) => {
e.preventDefault();
addToast("Settings saved successfully", "success");
}}
>
<Input label="Display Name" placeholder="Enter your name" />
<Input label="Email" type="email" placeholder="you@example.com" />
<Input label="Bio" helperText="Brief description for your profile" />
<div className="flex gap-[var(--space-2)]">
<Button type="submit">Save Changes</Button>
<Button variant="secondary" type="button">
Cancel
</Button>
<Button variant="destructive" type="button" onClick={() => setModalOpen(true)}>
Delete Account
</Button>
</div>
</form>
</CardContent>
<Modal isOpen={isModalOpen} onClose={() => setModalOpen(false)} title="Delete Account">
<p className="text-sm text-[var(--text-secondary)]">
This action cannot be undone. All your data will be permanently removed.
</p>
<div className="mt-4 flex justify-end gap-2">
<Button variant="secondary" onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button variant="destructive">Delete</Button>
</div>
</Modal>
</Card>
);
}function DesignSystemDocs(): ReactNode {
return (
<div className="mx-auto max-w-4xl space-y-12 p-8">
<section>
<h2 className="mb-6 text-2xl font-bold">Buttons</h2>
<div className="flex flex-wrap gap-3">
{(["primary", "secondary", "ghost", "destructive"] as const).map((variant) => (
<Button key={variant} variant={variant}>
{variant}
</Button>
))}
<Button isLoading>Loading</Button>
<Button disabled>Disabled</Button>
</div>
<div className="mt-4 flex flex-wrap gap-3">
{(["sm", "md", "lg"] as const).map((size) => (
<Button key={size} size={size}>
Size {size}
</Button>
))}
</div>
</section>
{/* Repeat for Input, Card, Modal, Toast */}
</div>
);
}// src/components/ui/index.ts
export { Button, buttonVariants } from "./Button";
export { Input } from "./Input";
export { Card, CardHeader, CardTitle, CardContent } from "./Card";
export { Modal } from "./Modal";
export { useToast, ToastContainer } from "./Toast";
export { ThemeProvider, useTheme } from "../ThemeProvider";