skills-ws
Version:
85 agent skills for AI coding assistants — marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.
994 lines (819 loc) • 39.4 kB
Markdown
---
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 ` {}` (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 ` "tailwindcss";` plus a ` {}` block. Names inside `` 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 */
"tailwindcss";
/* Optional: opt into class-based dark mode instead of the default
prefers-color-scheme. The variant below matches .dark on any ancestor. */
-variant dark (&:where(.dark, .dark *));
{
/* 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 ` {}` 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 ` "./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 `/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.
- **`/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 init
# Upgrading an existing 7/8 project — runs codemods (removes merged addons, migrates test-runner)
npx storybook upgrade
```
The init flow asks to set up component testing; accept it to get `/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: ['/addon-a11y', '@storybook/addon-vitest'],
framework: '/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
(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
(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 `/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 '/react-vite';
import * as a11yAddonAnnotations from '/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 '-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 `` 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 ` {}` 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 }) =>
` "tailwindcss";\n\n {\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 `-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
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 `/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 `/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 '-library/react';
import userEvent from '-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 init
npx shadcn 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 (``) 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.