
Design System
- 96 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
design-system is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- design-system
- AI & Agent Building
- AI-coding skill
Design System by the numbers
- 96 all-time installs (skills.sh)
- +7 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,561 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill design-systemAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 96 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Design System
Overview
Single source of truth for visual consistency at scale — a shared language between design and engineering. Covers token architecture, theming infrastructure, component API patterns, accessibility compliance, and governance workflows.
Design systems are not component libraries alone. They unify tokens, patterns, documentation, and contribution processes so teams build once and maintain once.
Quick Reference
| Area | Pattern | Key Points |
|---|---|---|
| Token hierarchy | Primitive > Semantic > Component | Never reference primitives in components; semantic layer is themable |
| Dark mode | Swap semantic tokens via data-theme | Use off-white/dark-gray, not pure white/black |
| Multi-brand theming | Override semantic tokens per brand | Apply via CSS custom properties at runtime |
| Tailwind v4 | @theme block in CSS (no config file) | CSS-first configuration replaces tailwind.config.js |
| CVA variants | cva() for variant + size combinations | Type-safe with VariantProps; pair with cn() utility |
| Compound components | Modal.Header, Modal.Body sub-parts | Composition over configuration; shared context for implicit state |
| Headless UI | Radix primitives + Tailwind classes | Accessibility built-in; bring your own styles |
| Focus management | focus-visible:ring-2 on all controls | 2px solid outline with offset; visible on keyboard only |
| Reduced motion | prefers-reduced-motion media query | Near-zero duration for all transitions and animations |
| Style Dictionary | JSON tokens to CSS/iOS/Android | outputReferences: true preserves token chain |
| Storybook | Stories + autodocs + a11y addon | Visual documentation with accessibility audit built in |
| Governance | Semver for tokens and components | Breaking = removed props/tokens; minor = new additions |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
Primitive tokens in components (blue-500) | Reference semantic tokens (interactive-primary) that map to primitives |
| Skipping focus states on interactive elements | Add focus-visible:ring-2 on every button, link, and input |
Body text set to gray-400 | Use gray-600 or darker to meet 4.5:1 WCAG AA contrast |
| Circular token references between layers | Tokens flow one direction: primitive > semantic > component |
Ignoring prefers-reduced-motion | Wrap all animations in a reduced-motion media query |
Using scale() transforms for hover | Use translateY(-1px) and shadow changes to avoid layout shift |
| Hardcoded hex/px values in component files | All visual values come from semantic or component tokens |
| Deep CSS nesting for theme overrides | Override CSS custom properties at the semantic layer |
| Theme flash on page load (FOUC) | Inject synchronous theme script in <head> before body renders |
| Flat token list without layers | Organize into primitive, semantic, and component tiers |
Delegation
- Audit codebase for hardcoded values that should be tokens: Use
Exploreagent - Migrate a component library to a token-based design system: Use
Taskagent - Plan a multi-brand theming architecture: Use
Planagent - Review accessibility compliance across components: Use
Taskagent
If the motion skill is available, delegate animation token integration and motion design patterns to it.References
- Design Tokens — three-layer hierarchy, CSS custom properties, TypeScript definitions, naming conventions
- Theming — dark mode, theme provider, multi-brand, Tailwind v4, SSR flash prevention
- Color and Typography — 60-30-10 rule, contrast requirements, type scale, fluid typography
- Component Architecture — CVA variants, compound components, headless UI, polymorphic rendering
- Accessibility — WCAG compliance, keyboard navigation, ARIA patterns, screen reader testing
- Motion and Layout — animation tokens, reduced motion, spacing grid, elevation system
- Tooling — Style Dictionary, Storybook, component testing, visual regression
- Governance — versioning, deprecation, contribution workflow, component lifecycle
- Troubleshooting — common issues, dark mode fixes, token sprawl, pre-delivery checklist
Accessibility
Accessibility is built into design system foundations, not bolted on after. Every component ships with keyboard support, ARIA semantics, and contrast compliance by default.
WCAG 2.1 AA Requirements
| Check | Requirement | Level |
|---|---|---|
| Normal text contrast | 4.5:1 ratio minimum | AA |
| Large text contrast | 3:1 ratio (18px+ or 14px bold) | AA |
| UI component contrast | 3:1 ratio against adjacent colors | AA |
| Touch targets | 44x44px minimum | AAA |
| Focus indicators | Visible ring on all interactive elements | AA |
| Reduced motion | Respect prefers-reduced-motion | AA |
| Body font size | 16px minimum on mobile | Best |
| Line length | 65-75 characters maximum | Best |
| Text spacing | Support 1.5x line height, 2x paragraph | AA |
Focus Management
Every interactive element needs a visible focus indicator. Use focus-visible to show the ring only for keyboard users.
/* Base focus style for all interactive elements */
:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* Remove default outline for mouse users */
:focus:not(:focus-visible) {
outline: none;
}
/* Ensure buttons, links, inputs all have focus styles */
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}Keyboard Navigation Patterns
| Component | Keys | Behavior |
|---|---|---|
| Button | Enter, Space | Activate |
| Link | Enter | Navigate |
| Menu | Arrow keys, Escape | Navigate items, close |
| Dialog | Escape, Tab (trapped) | Close, cycle focus within |
| Tabs | Arrow keys, Home, End | Switch tabs |
| Combobox | Arrow keys, Enter, Escape | Navigate options, select, close |
| Checkbox | Space | Toggle |
| Radio | Arrow keys | Move selection within group |
Focus Trapping in Dialogs
When a dialog opens, trap focus inside it. Return focus to the trigger when it closes.
import { useEffect, useRef } from 'react';
function useFocusTrap(isOpen: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!isOpen || !containerRef.current) return;
triggerRef.current = document.activeElement as HTMLElement;
const focusable = containerRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
first?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
triggerRef.current?.focus();
};
}, [isOpen]);
return containerRef;
}ARIA Patterns for Common Components
Buttons with Loading State
<button disabled={isLoading} aria-busy={isLoading || undefined}>
{isLoading ? <Spinner aria-hidden="true" /> : null}
<span className={isLoading ? 'sr-only' : undefined}>Save</span>
</button>Form Validation
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-invalid={!!error}
aria-describedby={error ? 'email-error' : undefined}
/>
{error ? (
<p id="email-error" role="alert">
{error}
</p>
) : null}
</div>Live Regions for Notifications
<div aria-live="polite" aria-atomic="true" className="sr-only">
{statusMessage}
</div>Use aria-live="polite" for non-urgent updates and aria-live="assertive" for critical alerts.
Skip Navigation
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:z-50"
>
Skip to main content
</a>Screen Reader Only Utility
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}Reduced Motion
Respect user preference for reduced motion:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}In JavaScript, check the preference before triggering animations:
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
const animationDuration = prefersReducedMotion ? 0 : 300;High Contrast Mode
Support Windows High Contrast Mode and forced colors:
@media (forced-colors: active) {
.button {
border: 1px solid ButtonText;
}
.icon {
forced-color-adjust: auto;
}
}Automated Testing
jest-axe for Unit Tests
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<Button>Click me</Button>);
expect(await axe(container)).toHaveNoViolations();
});Storybook a11y Addon
Add @storybook/addon-a11y to run axe checks on every story automatically. Violations appear in the Accessibility panel during development.
Accessibility Audit Checklist
- Color contrast passes in both light and dark themes
- All interactive elements reachable via keyboard
- Focus indicator visible on every focusable element
- Form inputs have associated labels
- Images have descriptive alt text
- Dialogs trap focus and return it on close
aria-liveregions announce dynamic content changesprefers-reduced-motionrespected- Skip navigation link present
- Tested with VoiceOver (macOS), NVDA (Windows), or TalkBack (Android)
Color and Typography
60-30-10 Rule
Distribute color usage across the interface:
- 60% Primary (backgrounds, large areas)
- 30% Secondary (complementary elements, cards, sidebars)
- 10% Accent (CTAs, highlights, alerts)
Color Scheme Relationships
| Scheme | Description | Use Case |
|---|---|---|
| Complementary | Opposite on wheel (blue + orange) | High contrast, CTAs |
| Analogous | Adjacent colors (blue + teal + green) | Harmonious, calm |
| Triadic | Three evenly spaced | Balanced, vibrant |
| Monochromatic | Shades of one color | Minimal, safe |
Semantic Color Palette
Define colors by purpose, not appearance:
:root {
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-info: #3b82f6;
}Each semantic color needs a foreground and background pair that passes contrast.
Contrast Requirements (WCAG 2.1 AA)
| Element | Minimum Ratio |
|---|---|
| Normal text | 4.5:1 |
| Large text (18px+ or 14px bold) | 3:1 |
| UI components and borders | 3:1 |
| Focus indicators | 3:1 |
/* Pass: 8:1 contrast */
.text-good {
color: #111827;
background: #ffffff;
}
/* Fail: 2.1:1 contrast */
.text-bad {
color: #d1d5db;
background: #ffffff;
}Check contrast at the semantic token layer, where foreground actually meets background. Primitive tokens alone cannot guarantee compliance because they lack usage context.
CSS color-mix for Derived Colors
Generate hover and active states from a base color without separate tokens:
:root {
--color-primary: #3b82f6;
--color-primary-hover: color-mix(in srgb, var(--color-primary), black 10%);
--color-primary-active: color-mix(in srgb, var(--color-primary), black 20%);
--color-primary-light: color-mix(in srgb, var(--color-primary), white 80%);
}Font Pairing (Maximum Two Fonts)
| Style | Heading | Body |
|---|---|---|
| Modern and Clean | Inter | Inter |
| Classic and Professional | Playfair Display (serif) | Source Sans Pro |
| Tech and Minimal | Space Grotesk | IBM Plex Sans |
| Creative and Friendly | Poppins | Open Sans |
Using a single font family with weight variation (Inter 400/500/600/700) reduces load time and maintains consistency.
Modular Type Scale (1.25 ratio, 16px base)
:root {
--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 */
}Line Height and Letter Spacing
| Context | Line Height | Letter Spacing |
|---|---|---|
| Headings | 1.2 (tight) | -0.02em (tighter) |
| Body text | 1.5-1.6 | 0 (normal) |
| Small text | 1.8 | 0 (normal) |
| Uppercase | 1.2 | 0.05em (wider) |
| Captions | 1.4 | 0.01em |
Fluid Typography
Scale text smoothly between breakpoints without media queries:
h1 {
font-size: clamp(2rem, 5vw, 3.5rem);
}
h2 {
font-size: clamp(1.5rem, 3.5vw, 2.5rem);
}
body {
font-size: clamp(1rem, 1vw + 0.75rem, 1.125rem);
}clamp(min, preferred, max) provides a minimum, a viewport-relative value, and a maximum. The browser picks the appropriate size within that range.
Responsive Type Scale
For projects that prefer explicit breakpoints over fluid type:
:root {
--text-h1: 2rem;
}
@media (min-width: 768px) {
:root {
--text-h1: 2.5rem;
}
}
@media (min-width: 1024px) {
:root {
--text-h1: 3rem;
}
}Text Readability Rules
- Maximum line width: 65-75 characters (approximately
max-width: 65ch) - Minimum body text size: 16px (1rem) on mobile
- Paragraph spacing: at least 1.5x the font size
- Avoid justified text in narrow containers (causes uneven spacing)
Component Architecture
API Design Principles
1. Sensible defaults — works with minimal props (<Button>Click</Button>) 2. Composition over configuration — prefer compound components over prop-heavy APIs 3. Controlled and uncontrolled modes — support both value and defaultValue 4. Polymorphic rendering — as prop for element flexibility
CVA Variant System
Type-safe variant management with class-variance-authority:
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-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: {
sm: 'h-9 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-11 px-8 text-base',
icon: 'h-10 w-10',
},
},
defaultVariants: { variant: 'default', size: 'md' },
},
);
interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean;
}
export function Button({
className,
variant,
size,
isLoading,
disabled,
children,
...props
}: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
disabled={disabled || isLoading}
aria-busy={isLoading || undefined}
{...props}
>
{children}
</button>
);
}Compound Components
Compound components share implicit state through React context, allowing flexible composition without prop drilling.
<Modal>
<Modal.Header>Delete Account</Modal.Header>
<Modal.Body>Are you sure?</Modal.Body>
<Modal.Footer>
<Button variant="destructive" onClick={handleDelete}>
Delete
</Button>
<Button variant="secondary" onClick={handleCancel}>
Cancel
</Button>
</Modal.Footer>
</Modal>Prefer this over a single component with many props:
/* Avoid this pattern */
<Modal
title="Delete Account"
body="Are you sure?"
confirmText="Delete"
cancelText="Cancel"
onConfirm={handleDelete}
onCancel={handleCancel}
/>Headless UI (Radix + Tailwind)
Radix provides accessible behavior; you provide the styles. This separates logic from presentation.
import * as Tooltip from '@radix-ui/react-tooltip';
export function CustomTooltip({
children,
content,
}: {
children: React.ReactNode;
content: string;
}) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
<Tooltip.Content className="bg-surface-elevated text-text-primary p-2 rounded-md shadow-lg animate-in fade-in">
{content}
<Tooltip.Arrow className="fill-surface-elevated" />
</Tooltip.Content>
</Tooltip.Root>
);
}Polymorphic Components
Allow components to render as different HTML elements or framework-specific components:
type PolymorphicProps<T extends React.ElementType> = {
as?: T;
} & React.ComponentPropsWithoutRef<T>;
function Button<T extends React.ElementType = 'button'>({
as,
...props
}: PolymorphicProps<T>) {
const Component = as || 'button';
return <Component {...props} />;
}Usage:
<Button onClick={handleClick}>Click</Button>
<Button as="a" href="/dashboard">Dashboard</Button>
<Button as={Link} href="/about">About</Button>Controlled and Uncontrolled Patterns
Support both modes so consumers can choose:
interface InputProps {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
}
function Input({ value, defaultValue, onChange, ...props }: InputProps) {
const [internal, setInternal] = useState(defaultValue ?? '');
const isControlled = value !== undefined;
const currentValue = isControlled ? value : internal;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!isControlled) setInternal(e.target.value);
onChange?.(e.target.value);
};
return <input value={currentValue} onChange={handleChange} {...props} />;
}FormField Pattern (Molecule)
Compose atoms into a labeled, accessible form field:
function FormField({
label,
error,
hint,
required,
children,
}: {
label: string;
error?: string;
hint?: string;
required?: boolean;
children: (props: {
id: string;
'aria-invalid'?: boolean;
'aria-describedby'?: string;
}) => React.ReactNode;
}) {
const id = useId();
const errorId = `${id}-error`;
const hintId = `${id}-hint`;
const describedBy =
[hint ? hintId : null, error ? errorId : null].filter(Boolean).join(' ') ||
undefined;
return (
<div>
<label htmlFor={id}>
{label}
{required ? <span aria-label="required">*</span> : null}
</label>
{hint ? <div id={hintId}>{hint}</div> : null}
{children({
id,
'aria-invalid': error ? true : undefined,
'aria-describedby': describedBy,
})}
{error ? (
<div id={errorId} role="alert">
{error}
</div>
) : null}
</div>
);
}Component States
Every interactive component must implement these states:
| State | Visual | ARIA |
|---|---|---|
| Default | Base styles | -- |
| Hover | Background/shadow shift | -- |
| Focus | Visible ring (outline: 2px solid) | -- |
| Active/Pressed | Darker shade | -- |
| Disabled | 50% opacity, cursor: not-allowed | aria-disabled |
| Loading | Spinner overlay, text hidden | aria-busy="true" |
| Error | Red border, error message | aria-invalid="true" |
Atomic Design Levels
| Level | Description | Examples |
|---|---|---|
| Atom | Smallest UI unit | Button, Input, Label, Icon, Badge |
| Molecule | Group of atoms | FormField, SearchBar, Card |
| Organism | Complex UI section | Header, LoginForm, ProductGrid |
| Template | Page layout structure | DashboardLayout, MarketingLayout |
| Page | Template with real data | HomePage, SettingsPage |
Build atoms first, compose into molecules, then organisms. Templates define layout without content; pages fill in real data.
Design Tokens
Atomic values of a design system — colors, spacing, typography, shadows, radii — stored as data and consumed across platforms. Tokens are the single source of truth: change once, update everywhere.
Three-Layer CSS Custom Properties
/* Layer 1: Primitive tokens (raw values, never used directly in components) */
:root {
--color-blue-500: #3b82f6;
--color-blue-600: #2563eb;
--color-gray-50: #fafafa;
--color-gray-100: #f5f5f5;
--color-gray-200: #e5e7eb;
--color-gray-400: #9ca3af;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #171717;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-4: 1rem;
--space-6: 1.5rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 1rem;
--shadow-sm: 0 1px 2px rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
/* Layer 2: Semantic tokens (meaning, theme-switchable) */
:root {
--text-primary: var(--color-gray-900);
--text-secondary: var(--color-gray-600);
--surface-default: white;
--surface-elevated: var(--color-gray-50);
--border-default: var(--color-gray-200);
--interactive-primary: var(--color-blue-500);
--interactive-primary-hover: var(--color-blue-600);
--focus-ring: var(--color-blue-500);
}
/* Layer 3: Component tokens (specific usage) */
:root {
--button-bg: var(--interactive-primary);
--button-bg-hover: var(--interactive-primary-hover);
--button-text: white;
--button-radius: var(--radius-md);
--button-padding-x: var(--space-4);
--button-padding-y: var(--space-2);
--input-border: var(--border-default);
--input-focus-ring: var(--focus-ring);
--card-bg: var(--surface-elevated);
--card-shadow: var(--shadow-md);
}Token Naming Conventions
Consistent naming prevents sprawl and makes tokens discoverable.
| Pattern | Example | Rule |
|---|---|---|
| Primitive: category-scale | color-blue-500 | Descriptive of the raw value |
| Semantic: purpose | text-primary | Named by intent, not appearance |
| Component: component-prop | button-bg | Scoped to specific component usage |
| Kebab-case throughout | interactive-primary-hover | No camelCase or mixed conventions |
| No visual descriptions | text-primary not dark-gray | Semantic names survive theme changes |
Token Categories
| Category | Primitive | Semantic | Component |
|---|---|---|---|
| Color | color-blue-500: #3b82f6 | interactive-primary: {blue-500} | button-bg: {interactive-primary} |
| Spacing | space-4: 1rem | spacing-default: {space-4} | button-padding-x: {spacing-default} |
| Typography | font-size-base: 1rem | text-body: {font-size-base} | input-font-size: {text-body} |
| Shadow | shadow-md: 0 4px 6px... | elevation-card: {shadow-md} | card-shadow: {elevation-card} |
| Radius | radius-md: 0.5rem | radius-interactive: {radius-md} | button-radius: {radius-interactive} |
File Architecture
tokens/
primitives/
colors.json
spacing.json
typography.json
shadows.json
radii.json
semantic/
colors.json
spacing.json
typography.json
components/
button.json
input.json
card.jsonW3C Design Token Community Group Format
The DTCG format standardizes token interchange across tools (Figma, Style Dictionary, Tokens Studio).
{
"color": {
"brand": {
"primary": {
"$type": "color",
"$value": "#3b82f6",
"$description": "Primary brand color"
}
}
},
"spacing": {
"default": {
"$type": "dimension",
"$value": "1rem"
}
}
}Use $type, $value, and $description fields. Tools like Style Dictionary v4 and Tokens Studio support this format natively.
TypeScript Token Definition
export const tokens = {
colors: {
primary: {
50: 'oklch(0.97 0.014 254.6)',
100: 'oklch(0.932 0.032 255.6)',
500: 'oklch(0.623 0.214 259.1)',
600: 'oklch(0.546 0.245 262.9)',
700: 'oklch(0.488 0.243 264.4)',
900: 'oklch(0.379 0.146 265.8)',
},
gray: {
50: 'oklch(0.985 0.002 247.9)',
100: 'oklch(0.967 0.003 264.5)',
200: 'oklch(0.928 0.006 264.5)',
300: 'oklch(0.872 0.01 258.3)',
500: 'oklch(0.551 0.014 264.4)',
600: 'oklch(0.446 0.015 264.5)',
700: 'oklch(0.373 0.013 261.1)',
900: 'oklch(0.21 0.006 264.5)',
},
semantic: {
success: 'oklch(0.696 0.17 162.5)',
warning: 'oklch(0.769 0.188 70.1)',
error: 'oklch(0.628 0.258 29.2)',
info: 'oklch(0.623 0.214 259.1)',
},
},
spacing: {
1: '0.25rem',
2: '0.5rem',
3: '0.75rem',
4: '1rem',
6: '1.5rem',
8: '2rem',
12: '3rem',
16: '4rem',
},
typography: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
fontSize: {
xs: '0.75rem',
sm: '0.875rem',
base: '1rem',
lg: '1.125rem',
xl: '1.25rem',
'2xl': '1.5rem',
'3xl': '1.875rem',
'4xl': '2.25rem',
},
fontWeight: { normal: 400, medium: 500, semibold: 600, bold: 700 },
lineHeight: { tight: 1.25, normal: 1.5, relaxed: 1.75 },
},
shadows: {
sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
md: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1)',
xl: '0 20px 25px -5px rgb(0 0 0 / 0.1)',
},
radii: {
sm: '0.25rem',
md: '0.375rem',
lg: '0.5rem',
xl: '0.75rem',
'2xl': '1rem',
full: '9999px',
},
} as const;Governance
Design systems require clear governance to scale across teams. This covers versioning, deprecation, contribution processes, and component prioritization.
Semantic Versioning
Treat token and component changes as API changes. Follow semver strictly.
| Change Type | Version Bump | Examples |
|---|---|---|
| Breaking | Major | Removed prop, renamed token, changed defaults |
| New feature | Minor | New component, new variant, new token |
| Bug fix | Patch | CSS fix, a11y fix, TypeScript correction |
What Counts as Breaking
- Removing or renaming a component prop
- Changing a prop's type signature
- Removing or renaming a design token
- Changing default behavior (a previously-optional prop becomes required)
- Changing the DOM structure components render
What Does Not Break
- Adding new optional props
- Adding new components
- Adding new token values
- Bug fixes that match documented behavior
- Accessibility improvements
Deprecation Strategy
Deprecate gradually across three releases before removing.
Step 1: Announce Deprecation
interface ButtonProps {
/** @deprecated Use `variant` instead. Will be removed in v3.0.0. */
type?: 'primary' | 'secondary';
variant?: 'primary' | 'secondary';
}Step 2: Support Both with Runtime Warning
function Button({ type, variant, ...props }: ButtonProps) {
if (type && process.env.NODE_ENV === 'development') {
console.warn(
'Button: `type` prop is deprecated. Use `variant` instead. Removal in v3.0.0.',
);
}
const resolved = variant ?? type ?? 'primary';
return <button data-variant={resolved} {...props} />;
}Step 3: Remove in Next Major
interface ButtonProps {
variant?: 'primary' | 'secondary';
}Document the migration path in the changelog.
Contribution Workflow
1. Proposal -> GitHub issue with use case and API sketch
2. Design -> Figma mockup reviewed by design lead
3. API review -> TypeScript interface approved before coding
4. Build -> PR with implementation, tests, and stories
5. Document -> Storybook docs with usage guidelines
6. Release -> Semver bump + changelog entryProposal Template
Proposals should answer:
- Problem: What user need does this solve?
- Usage: When should this component/token be used?
- API: What does the TypeScript interface look like?
- Accessibility: How will it be keyboard and screen reader accessible?
- Alternatives: What other approaches were considered?
Component Lifecycle
Prioritize components by product need:
| Priority | Components | Criteria |
|---|---|---|
| P0 | Button, Input, Label, Text, Icon, FormField | Used on every screen |
| P1 | Select, Checkbox, Radio, Switch, Tabs, Tooltip | Used in most forms/layouts |
| P2 | DatePicker, Combobox, Slider, Toast, Drawer | Used in specific features |
| P3 | DataTable, Calendar, FileUpload, Stepper | Complex, domain-specific |
Build P0 first, then ship P1 once P0 is stable. P2 and P3 are added based on product demand.
Changelog Management
Maintain a changelog that documents every release. Use tools like Changesets for automated changelog generation in monorepos.
Changelog Format
## [2.0.0]
### Breaking Changes
- **Button**: Renamed `type` prop to `variant`
- **tokens**: Removed `color-gray-500` (use `color-gray-600`)
### Migration Guide
Replace `type` with `variant` on all Button instances:
- Before: `<Button type="primary">`
- After: `<Button variant="primary">`
## [1.5.0]
### Added
- **Toast**: New component for notifications
- **Button**: New `isLoading` prop
### Fixed
- **Button**: Focus outline visible on all browsers
- **Input**: Placeholder color meets WCAG contrastDesign System Team Roles
| Role | Responsibility |
|---|---|
| Owner | Vision, roadmap, final approval |
| Design lead | Visual standards, Figma library, design review |
| Engineering | Implementation, tooling, CI/CD, releases |
| Contributors | Product teams proposing and building components |
Token Governance
Tokens are shared infrastructure. Changes require the same review rigor as API changes.
- New primitive tokens need justification (does a similar value exist?)
- Semantic tokens must map to documented use cases
- Component tokens are scoped to a single component
- Unused tokens are removed during quarterly audits
- Token naming follows the established convention (kebab-case, category-purpose pattern)
Quality Gates
Before any release, verify:
- All components pass automated a11y tests (jest-axe)
- Visual regression tests show no unintended changes (Chromatic)
- TypeScript types are correct and exported
- Storybook stories exist for all variants and states
- Changelog entry describes the change
- Token changes are backward-compatible (or major version bumped)
Motion and Layout
Animation Tokens
Define duration and easing as tokens for consistent motion across the system:
:root {
--duration-fast: 150ms;
--duration-normal: 300ms;
--duration-slow: 500ms;
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
.button {
transition: all var(--duration-fast) var(--ease-in-out);
}
.button:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}Duration Guidelines
| Duration | Use Case |
|---|---|
| Fast | Hover states, color changes, opacity shifts |
| Normal | Page transitions, content reveals, tooltips |
| Slow | Full-page animations, complex transitions |
Performant Animations
Use transform and opacity for GPU acceleration. Never animate width, height, top, left, or margin — these trigger layout recalculation.
/* Good: GPU-accelerated */
.card-hover {
transition:
transform var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
/* Bad: triggers layout */
.card-hover-bad:hover {
margin-top: -2px;
height: calc(100% + 2px);
}Reduced Motion
Respect the user's system preference:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}For more granular control, check the preference in JavaScript:
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;8pt Spacing Grid
Most screens are divisible by 8, creating consistent visual rhythm:
:root {
--space-0: 0;
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
--space-20: 5rem; /* 80px */
--space-24: 6rem; /* 96px */
}Use spacing tokens for all padding, margin, and gap values. Avoid one-off pixel values.
Responsive Breakpoints
:root {
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
--breakpoint-2xl: 1536px;
}Use mobile-first media queries (min-width). Test at 375px, 768px, 1024px, and 1440px.
Visual Hierarchy
Establish importance through four tools:
1. Size — larger elements draw attention first 2. Weight — bolder text signals importance 3. Color — saturated/bright colors attract the eye 4. Space — more whitespace around an element elevates its importance
Apply these consistently. A heading that is large, bold, and surrounded by whitespace communicates top-level importance without any additional styling.
Shadow Elevation System
:root {
--shadow-sm: 0 1px 2px rgb(0 0 0 / 0.05); /* Buttons, inputs */
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1); /* Cards */
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1); /* Dropdowns */
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.15); /* Modals */
}Higher elevation = more shadow. Use consistently to communicate depth and layer hierarchy. In dark mode, reduce shadow opacity or rely more on surface color differentiation.
Container Layout Pattern
.container {
width: 100%;
max-width: var(--breakpoint-xl);
margin-inline: auto;
padding-inline: var(--space-4);
}
@media (min-width: 768px) {
.container {
padding-inline: var(--space-8);
}
}Use margin-inline: auto and padding-inline for logical properties that support RTL layouts.
Theming
Dark Mode via Semantic Tokens
Override semantic tokens per theme — components reference semantic tokens and automatically adapt.
:root,
[data-theme='light'] {
--text-primary: var(--color-gray-900);
--text-secondary: var(--color-gray-600);
--surface-default: #f9fafb;
--surface-elevated: var(--color-gray-50);
--border-default: var(--color-gray-200);
--interactive-primary: var(--color-blue-500);
}
[data-theme='dark'] {
--text-primary: var(--color-gray-50);
--text-secondary: var(--color-gray-400);
--surface-default: var(--color-gray-900);
--surface-elevated: var(--color-gray-800);
--border-default: var(--color-gray-700);
--interactive-primary: var(--color-blue-400);
}When combining with shadcn/ui, note that shadcn uses .dark class-based switching while this design system uses data-theme attributes. The ThemeProvider above applies both (classList.add and setAttribute) for compatibility. Choose one convention per project — .dark class is recommended when using shadcn/ui components.
Dark mode checklist:
- Reduce pure white (
#fff) to off-white (#f9fafb) - Reduce pure black (
#000) to dark gray (#111827) - Lighten interactive colors for dark backgrounds (blue-500 becomes blue-400)
- Re-verify all contrast ratios in both themes
- Support system preference via
prefers-color-scheme
Theme Provider (React)
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextValue {
theme: Theme;
resolvedTheme: 'light' | 'dark';
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window !== 'undefined') {
return (localStorage.getItem('theme') as Theme) || 'system';
}
return 'system';
});
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
const root = document.documentElement;
const applyTheme = (isDark: boolean) => {
root.classList.remove('light', 'dark');
root.classList.add(isDark ? 'dark' : 'light');
root.setAttribute('data-theme', isDark ? 'dark' : 'light');
setResolvedTheme(isDark ? 'dark' : 'light');
};
if (theme === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
applyTheme(mediaQuery.matches);
const handler = (e: MediaQueryListEvent) => applyTheme(e.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
} else {
applyTheme(theme === 'dark');
}
}, [theme]);
useEffect(() => {
localStorage.setItem('theme', theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within ThemeProvider');
return context;
};SSR Flash Prevention
Theme resolution on the client causes a flash of the wrong theme. Inject a blocking script in <head> before the body renders.
<head>
<script>
(function () {
var theme = localStorage.getItem('theme') || 'system';
var resolved = theme;
if (theme === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
document.documentElement.setAttribute('data-theme', resolved);
document.documentElement.classList.add(resolved);
})();
</script>
</head>For cookie-based SSR (Next.js, Remix), read the theme from a cookie on the server and set data-theme in the initial HTML response to avoid any flash.
Multi-Brand Theming
Override semantic tokens per brand at runtime:
interface BrandTheme {
colors: Record<string, string>;
fontFamily: string;
spacing?: { unit: number };
}
function applyBrandTheme(theme: BrandTheme) {
const root = document.documentElement;
Object.entries(theme.colors).forEach(([key, value]) => {
root.style.setProperty(`--color-${key}`, value);
});
root.style.setProperty('--font-base', theme.fontFamily);
if (theme.spacing) {
root.style.setProperty('--space-unit', `${theme.spacing.unit}px`);
}
}Brand definitions share the same token interface — only values differ:
const acmeBrand: BrandTheme = {
colors: { primary: '#3b82f6', secondary: '#8b5cf6' },
fontFamily: 'Inter, sans-serif',
};
const contosoBrand: BrandTheme = {
colors: { primary: '#dc2626', secondary: '#f59e0b' },
fontFamily: 'Roboto, sans-serif',
};Tailwind v4 CSS-First Theme
Tailwind v4 replaces tailwind.config.js with CSS @theme blocks. Tokens become native CSS custom properties.
@import 'tailwindcss';
@theme {
--color-blue-500: #3b82f6;
--color-brand-primary: var(--color-blue-500);
--color-action-hover: color-mix(
in srgb,
var(--color-brand-primary),
black 10%
);
--button-radius: var(--radius-lg);
}Tailwind v4 Monorepo Pattern
Centralize tokens in a shared package:
/* @repo/design-tokens/base.css */
@import 'tailwindcss';
@theme {
--color-brand: #7c3aed;
--font-sans: 'Geist', sans-serif;
}Consuming apps import the shared theme:
/* apps/web/src/globals.css */
@import '@repo/design-tokens/base.css';Z-Index Scale
Define a consistent z-index system to avoid arbitrary stacking conflicts.
:root {
--z-dropdown: 100;
--z-sticky: 200;
--z-modal-backdrop: 250;
--z-modal: 300;
--z-toast: 400;
--z-tooltip: 500;
}Components reference these tokens instead of hardcoded z-index values.
Tooling
Style Dictionary Pipeline
Transform tokens from JSON to CSS, iOS, Android, and other platforms. Use outputReferences: true to preserve the token reference chain in CSS output.
module.exports = {
source: ['tokens/**/*.json'],
platforms: {
css: {
transformGroup: 'css',
buildPath: 'dist/css/',
files: [
{
destination: 'variables.css',
format: 'css/variables',
options: { outputReferences: true },
},
],
},
scss: {
transformGroup: 'scss',
buildPath: 'dist/scss/',
files: [
{
destination: '_variables.scss',
format: 'scss/variables',
},
],
},
ios: {
transformGroup: 'ios-swift',
buildPath: 'dist/ios/',
files: [
{
destination: 'DesignTokens.swift',
format: 'ios-swift/class.swift',
className: 'DesignTokens',
},
],
},
android: {
transformGroup: 'android',
buildPath: 'dist/android/',
files: [
{
destination: 'colors.xml',
format: 'android/colors',
filter: { attributes: { category: 'color' } },
},
],
},
},
};Build tokens:
npx style-dictionary buildStyle Dictionary v4 supports the W3C DTCG token format ($type, $value fields) natively.
Figma to Code Sync
Use Tokens Studio (formerly Figma Tokens) to sync design tokens from Figma to JSON files in your repository. The flow:
1. Designers define tokens in Figma via Tokens Studio plugin 2. Tokens Studio syncs token JSON to a GitHub branch 3. CI runs Style Dictionary to transform tokens into platform outputs 4. PR review ensures token changes are intentional
Storybook Documentation
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Atoms/Button',
component: Button,
parameters: { layout: 'centered' },
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'ghost', 'destructive'],
},
size: { control: 'select', options: ['sm', 'md', 'lg'] },
isLoading: { control: 'boolean' },
disabled: { control: 'boolean' },
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: { variant: 'primary', children: 'Button' },
};
export const AllVariants: Story = {
render: () => (
<div style={{ display: 'flex', gap: '1rem' }}>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Destructive</Button>
</div>
),
};Storybook Addons
| Addon | Purpose |
|---|---|
@storybook/addon-a11y | Accessibility audit per story |
@storybook/addon-interactions | Test user interactions |
storybook-addon-designs | Link Figma frames to stories |
Component Testing
Test components by role and accessible name, not by implementation details:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('renders children', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('disables button when loading', () => {
render(<Button isLoading>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});Accessibility Testing
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<Button>Click me</Button>);
expect(await axe(container)).toHaveNoViolations();
});Run axe on every component in CI to catch regressions.
Visual Regression with Chromatic
Chromatic captures snapshots of every Storybook story and compares them across PRs.
npx chromatic --project-token=$CHROMATIC_TOKENAdd to CI:
- name: Visual regression
run: npx chromatic --project-token=${{ secrets.CHROMATIC_TOKEN }} --exit-zero-on-changesChromatic flags visual diffs for human review before merge.
CI/CD Integration
A design system CI pipeline should include:
| Step | Tool | Purpose |
|---|---|---|
| Lint | ESLint, Stylelint | Code quality |
| Type check | TypeScript | Type safety |
| Unit tests | Vitest, Testing Library | Component behavior |
| A11y tests | jest-axe | Accessibility compliance |
| Build tokens | Style Dictionary | Token transformation |
| Visual regression | Chromatic | Catch unintended visual changes |
| Build Storybook | Storybook | Documentation generation |
| Publish | Changesets, npm | Package release |
Changesets for Versioning
Changesets automates versioning and changelog generation in monorepos:
npx changeset
npx changeset version
npx changeset publishEach PR includes a changeset file describing the change type (major/minor/patch) and a description. The version command bumps versions and generates changelog entries.
Troubleshooting
UI Looks Generic / Lacks Identity
Cause: No visual direction or custom tokens. Fix: Define specific color palette (60/30/10 rule), choose intentional font pairing, apply consistent spacing scale. Avoid default framework styles without customization.
Layout Breaks on Mobile
Cause: Missing responsive grid rules or breakpoint definitions. Fix: Define breakpoints (640/768/1024/1280px), use mobile-first approach. Test at 375px, 768px, 1024px, 1440px. Ensure touch targets are 44x44px minimum.
Inconsistent Components Across Screens
Cause: Raw values used instead of tokens; no variant system. Fix: Reference all visual values from tokens. Use CVA for variant management. Define component states systematically (default, hover, focus, active, disabled, loading, error).
Dark Mode Colors Look Wrong
Cause: Semantic token layer missing; primitive tokens used directly. Fix: Create semantic token layer mapping to different primitives per theme. Never use #ffffff or #000000 — use off-white (#f9fafb) and dark gray (#111827). Re-verify contrast ratios in both themes.
Token Sprawl (Too Many Tokens)
Cause: No hierarchy or naming convention; ad-hoc creation. Fix: Audit tokens into three layers (primitive, semantic, component). Remove unused tokens. Enforce naming review in PRs. Use color-mix() for derived values instead of new tokens.
Accessibility Audit Failures
Cause: Contrast ratios not checked at semantic token layer; missing ARIA attributes. Fix: Verify contrast where foreground meets background. Use automated tools (axe, jest-axe). Check both light and dark themes. Add aria-invalid, aria-busy, aria-describedby where appropriate.
Flash of Wrong Theme on Load
Cause: Theme resolved client-side after paint. Fix: Inject theme script in <head> before body renders. Read from localStorage or cookie synchronously. For SSR frameworks, set data-theme on the server response.
Hover States Cause Layout Shift
Cause: Using scale() or width/height transforms on hover. Fix: Use translateY(-1px) and box-shadow changes. Avoid transform: scale() on interactive cards. Animate only transform and opacity for GPU-accelerated rendering.
Components Not Tree-Shakeable
Cause: Barrel file re-exports everything; bundler cannot eliminate unused code. Fix: Use named exports per component. Ensure sideEffects: false in package.json. Use tsup or rollup with proper ESM output.
Tokens Not Syncing from Figma
Cause: Token naming mismatch between Figma and code. Fix: Use Tokens Studio with a shared naming convention. Automate the sync via CI (push to branch, run Style Dictionary, create PR). Validate token names on both sides match.
Pre-Delivery Checklist
Tokens: Primitive/semantic/component layers defined, dark mode overrides, all colors meet WCAG contrast, consistent naming, no circular references.
Components: All states implemented (default/hover/focus/active/disabled/loading/error), TypeScript types exported, sensible defaults, focus-visible ring on all interactive elements.
Responsiveness: Mobile-first, tested at 375/768/1024/1440px, 44x44px touch targets, no horizontal scroll, fluid or responsive text scaling.
Accessibility: prefers-reduced-motion respected, prefers-color-scheme supported, all images have alt text, form inputs have labels, keyboard navigation works, ARIA attributes present, skip navigation link, tested with screen reader.
Governance: Changelog updated, version bumped, Storybook stories for new components, migration guide for breaking changes.