Design system implementation — component libraries, design tokens, Storybook, Figma-to-code, and documentation.
Design tokens are the atomic values of your design system — colors, spacing, typography, shadows. Define once, use everywhere.
/* tokens/base.css */
:root {
/* Colors - semantic naming */
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-primary-active: #1e40af;
--color-primary-foreground: #ffffff;
--color-secondary: #64748b;
--color-secondary-hover: #475569;
--color-secondary-foreground: #ffffff;
--color-destructive: #dc2626;
--color-destructive-hover: #b91c1c;
--color-destructive-foreground: #ffffff;
--color-background: #ffffff;
--color-foreground: #0f172a;
--color-muted: #f1f5f9;
--color-muted-foreground: #64748b;
--color-border: #e2e8f0;
--color-ring: #2563eb;
/* Spacing scale */
--space-0: 0;
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
/* Typography */
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.875rem; /* 30px */
--text-4xl: 2.25rem; /* 36px */
--leading-tight: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.75;
--weight-normal: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
/* Border radius */
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius-full: 9999px;
/* Transitions */
--duration-fast: 150ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
}
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
primary: {
DEFAULT: 'var(--color-primary)',
hover: 'var(--color-primary-hover)',
active: 'var(--color-primary-active)',
foreground: 'var(--color-primary-foreground)',
},
secondary: {
DEFAULT: 'var(--color-secondary)',
hover: 'var(--color-secondary-hover)',
foreground: 'var(--color-secondary-foreground)',
},
destructive: {
DEFAULT: 'var(--color-destructive)',
foreground: 'var(--color-destructive-foreground)',
},
muted: {
DEFAULT: 'var(--color-muted)',
foreground: 'var(--color-muted-foreground)',
},
border: 'var(--color-border)',
ring: 'var(--color-ring)',
background: 'var(--color-background)',
foreground: 'var(--color-foreground)',
},
fontFamily: {
sans: ['var(--font-sans)'],
mono: ['var(--font-mono)'],
},
borderRadius: {
sm: 'var(--radius-sm)',
md: 'var(--radius-md)',
lg: 'var(--radius-lg)',
xl: 'var(--radius-xl)',
},
},
},
};
Atoms → Button, Input, Badge, Avatar, Icon
Molecules → SearchBar (Input + Button), FormField (Label + Input + Error)
Organisms → Header (Logo + Nav + Avatar), Card (Image + Title + Badge + Button)
Templates → Page layouts, grid systems
Pages → Composed from templates + organisms
packages/ui/src/
├── components/
│ ├── button/
│ │ ├── button.tsx # Component implementation
│ │ ├── button.variants.ts # CVA variants
│ │ ├── button.test.tsx # Unit tests
│ │ ├── button.stories.tsx # Storybook stories
│ │ └── index.ts # Re-export
│ ├── input/
│ │ └── ...
│ └── card/
│ └── ...
├── tokens/
│ ├── base.css
│ └── dark.css
├── utils/
│ └── cn.ts # classname merge utility
└── index.ts # Public API exports
cn() Utility// packages/ui/src/utils/cn.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// components/button/button.variants.ts
import { cva, type VariantProps } from 'class-variance-authority';
export const buttonVariants = cva(
// Base styles (always applied)
'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium 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-hover',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary-hover',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive-hover',
outline: 'border border-border bg-background hover:bg-muted',
ghost: 'hover:bg-muted',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
sm: 'h-8 px-3 text-sm gap-1.5',
md: 'h-10 px-4 text-sm gap-2',
lg: 'h-12 px-6 text-base gap-2.5',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
);
export type ButtonVariants = VariantProps<typeof buttonVariants>;
// components/button/button.tsx
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cn } from '../../utils/cn';
import { buttonVariants, type ButtonVariants } from './button.variants';
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
ButtonVariants {
asChild?: boolean;
loading?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, loading, children, disabled, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...props}
>
{loading && (
<svg className="animate-spin h-4 w-4" 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}
</Comp>
);
}
);
Button.displayName = 'Button';
npx storybook@latest init --type react
pnpm add -D @storybook/addon-a11y @storybook/addon-docs
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx|mdx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-a11y',
'@storybook/addon-interactions',
],
framework: '@storybook/react-vite',
docs: { autodocs: 'tag' },
};
export default config;
// components/button/button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './button';
const meta = {
title: 'Components/Button',
component: Button,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['default', 'secondary', 'destructive', 'outline', 'ghost', 'link'],
},
size: { control: 'select', options: ['sm', 'md', 'lg', 'icon'] },
loading: { control: 'boolean' },
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { children: 'Button', variant: 'default', size: 'md' },
};
export const Secondary: Story = {
args: { children: 'Secondary', variant: 'secondary' },
};
export const Destructive: Story = {
args: { children: 'Delete', variant: 'destructive' },
};
export const Loading: Story = {
args: { children: 'Saving...', loading: true },
};
export const AllVariants: Story = {
render: () => (
<div className="flex flex-wrap gap-4">
<Button variant="default">Default</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
</div>
),
};
export const AllSizes: Story = {
render: () => (
<div className="flex items-center gap-4">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
),
};
Every component must meet:
| Requirement | Implementation |
|---|---|
| Keyboard navigation | Tab, Enter, Space, Escape, Arrow keys |
| Focus visible | focus-visible:ring-2 focus-visible:ring-ring |
| ARIA labels | aria-label, aria-labelledby, aria-describedby |
| Roles | Correct semantic roles (button, dialog, alert) |
| Screen reader text | sr-only class for visually hidden labels |
| Color contrast | 4.5:1 for text, 3:1 for large text (WCAG AA) |
| Motion | prefers-reduced-motion media query |
import * as Dialog from '@radix-ui/react-dialog';
export function Modal({ trigger, title, description, children }) {
return (
<Dialog.Root>
<Dialog.Trigger asChild>{trigger}</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-fadeIn" />
<Dialog.Content
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-background rounded-lg p-6 shadow-xl w-full max-w-md"
aria-describedby="modal-description"
>
<Dialog.Title className="text-lg font-semibold">{title}</Dialog.Title>
<Dialog.Description id="modal-description" className="text-muted-foreground mt-2">
{description}
</Dialog.Description>
<div className="mt-4">{children}</div>
<Dialog.Close asChild>
<button
className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring"
aria-label="Close"
>
✕
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
// Trap focus within a container
import { useFocusTrap } from '@mantine/hooks';
// or use Radix primitives which handle focus trapping automatically
// Return focus after closing
const triggerRef = useRef<HTMLButtonElement>(null);
function onClose() {
setOpen(false);
triggerRef.current?.focus(); // Return focus to trigger element
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* tokens/dark.css */
.dark, [data-theme="dark"] {
--color-primary: #60a5fa;
--color-primary-hover: #93bbfd;
--color-primary-foreground: #0f172a;
--color-background: #0f172a;
--color-foreground: #f8fafc;
--color-muted: #1e293b;
--color-muted-foreground: #94a3b8;
--color-border: #334155;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
}
'use client';
import { useEffect, useState } from 'react';
export function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
const stored = localStorage.getItem('theme');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initial = stored || (systemDark ? 'dark' : 'light');
setTheme(initial as 'light' | 'dark');
document.documentElement.classList.toggle('dark', initial === 'dark');
}, []);
function toggle() {
const next = theme === 'light' ? 'dark' : 'light';
setTheme(next);
localStorage.setItem('theme', next);
document.documentElement.classList.toggle('dark', next === 'dark');
}
return (
<button onClick={toggle} aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}>
{theme === 'light' ? '🌙' : '☀️'}
</button>
);
}
| Step | Tool | Action |
|---|---|---|
| Token export | Figma Tokens plugin | Export design tokens as JSON |
| Component specs | Figma Dev Mode | Inspect spacing, colors, typography |
| Asset export | Figma → SVG/PNG | Export icons and images |
| Responsive behavior | Figma auto-layout | Map to flex/grid CSS |
| Interaction specs | Figma prototyping | Document hover, active, focus states |
# Using Style Dictionary to transform Figma tokens
npx style-dictionary build --config style-dictionary.config.json
// style-dictionary.config.json
{
"source": ["tokens/**/*.json"],
"platforms": {
"css": {
"transformGroup": "css",
"buildPath": "src/tokens/",
"files": [{ "destination": "variables.css", "format": "css/variables" }]
},
"js": {
"transformGroup": "js",
"buildPath": "src/tokens/",
"files": [{ "destination": "tokens.ts", "format": "javascript/es6" }]
}
}
}
pnpm add -D chromatic
npx chromatic --project-token=YOUR_TOKEN
Add to CI:
- name: Visual regression
run: npx chromatic --auto-accept-changes=main --exit-zero-on-changes
env:
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_TOKEN }}
import { within, userEvent, expect } from '@storybook/test';
export const ClickTest: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
await userEvent.click(button);
await expect(button).toHaveAttribute('aria-busy', 'true');
},
};
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './button';
describe('Button', () => {
it('renders children', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
it('handles click', async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click</Button>);
await userEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledOnce();
});
it('shows loading state', () => {
render(<Button loading>Save</Button>);
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
expect(screen.getByRole('button')).toBeDisabled();
});
it('applies variant classes', () => {
render(<Button variant="destructive">Delete</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-destructive');
});
});
{
"name": "@myorg/ui",
"version": "1.2.0",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./styles.css": "./dist/styles.css"
},
"files": ["dist"],
"sideEffects": ["**/*.css"],
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts --external react",
"prepublishOnly": "pnpm build"
}
}
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
splitting: true,
sourcemap: true,
clean: true,
external: ['react', 'react-dom'],
treeshake: true,
});
| System | Approach | Best for |
|---|---|---|
| shadcn/ui | Copy-paste components, Radix + Tailwind | Full control, customization |
| Radix UI | Unstyled primitives with accessibility | Building custom design systems |
| Chakra UI | Styled components, theme system | Rapid development |
| Headless UI | Unstyled components from Tailwind team | Tailwind-first projects |
| Mantine | Full-featured, hooks library included | Feature-rich apps |
| Ark UI | Headless, framework-agnostic (Zag.js) | Multi-framework support |
npx shadcn@latest init
npx shadcn@latest add button card dialog input
shadcn/ui generates components directly into your project — you own the code. Modify freely. This is the best starting point for a custom design system: