---
name: design-system
description: "Build and audit React/Tailwind design systems — design tokens, CVA component libraries, Storybook 9 docs/tests, WCAG 2.2 accessibility, theming, Figma-to-code token pipelines, npm publishing. Use when building or reviewing a component library, token pipeline, Storybook setup, theming, or Figma handoff on React 18/19 + Tailwind v4."
---

# Design System Implementation

## 1. Design Tokens

Design tokens are the atomic values of your design system — colors, spacing, typography, shadows. Define once, use everywhere.

> **Where tokens live depends on your Tailwind version.** On **Tailwind v4** put them directly in `@theme {}` (see the next subsection) — they become CSS vars *and* utilities in one declaration. On **v3** (or framework-agnostic CSS), keep them as raw custom properties in `:root` like below and map them through `tailwind.config.js`.

### CSS Custom Properties (raw `:root` — Tailwind v3 / non-Tailwind)

```css
/* 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 v4 Integration (CSS-first — recommended in 2026)

Tailwind v4 (stable since Jan 2025) replaced `tailwind.config.js` with a **CSS-first** model: a single `@import "tailwindcss";` plus a `@theme {}` block. Names inside `@theme` do double duty — they become both CSS custom properties **and** matching utility classes. A `--color-primary` token there generates `bg-primary`, `text-primary`, `border-primary`, etc., automatically — so you no longer maintain a parallel `colors: {}` map. Use the `oklch()` color space (v4's default) for perceptually-uniform palettes and wider gamut.

```css
/* app.css — the ONLY config most v4 projects need */
@import "tailwindcss";

/* Optional: opt into class-based dark mode instead of the default
   prefers-color-scheme. The variant below matches .dark on any ancestor. */
@custom-variant dark (&:where(.dark, .dark *));

@theme {
  /* Colors — oklch(L C H). These define both --color-* vars and utilities. */
  --color-primary: oklch(0.546 0.245 262.9);          /* ≈ #2563eb */
  --color-primary-hover: oklch(0.488 0.243 264.4);    /* ≈ #1d4ed8 */
  --color-primary-active: oklch(0.424 0.199 265.6);   /* ≈ #1e40af */
  --color-primary-foreground: oklch(1 0 0);

  --color-secondary: oklch(0.554 0.041 257.4);
  --color-secondary-hover: oklch(0.446 0.043 257.3);
  --color-secondary-foreground: oklch(1 0 0);

  --color-destructive: oklch(0.577 0.245 27.3);       /* ≈ #dc2626 */
  --color-destructive-hover: oklch(0.505 0.213 27.5);
  --color-destructive-foreground: oklch(1 0 0);

  --color-background: oklch(1 0 0);
  --color-foreground: oklch(0.208 0.042 265.8);
  --color-muted: oklch(0.968 0.007 247.9);
  --color-muted-foreground: oklch(0.554 0.046 257.4);
  --color-border: oklch(0.929 0.013 255.5);
  --color-ring: oklch(0.546 0.245 262.9);

  /* Typography — registering --font-* yields font-sans / font-mono utilities */
  --font-sans: "Inter", system-ui, -apple-system, sans-serif;
  --font-mono: "JetBrains Mono", "Fira Code", monospace;

  /* Radii — yields rounded-sm … rounded-xl */
  --radius-sm: 0.25rem;
  --radius-md: 0.375rem;
  --radius-lg: 0.5rem;
  --radius-xl: 0.75rem;

  /* Motion — yields ease-fluid / ease-snappy and duration-* utilities */
  --ease-fluid: cubic-bezier(0.3, 0, 0, 1);
  --ease-snappy: cubic-bezier(0.2, 0, 0, 1);
}
```

PostCSS / Vite wiring for v4 (no JS config needed):

```js
// postcss.config.mjs  (only if you are NOT using @tailwindcss/vite)
export default { plugins: { "@tailwindcss/postcss": {} } };
```

```ts
// vite.config.ts — preferred: the dedicated Vite plugin (faster, no PostCSS)
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [tailwindcss()] });
```

> **Token-pipeline note:** when tokens are generated by Style Dictionary (see §7), emit them as `@theme {}` CSS for v4 rather than a `theme.extend` JS object. v4 auto-detects content (no `content: []` array), so deleting `tailwind.config.js` is expected, not a mistake.

### Legacy Tailwind v3 appendix (`tailwind.config.js`)

Only for projects still on v3 (or v4 running in JS-config compat mode via `@config "./tailwind.config.js";`). Here tokens stay as raw CSS variables in `:root` (the block above in "CSS Custom Properties") and are mapped through `theme.extend`:

```javascript
// tailwind.config.js  (Tailwind v3 only)
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./src/**/*.{ts,tsx}'],
  darkMode: 'class',
  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)',
      },
    },
  },
};
```

---

## 2. Component Architecture (Atomic Design)

### Hierarchy

```
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
```

### Component File Structure

```
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
```

### The `cn()` Utility

```typescript
// 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));
}
```

---

## 3. Component Variants with CVA

```typescript
// components/button/button.variants.ts
import { cva, type VariantProps } from 'class-variance-authority';

export const buttonVariants = cva(
  // Base styles (always applied). Covers BOTH native disabled and the
  // aria-disabled state used when rendered asChild (anchor / custom element).
  '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 aria-disabled:pointer-events-none aria-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>;
```

```tsx
// 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, onClick, ...props }, ref) => {
    const Comp = asChild ? Slot : 'button';
    const isDisabled = disabled || loading;

    // CAUTION: `disabled` is NOT a valid attribute on <a> or arbitrary custom
    // elements. When asChild forwards props to a non-<button>, the native
    // `disabled` is silently ignored — the element stays focusable and clickable.
    // So: only set the native `disabled` on a real <button>; for everything else
    // express the disabled state with aria-disabled + tabIndex={-1} and block clicks.
    const disabledProps = asChild
      ? {
          'aria-disabled': isDisabled || undefined,
          'data-disabled': isDisabled ? '' : undefined,
          tabIndex: isDisabled ? -1 : props.tabIndex,
          onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
            if (isDisabled) {
              e.preventDefault();
              e.stopPropagation();
              return;
            }
            onClick?.(e);
          },
        }
      : { disabled: isDisabled, onClick };

    // Spread props FIRST, then disabledProps, so the guarded onClick / tabIndex
    // and aria-disabled win over anything passed in by the consumer.
    return (
      <Comp
        ref={ref}
        className={cn(buttonVariants({ variant, size }), className)}
        aria-busy={loading || undefined}
        {...props}
        {...disabledProps}
      >
        {loading && (
          <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <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';
```

> The `disabled:pointer-events-none disabled:opacity-50` base style only fires on a real `disabled` button. To dim a disabled `asChild` link, add the matching `aria-disabled` selector to the variant base: `aria-disabled:pointer-events-none aria-disabled:opacity-50`. Note `pointer-events-none` stops mouse clicks but **not** keyboard activation — that is why the `onClick` guard and `tabIndex={-1}` above are still required.

---

## 4. Storybook Setup (Storybook 9)

Storybook 9 (stable June 2025) is a major shift from the 7/8 era:

- **`addon-essentials`, `addon-docs`, `addon-controls`, `addon-interactions`, `addon-viewport`, `addon-backgrounds` are folded into the core.** Do not list them in `addons` anymore — `init` strips them. Autodocs and controls work out of the box.
- **Component testing moved to Vitest.** The old `@storybook/test-runner` (Jest + Playwright) is superseded by **`@storybook/addon-vitest`**, which runs every story as a real Vitest browser-mode test. `play` functions become assertions; failures fail `vitest`/CI.
- **`@storybook/test` is deprecated** in favor of importing `within`, `userEvent`, `expect`, `waitFor` from `storybook/test`.
- Bundle is ~50% lighter; Node ≥20 and a Vite-based framework are expected.

### Installation

```bash
# Fresh install (detects React + Vite, scaffolds addon-vitest + a11y)
npx storybook@latest init

# Upgrading an existing 7/8 project — runs codemods (removes merged addons, migrates test-runner)
npx storybook@latest upgrade
```

The init flow asks to set up component testing; accept it to get `@storybook/addon-vitest`, a `vitest.config.ts` project, and `.storybook/vitest.setup.ts` wired automatically.

### Configuration

```typescript
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'],
  // Only NON-core addons go here in SB9. essentials/docs/controls are built in.
  addons: ['@storybook/addon-a11y', '@storybook/addon-vitest'],
  framework: '@storybook/react-vite',
  // autodocs defaults on for stories tagged 'autodocs'; 'tag' is the SB9 value.
  docs: { autodocs: 'tag' },
};
export default config;
```

```typescript
// .storybook/preview.ts — turn the a11y addon into a hard gate
import type { Preview } from '@storybook/react-vite';

const preview: Preview = {
  parameters: {
    a11y: {
      // 'error' fails the build/test on axe violations; 'todo' only warns.
      test: 'error',
    },
  },
};
export default preview;
```

### Writing Stories

```tsx
// components/button/button.stories.tsx
// SB9: import the typed helpers from the framework package, not '@storybook/react'.
import type { Meta, StoryObj } from '@storybook/react-vite';
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>
  ),
};
```

---

## 5. Accessibility

### Component Accessibility Checklist (WCAG 2.2 AA)

WCAG 2.2 (W3C Recommendation, Oct 2023) is the target baseline in 2026 — it adds **2.4.11 Focus Not Obscured**, **2.4.13 Focus Appearance**, **2.5.8 Target Size (min 24×24 CSS px)**, and **3.3.8 Accessible Authentication**. Every component must meet:

| Requirement | WCAG SC | Implementation |
|-------------|---------|----------------|
| Keyboard navigation | 2.1.1 | Tab, Enter, Space, Escape, Arrow keys per the WAI-ARIA APG pattern |
| Focus visible | 2.4.7 | `focus-visible:ring-2 focus-visible:ring-ring` |
| Focus appearance | 2.4.13 | Indicator ≥ 2px thick, ≥ 3:1 contrast vs adjacent colors (`ring-offset-2` helps) |
| Focus not obscured | 2.4.11 | Sticky headers/toolbars must not cover the focused element |
| Target size | 2.5.8 | Interactive targets ≥ 24×24px (our `size="sm"` button is `h-8`=32px ✓; `size="icon"` is 40px ✓) |
| ARIA labels | 4.1.2 | `aria-label`, `aria-labelledby`, `aria-describedby` |
| Roles & state | 4.1.2 | Correct roles (`button`,`dialog`,`alert`) + state (`aria-expanded`, `aria-selected`, `aria-pressed`) |
| Screen reader text | 1.1.1 | `sr-only` class for visually-hidden labels; `aria-hidden` on decorative icons |
| Color contrast | 1.4.3 / 1.4.11 | 4.5:1 text, 3:1 large text **and** non-text UI (borders, icons, focus rings) |
| High-contrast / forced colors | 1.4.1 | Don't convey state by color alone; test Windows High Contrast / `forced-colors` |
| Motion | 2.3.3 | `prefers-reduced-motion` media query |

### Accessible Dialog Example

```tsx
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>
  );
}
```

### Focus Management

```typescript
// 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
}
```

### Reduced Motion

```css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}
```

### Forced Colors / Windows High Contrast

In forced-colors mode the OS overrides your palette with a system theme, so anything that relied on `background-color` or `box-shadow` to convey state can vanish. Prefer system color keywords and keep borders/outlines.

```css
@media (forced-colors: active) {
  /* Box-shadow focus rings are stripped — restore a real outline. */
  .btn:focus-visible { outline: 2px solid CanvasText; outline-offset: 2px; }
  /* Selected/active state must use a forced-colors-aware system color. */
  [aria-selected="true"] { forced-color-adjust: none; background: Highlight; color: HighlightText; }
  /* Disabled controls should map to GrayText. */
  [disabled], [aria-disabled="true"] { color: GrayText; }
}
```

### Per-Component ARIA Patterns (WAI-ARIA APG)

Implement these keyboard contracts. Radix UI / Ark UI / React Aria give them for free — but you still own testing them. Map to the [WAI-ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/patterns/).

| Component | Role(s) | Required keys | Critical state attrs |
|-----------|---------|---------------|----------------------|
| **Button** | `button` | Enter, Space activate | `aria-pressed` (toggle), `aria-disabled`, `aria-busy` |
| **Dialog (modal)** | `dialog` `aria-modal="true"` | Esc closes; **focus trapped**; focus returns to trigger | `aria-labelledby`, `aria-describedby` |
| **Menu** | `menu` / `menuitem` | ↑↓ move, Enter/Space select, Esc close, Home/End, type-ahead | `aria-haspopup`, `aria-expanded`, `aria-orientation` |
| **Combobox** | `combobox` + `listbox` | ↑↓ navigate options, Enter select, Esc close, type filters | `aria-expanded`, `aria-controls`, `aria-activedescendant`, `aria-autocomplete` |
| **Tabs** | `tablist`/`tab`/`tabpanel` | ←→ (or ↑↓) move tabs, Home/End; automatic vs manual activation | `aria-selected`, `aria-controls`, `tabindex="-1"` on inactive |
| **Tooltip** | `tooltip` | Shows on focus **and** hover; Esc dismisses; not focusable itself | `aria-describedby` on the trigger |
| **Toast** | `status`/`alert` in an `aria-live` region | Reachable via keyboard; not auto-dismissed if interactive | `role="alert"` (assertive) vs `role="status"` (polite) |

> **Tooltip gotcha (WCAG 1.4.13 Content on Hover/Focus):** the tooltip must be dismissable (Esc), hoverable (stays open while the pointer moves onto it), and persistent (no time-out while hovered/focused). A pure CSS `:hover` tooltip fails all three.

### Automated A11y Enforcement (CI gate)

Catch ~30-40% of issues automatically with **axe-core**; the rest needs manual keyboard + screen-reader testing. Wire three layers:

**1. Storybook `addon-a11y` as a Vitest gate.** With `a11y: { test: 'error' }` set in `preview.ts` (above) and `@storybook/addon-vitest` installed, every story is axe-scanned during `vitest`. Annotate the setup file:

```typescript
// .storybook/vitest.setup.ts
import { beforeAll } from 'vitest';
import { setProjectAnnotations } from '@storybook/react-vite';
import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview';
import * as previewAnnotations from './preview';

const annotations = setProjectAnnotations([previewAnnotations, a11yAddonAnnotations]);
beforeAll(annotations.beforeAll);
```

**2. Jest/Vitest unit assertion with `jest-axe`** for components tested outside Storybook:

```tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { expect, it } from 'vitest';
import { Button } from './button';

// Register the custom matcher (jest-axe ships it; do this once, e.g. in test setup).
expect.extend(toHaveNoViolations);

it('has no axe violations', async () => {
  const { container } = render(<Button>Save</Button>);
  expect(await axe(container)).toHaveNoViolations();
});
```

**3. Keyboard interaction tests per pattern** — assert the contract from the table above, not just rendering. Example for a Menu (Storybook `play` / Vitest browser mode):

```tsx
import { within, userEvent, expect } from 'storybook/test';

export const MenuKeyboard: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const trigger = canvas.getByRole('button', { name: 'Options' });
    await userEvent.click(trigger);
    await expect(trigger).toHaveAttribute('aria-expanded', 'true');

    // Arrow keys move roving focus across menuitems
    await userEvent.keyboard('{ArrowDown}');
    const items = canvas.getAllByRole('menuitem');
    await expect(items[0]).toHaveFocus();
    await userEvent.keyboard('{ArrowDown}');
    await expect(items[1]).toHaveFocus();

    // Escape closes and returns focus to the trigger
    await userEvent.keyboard('{Escape}');
    await expect(trigger).toHaveAttribute('aria-expanded', 'false');
    await expect(trigger).toHaveFocus();
  },
};
```

---

## 6. Theming (Dark Mode)

### CSS Variables Approach

Override the same token names under a `.dark` selector. On Tailwind v4 this lives in plain CSS *after* the `@theme` block (it re-points the existing `--color-*` vars); on v3 it's a separate `tokens/dark.css`.

```css
/* Dark theme — overrides the --color-* tokens defined in @theme / :root */
.dark, [data-theme="dark"] {
  --color-primary: oklch(0.707 0.165 254.6);          /* ≈ #60a5fa */
  --color-primary-hover: oklch(0.789 0.135 255.5);
  --color-primary-foreground: oklch(0.208 0.042 265.8);

  --color-background: oklch(0.208 0.042 265.8);        /* ≈ #0f172a */
  --color-foreground: oklch(0.984 0.003 247.9);
  --color-muted: oklch(0.279 0.041 260);
  --color-muted-foreground: oklch(0.704 0.04 256.8);
  --color-border: oklch(0.372 0.044 257.3);
}

/* Tell the UA to render native form controls / scrollbars dark too. */
.dark { color-scheme: dark; }
```

### No-FOUC theme script (run before paint)

Reading `localStorage` in `useEffect` flashes the wrong theme on first paint (the class is applied after hydration). Set the class **synchronously** before React mounts via a blocking inline script in `<head>`. In Next.js App Router this goes in the root `layout.tsx`; with a CMS/HTML it's a `<script>` in `<head>`.

```tsx
// app/layout.tsx — runs before first paint, no flash, no hydration mismatch
<script
  dangerouslySetInnerHTML={{
    __html: `(function(){try{var t=localStorage.getItem('theme');
      var d=t?t==='dark':matchMedia('(prefers-color-scheme:dark)').matches;
      document.documentElement.classList.toggle('dark',d);
      document.documentElement.style.colorScheme=d?'dark':'light';}catch(e){}})();`,
  }}
/>
```
> In real projects prefer `next-themes` (`<ThemeProvider attribute="class">`), which ships this exact script and a `useTheme()` hook. The component below shows the mechanics for a standalone library.

### Theme Toggle Component (SSR-safe, accessible)

Use real icon components (e.g. `lucide-react`), an accessible name, and `aria-pressed` for the toggle state. Guard against hydration mismatch with a `mounted` flag so SSR and the first client render agree, then reveal the resolved state.

```tsx
'use client';
import { useEffect, useState } from 'react';
import { Moon, Sun } from 'lucide-react';

export function ThemeToggle() {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    // The no-FOUC script already set the <html> class; read it back as truth.
    setTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light');
    setMounted(true);
  }, []);

  function toggle() {
    const next = theme === 'light' ? 'dark' : 'light';
    setTheme(next);
    localStorage.setItem('theme', next);
    document.documentElement.classList.toggle('dark', next === 'dark');
    document.documentElement.style.colorScheme = next;
  }

  const isDark = theme === 'dark';
  return (
    <button
      type="button"
      onClick={toggle}
      // Stable, non-flipping label so the accessible name doesn't jump pre-hydration.
      aria-label="Toggle dark mode"
      aria-pressed={mounted ? isDark : undefined}
      className="inline-flex h-10 w-10 items-center justify-center rounded-md hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
    >
      {/* suppressHydrationWarning: the icon legitimately differs from the SSR default */}
      <span suppressHydrationWarning>
        {isDark ? <Moon className="h-5 w-5" aria-hidden="true" /> : <Sun className="h-5 w-5" aria-hidden="true" />}
      </span>
      <span className="sr-only">{isDark ? 'Dark mode on' : 'Light mode on'}</span>
    </button>
  );
}
```

---

## 7. Figma-to-Code Workflow

### Handoff Checklist

| Step | Tool (2026) | Action |
|------|-------------|--------|
| Token source of truth | **Native Figma variables** (Dev Mode → Variables) or **Tokens Studio** plugin | Author colors/spacing/typography as variables with light/dark modes |
| Token export | Tokens Studio (→ GitHub/JSON sync) or Figma Variables REST API / Dev Mode "Export variables" | Emit **W3C DTCG** JSON (`$value`/`$type`) |
| Component specs | Figma **Dev Mode** | Inspect spacing, colors, typography; copy values as CSS vars |
| Code linkage | Figma **Code Connect** | Map a Figma component to its real `<Button />` so Dev Mode shows your code |
| Asset export | Dev Mode → SVG/PNG, or SVGR for React icons | Export icons/images |
| Responsive behavior | Figma auto-layout | Map to flex/grid CSS |
| Interaction specs | Figma prototyping | Document hover, active, focus states |

> The old standalone **"Figma Tokens"** plugin was renamed **Tokens Studio** years ago. For simple systems, **native Figma variables** (with the Variables REST API or Dev Mode export) are now enough and need no plugin; reach for Tokens Studio when you want themes, math/aliasing, and Git two-way sync.

### Token Transform Pipeline (DTCG → CSS/TS)

Tokens Studio and Figma variables export the **W3C Design Tokens (DTCG)** format. Style Dictionary v4+ consumes it natively (enable the preprocessor) and outputs platform files. For a **Tailwind v4** target, emit a `@theme {}` block instead of `:root` so tokens become utilities automatically (see §1).

```bash
npm i -D style-dictionary
npx style-dictionary build --config style-dictionary.config.mjs
```

```js
// style-dictionary.config.mjs  (Style Dictionary v4+, ESM)
import StyleDictionary from 'style-dictionary';

// Custom format: wrap CSS variables in @theme {} for Tailwind v4.
StyleDictionary.registerFormat({
  name: 'css/tailwind-theme',
  format: ({ dictionary }) =>
    `@import "tailwindcss";\n\n@theme {\n` +
    dictionary.allTokens.map((t) => `  --${t.name}: ${t.value};`).join('\n') +
    `\n}\n`,
});

export default {
  source: ['tokens/**/*.json'],
  preprocessors: ['tokens-studio'], // parses DTCG $value/$type + resolves aliases
  platforms: {
    tailwind: {
      transformGroup: 'css',
      buildPath: 'src/tokens/',
      files: [{ destination: 'theme.css', format: 'css/tailwind-theme' }],
    },
    // Legacy/v3 or non-Tailwind: plain :root variables
    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' }],
    },
  },
};
```

> Style Dictionary v4 ships with `@tokens-studio/sd-transforms` support; install it (`npm i -D @tokens-studio/sd-transforms`) and register its transforms/preprocessor if your tokens use Tokens Studio math, references, or typography composites.

---

## 8. Testing Components

### Visual Regression with Chromatic

```bash
npm i -D chromatic
npx chromatic --project-token="$CHROMATIC_PROJECT_TOKEN"
```

Add to CI so visual diffs **block the PR** until a reviewer accepts them. Do **not** ship `--exit-zero-on-changes` as the default — that lets regressions merge silently; it's only for a temporary unblock.

```yaml
# .github/workflows/ui.yml
- name: Visual regression (blocks PR on unreviewed diffs)
  uses: chromaui/action@latest
  with:
    projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
    # Auto-accept baselines only on the trunk; PRs must be reviewed.
    autoAcceptChanges: main
    exitZeroOnChanges: false   # non-zero exit on undecided changes → red check
    onlyChanged: true          # TurboSnap: snapshot only stories affected by the diff
```

### Component / Interaction Tests with Vitest (Storybook 9)

In SB9, stories run as Vitest browser tests via `@storybook/addon-vitest`. Run them in CI alongside Chromatic; `play` failures and `a11y: 'error'` violations turn the check red. Import test utilities from **`storybook/test`** (the `@storybook/test` package is deprecated).

```yaml
- name: Component + a11y tests (Storybook stories via Vitest)
  run: npx vitest run --project=storybook
```

```tsx
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');
  },
};
```

### Unit Testing with Vitest + Testing Library

```tsx
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');
  });
});
```

---

## 9. Publishing Components

### Package.json for Publishing

```json
{
  "name": "@myorg/ui",
  "version": "1.2.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./styles.css": "./dist/styles.css"
  },
  "files": ["dist"],
  "sideEffects": ["**/*.css"],
  "peerDependencies": {
    "react": ">=18 <20",
    "react-dom": ">=18 <20"
  },
  "peerDependenciesMeta": {
    "react-dom": { "optional": false }
  },
  "scripts": {
    "build": "tsup",
    "prepublishOnly": "npm run build"
  }
}
```

> **React 19 support (stable since Dec 2024):** widen the peer range to `>=18 <20` so the package installs cleanly in React 19 apps. React 19 removed `propTypes`/`defaultProps` on function components, ships its own JSX runtime, and made `ref` a regular prop (so `forwardRef` is optional but still works — keep it for v18 compatibility). Verify your Radix/CVA deps have React 19 in *their* peer ranges before widening yours.

> **React Server Components:** a component library is consumed inside RSC trees. Any file using hooks, event handlers, or browser APIs (e.g. `ThemeToggle`, `Dialog`) must start with the `"use client"` directive, and your bundler must **preserve** that banner in the output — otherwise Next.js throws "You're importing a component that needs `useState`…". Purely presentational components (Button without `loading` state, Badge) can stay server-compatible.

### Build with tsup

```typescript
// 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,
  // Externalize React AND the JSX runtimes so consumers dedupe a single copy.
  external: ['react', 'react-dom', 'react/jsx-runtime'],
  treeshake: true,
  // Keep "use client" / "use server" directives in the emitted chunks (RSC-safe).
  banner: { js: '"use client";' }, // OR set esbuildOptions to preserve per-file directives
});
```

> If only *some* components are client components, don't blanket-banner the whole bundle. Instead split entries (e.g. `src/client.ts` + `src/server.ts`) or use a per-file directive-preserving plugin (`esbuild-plugin-preserve-directives`) so server-safe components stay in the RSC graph.

---

## 10. Popular Systems to Reference

| System | Approach | Best for |
|--------|----------|----------|
| **shadcn/ui** | Copy-paste components (Radix + Tailwind) via a CLI registry; you own the code | Full control, custom design systems on Tailwind |
| **Radix Primitives** | Unstyled, accessible behavior primitives | Hand-styled custom systems |
| **React Aria Components** (Adobe) | Unstyled, behavior + a11y; the deepest keyboard/i18n/screen-reader coverage | A11y-critical or i18n-heavy products |
| **Base UI** | Unstyled primitives from the Radix/MUI/Floating-UI teams | Newer alternative to Radix; framework-styled systems |
| **Headless UI** | Unstyled components from the Tailwind team | Small Tailwind-first projects |
| **Ark UI** | Headless, framework-agnostic (Zag.js state machines) | React + Vue + Solid from one source |
| **Mantine** | Full-featured styled components + hooks | Feature-rich apps, fast delivery |
| **Chakra UI** | Styled components + theme system | Rapid development, themeable apps |

### shadcn/ui Pattern (Recommended Starting Point)

```bash
# Initializes components.json, the cn() util, and a Tailwind-aware setup.
npx shadcn@latest init
npx shadcn@latest add button card dialog input
```

shadcn/ui generates components directly into your project — you own the code, there is no runtime dependency to upgrade. It works with Tailwind v4 (init scaffolds the CSS-first setup) and React 19. The CLI is also a **registry**: you can publish your own `registry.json` and `add` components from a private URL, which is how teams distribute an internal design system without an npm publish step. The path to a custom system:
1. Start with shadcn/ui components.
2. Customize tokens (`@theme`) and CVA variants to match your brand.
3. Add custom components following the same `cn()` + CVA + Radix patterns.
4. Extract into a shared package (§9) — or your own shadcn registry — when reused across apps.
