Design Systems

Developer Handoff

A good handoff answers an engineer's questions before they have to ask them. It isn't a file I send at the end of a project. It's a process that starts the first time engineering sees a sketch and ends when the shipped build matches the spec. Here's how I run it, the checklist I use, and the actual handoff spec I wrote for my Design System Playground.

How I hand off to developers

  1. 01

    Bring engineering in early

    A handoff should never be a reveal. I share early concepts with engineers, agree on the stack and the component library, and flag anything that might be expensive to build while it's still cheap to change.

  2. 02

    Name things once

    Tokens and components carry the same names in the design file and in code. If design calls it color.text.muted and code calls it gray-500, every conversation starts with a translation.

  3. 03

    Specify behavior, not just pixels

    Every component gets its states (default, hover, focus, active, disabled, loading, error, empty), its responsive behavior, its motion, and its edge cases: long text, missing data, slow networks.

  4. 04

    Make accessibility part of the spec

    Focus order, keyboard paths, roles and labels, contrast ratios and touch targets are written into each component spec. Accessibility is the baseline, not a polish pass.

  5. 05

    Package one source of truth

    One link holds everything: the organized design file with ready-for-dev sections, the token export, exported assets, the prototype and the written spec. Nobody should have to dig through old threads to find a decision.

  6. 06

    Walk through it together

    I present the handoff live, show the flows end to end, and capture questions and decisions in the spec instead of in chat, where they get lost.

  7. 07

    Stay through the build

    I review builds against the spec, answer questions quickly, and log every change in a versioned changelog so nobody builds against an outdated decision.

The handoff checklist

Before I call anything ready for development, every box here is checked.

Foundations

  • Tokens for color, type, spacing, radius, shadow and motion, exported in a format code can use
  • Grid, breakpoints and layout rules

Components

  • Every state designed, not just the default
  • Props and variants named the way code will name them
  • Rules for how components combine

Flows and content

  • Happy path and error paths, end to end
  • Final copy, including errors, empty states and confirmations
  • Loading and slow-network behavior

Accessibility

  • Focus order and keyboard paths
  • Labels and roles for assistive tech
  • Contrast checked against WCAG AA
  • Touch targets of at least 44px

Assets

  • Icons as SVG
  • Images exported at 1x and 2x
  • Fonts and their licenses

Logistics

  • A named owner and how to reach them
  • Open questions listed, not buried
  • A version number and a changelog

Mistakes I avoid

  • Specs that only describe the happy path.
  • Design files that rename things engineering has already named.
  • Redlining every pixel instead of pointing to tokens and rules.
  • Handing off and disappearing.
  • Changing the design after handoff without updating the spec.

Example: the spec I handed off

This is the developer handoff for the design system behind my playground: the token architecture, component API conventions, specs for 28 components, and the accessibility, testing and release rules. Jump to a section:

This is the implementation spec for engineers building against the Wexley design system. It pairs with the design system documentation but goes deeper on tokens, component APIs, accessibility requirements, and conventions.

If you only read one section, read Token Architecture. Everything else assumes you understand it.


Stack assumptions

I built the playground in React with Tailwind. The system is portable, but the reference implementation uses:

  • Framework: React 18+
  • Styling: Tailwind CSS with CSS custom properties for runtime theming
  • Component primitives: Radix UI for accessibility-critical components (Dialog, Popover, Combobox, Slider, Switch, Tabs)
  • Icons: Lucide React
  • Type system: TypeScript

If you are porting to another stack (Vue, Svelte, web components), the token layer below is framework-agnostic. The component contracts in this document are not, but they translate cleanly.


Token architecture

The system uses a two-layer token model.

Layer 1: Primitive tokens. Raw values. Color hex codes, pixel measurements, durations. These are not consumed directly by components.

Layer 2: Semantic tokens. Named by role, not appearance. Components reference these. When the theme changes, semantic tokens point to different primitive tokens, and components re-render automatically.

All tokens are exposed as CSS custom properties on :root. Tailwind classes reference them via the config.

Primitive tokens

:root {
  /* Color primitives */
  --slate-50:  #f8fafc;
  --slate-100: #f1f5f9;
  --slate-200: #e2e8f0;
  --slate-300: #cbd5e1;
  --slate-400: #94a3b8;
  --slate-500: #64748b;
  --slate-600: #475569;
  --slate-700: #334155;
  --slate-800: #1e293b;
  --slate-900: #0f172a;
  --slate-950: #020617;

  --red-500: #ef4444;
  --red-600: #dc2626;
  --green-500: #10b981;
  --amber-500: #f59e0b;
  --blue-500: #3b82f6;
  --blue-600: #2563eb;

  /* Spacing primitives (4-point scale) */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;
  --space-7: 48px;
  --space-8: 64px;
  --space-9: 96px;

  /* Radius primitives */
  --radius-none: 0;
  --radius-sm: 4px;
  --radius-md: 6px;
  --radius-lg: 8px;
  --radius-xl: 16px;
  --radius-full: 9999px;

  /* Motion primitives */
  --duration-fast: 100ms;
  --duration-base: 200ms;
  --duration-slow: 300ms;
  --ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
  --ease-decelerate: cubic-bezier(0, 0, 0.2, 1);
  --ease-accelerate: cubic-bezier(0.4, 0, 1, 1);
}

Semantic tokens

:root {
  /* Color: surface */
  --color-surface-default: var(--slate-50);
  --color-surface-raised:  #ffffff;
  --color-surface-sunken:  var(--slate-100);
  --color-surface-inverse: var(--slate-900);

  /* Color: text */
  --color-text-primary:   var(--slate-900);
  --color-text-secondary: var(--slate-600);
  --color-text-tertiary:  var(--slate-400);
  --color-text-inverse:   var(--slate-50);
  --color-text-link:      var(--blue-600);

  /* Color: border */
  --color-border-default: var(--slate-200);
  --color-border-strong:  var(--slate-300);
  --color-border-subtle:  var(--slate-100);
  --color-border-focus:   var(--blue-500);

  /* Color: action */
  --color-action-primary:        var(--slate-900);
  --color-action-primary-hover:  var(--slate-800);
  --color-action-secondary:      var(--slate-100);
  --color-action-destructive:    var(--red-500);
  --color-action-destructive-hover: var(--red-600);

  /* Color: status */
  --color-status-success: var(--green-500);
  --color-status-warning: var(--amber-500);
  --color-status-danger:  var(--red-500);
  --color-status-info:    var(--blue-500);

  /* Typography */
  --font-family-sans: system-ui, -apple-system, sans-serif;
  --font-family-mono: ui-monospace, "SF Mono", monospace;
  --font-size-base:   16px;
  --line-height-tight:   1.2;
  --line-height-normal:  1.5;
  --line-height-relaxed: 1.7;

  /* Layout */
  --border-radius-base: 6px;
  --spacing-multiplier: 1;
  --shadow-intensity:   1;
  --motion-speed:       0.2s;
}

Theme override pattern

Themes are CSS class overrides applied to a parent element. The Theme Editor in the playground writes overrides to a data-theme attribute on <body>:

[data-theme="custom"] {
  --color-action-primary: /* user-chosen primary */;
  --color-surface-sunken: /* user-chosen secondary base */;
  --border-radius-base:   /* user-chosen radius */;
  /* etc. */
}

Engineers should never write hex codes inside components. Always reference semantic tokens.


Component API conventions

Every component in the system follows the same five conventions.

1. Prop naming

  • Variant for visual style (variant="default" | "secondary" | "outline" | "ghost" | "link" | "destructive")
  • Size for sizing (size="sm" | "md" | "lg")
  • State props for interactive state (disabled, loading, selected, expanded)
  • Slot props for composition (leadingIcon, trailingIcon, helperText, errorMessage)
  • Event handlers prefixed with on (onClick, onChange, onValueChange)

I do not use boolean variant props (isPrimary, isSecondary). One variant prop with a union type.

2. Composition over configuration

When a component has more than four meaningful slots, it becomes composable instead of configurable. Compare:

// Wrong: a card with 12 props
<Card
  title="..."
  subtitle="..."
  imageUrl="..."
  badge="..."
  badgeVariant="..."
  primaryAction="..."
  secondaryAction="..."
  // ...
/>

// Right: composable
<Card>
  <Card.Header>
    <Card.Title>Project Alpha</Card.Title>
    <Badge>Active</Badge>
  </Card.Header>
  <Card.Body>...</Card.Body>
  <Card.Footer>
    <Button>View Details</Button>
  </Card.Footer>
</Card>

The cards in the playground (Project Alpha, Create New Asset) use this pattern.

3. Controlled and uncontrolled

Every input component supports both modes. Uncontrolled is the default with defaultValue. Controlled requires both value and onChange.

4. Polymorphism via as

Buttons, links, and surface components accept an as prop to render a different element:

<Button as="a" href="/dashboard">Go to Dashboard</Button>

The visual treatment stays the same. The semantics change. This is how I handle the button-versus-link distinction: a “Link” variant button renders an <a> by default, but any variant can be made a link via as.

5. Forwarding refs and props

Every component forwards refs. Every component spreads unrecognized props onto the root element. This is non-negotiable for accessibility tooling and third-party integration.


Component specs

Each spec lists props, states, accessibility requirements, and any non-obvious behavior.

Button

interface ButtonProps {
  variant?: 'default' | 'secondary' | 'outline' | 'ghost' | 'link' | 'destructive';
  size?: 'sm' | 'md' | 'lg';
  leadingIcon?: ReactNode;
  trailingIcon?: ReactNode;
  loading?: boolean;
  disabled?: boolean;
  fullWidth?: boolean;
  as?: ElementType;
  // plus all native button props
}

Sizes: sm = 32px height, md = 40px, lg = 48px. Horizontal padding scales: sm = 12px, md = 16px, lg = 20px.

Loading state: Replace the leading icon slot with a spinner. Keep the button width fixed. Set aria-busy="true". Do not change the label text.

Disabled: Use aria-disabled (not the disabled attribute) so screen readers can still discover the button. Capture and prevent click events when aria-disabled is true.

Accessibility:

  • Min touch target 44x44px regardless of visual size (use invisible padding or ::before on icon-only buttons)
  • Focus ring: 2px solid --color-border-focus, 2px offset
  • Icon-only buttons require aria-label

Icon Button

interface IconButtonProps extends Omit<ButtonProps, 'leadingIcon' | 'trailingIcon'> {
  icon: ReactNode;
  'aria-label': string; // required
}

Always required aria-label. The type system enforces it.

Floating Action Button

interface FABProps {
  icon: ReactNode;
  'aria-label': string;
  position?: 'bottom-right' | 'bottom-left';
  variant?: 'primary' | 'secondary';
}

56px circular. Always fixed-position. Uses the blue accent token, not primary. Respects safe-area insets on iOS.

Button Group / Segmented Control

interface ButtonGroupProps {
  value: string;
  onValueChange: (value: string) => void;
  options: Array<{ value: string; label: string; icon?: ReactNode }>;
  size?: 'sm' | 'md' | 'lg';
}

Accessibility: Wrap in a role="radiogroup". Each option is role="radio". Arrow keys move selection. Space or Enter activates.

Input (text, password, search, number)

interface InputProps {
  type?: 'text' | 'password' | 'search' | 'email' | 'number' | 'tel' | 'url';
  label?: string;
  helperText?: string;
  errorMessage?: string;
  leadingIcon?: ReactNode;
  trailingIcon?: ReactNode;
  trailingAction?: ReactNode; // for password reveal, clear button, etc.
  size?: 'sm' | 'md' | 'lg';
  // plus all native input props
}

States: default, focus, filled, error, disabled, read-only.

Error state: Three signals required. Border color shifts to danger. Icon appears in trailing slot. Error message renders below in --color-status-danger. Never color alone.

Accessibility:

  • Label is always associated via htmlFor. Visually hidden labels still get rendered for screen readers.
  • Helper text and error messages use aria-describedby
  • Error state sets aria-invalid="true"
  • Password reveal toggle is a separate button inside the input, not a checkbox

Textarea

Same props as Input but renders a <textarea>. Adds rows and autoResize. When autoResize is true, height grows with content up to a maxRows cap, then scrolls.

Select / Dropdown

Built on Radix Select. Use the wrapper for token application:

interface SelectProps {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  options: Array<{ value: string; label: string; disabled?: boolean }>;
  placeholder?: string;
  size?: 'sm' | 'md' | 'lg';
  label?: string;
  errorMessage?: string;
}

For lists over 7 items, use Combobox instead.

Combobox / Autocomplete

Built on Radix Combobox primitives or Downshift. Supports filtering, async loading, multi-select.

Async data: Debounce the filter callback at 200ms. Show a loading indicator inside the popover, not on the trigger.

Keyboard: Arrow keys navigate, Enter selects, Escape closes, type-ahead jumps to matching option.

Checkbox

interface CheckboxProps {
  checked?: boolean | 'indeterminate';
  defaultChecked?: boolean;
  onCheckedChange?: (checked: boolean) => void;
  label?: string;
  disabled?: boolean;
}

Supports indeterminate state for tree selection and bulk-select patterns.

Radio Group

interface RadioGroupProps {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  options: Array<{ value: string; label: string; description?: string }>;
  orientation?: 'horizontal' | 'vertical';
}

Switch / Toggle

interface SwitchProps {
  checked?: boolean;
  defaultChecked?: boolean;
  onCheckedChange?: (checked: boolean) => void;
  label?: string;
  disabled?: boolean;
}

Switches are for immediate-effect state changes. They never appear inside a form with a Submit button. If the change is reversible and instant (Notifications on/off, Airplane Mode), use Switch. Otherwise use Checkbox.

Slider / Range Slider

interface SliderProps {
  value?: number | [number, number];
  defaultValue?: number | [number, number];
  onValueChange?: (value: number | [number, number]) => void;
  min?: number;
  max?: number;
  step?: number;
  marks?: Array<{ value: number; label?: string }>;
}

Passing a tuple turns it into a range slider with two thumbs.

Date Picker

Built on a calendar primitive (react-day-picker recommended). Supports single date, date range, and date+time. Accepts min, max, and disabledDates for constraint.

OTP / Pin Input

interface OTPInputProps {
  length: number;
  value?: string;
  onChange?: (value: string) => void;
  separator?: ReactNode; // renders between segments
  type?: 'numeric' | 'alphanumeric';
  autoFocus?: boolean;
}

The playground example uses length={6} with a separator at index 3.

File Uploader

interface FileUploaderProps {
  accept?: string;
  multiple?: boolean;
  maxSize?: number; // in bytes
  onFilesAdded: (files: File[]) => void;
  onError?: (error: { code: string; message: string }) => void;
}

Supports click and drag-and-drop. Drag state is communicated visually (border color shift) and via aria-dropeffect.

Color Picker

Composed of a swatch button and a popover with the picker UI. The hex input below the swatch is bidirectional.

Tags / Chips Input

interface TagsInputProps {
  value: string[];
  onValueChange: (value: string[]) => void;
  placeholder?: string;
  maxTags?: number;
  validate?: (tag: string) => boolean | string;
}

Enter adds a tag. Backspace on empty input deletes the last tag. Each tag has a remove button with aria-label="Remove [tag name]".

Card

interface CardProps {
  variant?: 'default' | 'interactive' | 'bordered' | 'elevated';
  as?: ElementType;
}

// Subcomponents
Card.Header, Card.Title, Card.Description, Card.Body, Card.Footer, Card.Image

Interactive variant: Whole card is the hit target. Renders as <button> or <a> (via as). Nested interactive elements are forbidden by the contract; the linter should catch this.

Built on Radix Dialog.

interface ModalProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  size?: 'sm' | 'md' | 'lg' | 'fullscreen';
  // subcomponents: Modal.Header, Modal.Title, Modal.Description, Modal.Body, Modal.Footer
}

Behavior:

  • Focus traps to within the modal
  • Returns focus to the trigger on close
  • Closes on Escape unless disableEscapeClose
  • Closes on outside click unless disableOutsideClickClose
  • Body scroll is locked while open

The first focusable element receives focus on open, not the close button. The close button is the last element in the DOM order so it is not the first tab stop.

Drawer / Sheet

Same API as Modal, with an additional side prop ('left' | 'right' | 'top' | 'bottom'). Same focus behavior.

Popover

Built on Radix Popover.

interface PopoverProps {
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  side?: 'top' | 'right' | 'bottom' | 'left';
  align?: 'start' | 'center' | 'end';
  // subcomponents: Popover.Trigger, Popover.Content
}

Use for non-interrupting content that is too rich for a tooltip. If the content has any focusable element, use Popover, not Tooltip.

Toast / Snackbar

Built on Radix Toast or sonner.

interface ToastOptions {
  title: string;
  description?: string;
  variant?: 'default' | 'success' | 'warning' | 'destructive';
  duration?: number; // ms, default per variant
  action?: { label: string; onClick: () => void };
}

// Imperative API
toast.success({ title: 'Saved' });
toast.error({ title: 'Failed to save', description: '...' });

Defaults:

  • success: 5000ms
  • warning: 8000ms
  • destructive: persists until dismissed

Toasts render in a region with role="status" for success/info, role="alert" for warnings/errors.

Alert / Banner

interface AlertProps {
  variant?: 'info' | 'success' | 'warning' | 'destructive';
  title?: string;
  description?: string;
  icon?: ReactNode; // defaults per variant
  onDismiss?: () => void;
}

The “Heads up!” and “Error” examples in the playground use this component.

Progress Bar

interface ProgressProps {
  value?: number; // 0-100, undefined = indeterminate
  max?: number;
  size?: 'sm' | 'md';
  variant?: 'default' | 'success' | 'warning' | 'destructive';
  label?: string;
}

Always uses role="progressbar" with aria-valuenow, aria-valuemin, aria-valuemax.

Skeleton

interface SkeletonProps {
  variant?: 'rect' | 'circle' | 'text';
  width?: string | number;
  height?: string | number;
  count?: number;
}

Animated shimmer. Respects prefers-reduced-motion and falls back to a static gray block.

Spinner

interface SpinnerProps {
  size?: 'sm' | 'md' | 'lg';
  variant?: 'default' | 'inverse';
  label?: string; // for screen readers
}

Default label="Loading" if not provided. Renders inside role="status" with aria-live="polite".

Stat Tile

interface StatTileProps {
  label: string;
  value: string | number;
  delta?: { value: string; direction: 'up' | 'down' | 'neutral'; period: string };
  icon?: ReactNode;
}

The Total Revenue / Subscriptions / Active Now tiles in the playground use this.

Chart wrappers

I do not write chart primitives from scratch. The system wraps Recharts (or similar) with:

  • Pre-configured token-aware colors
  • A unified <Chart.Tooltip /> component matching the popover style
  • A unified empty state and loading state

Each chart type (Bar, Line, Area, Donut) has its own wrapper component with a normalized data prop:

interface ChartProps {
  data: Array<Record<string, number | string>>;
  xKey: string;
  yKeys: string[];
  colors?: string[]; // semantic tokens, not hex
  height?: number;
  loading?: boolean;
  emptyState?: ReactNode;
}

Accessibility checklist

This is a hard requirement, not a polish pass. Every PR must pass:

  • All interactive elements are keyboard-reachable
  • Tab order is logical and follows reading order
  • Focus rings are visible on all focusable elements
  • Touch targets are 44x44px minimum
  • Color contrast hits 4.5:1 for body text, 3:1 for UI and large text
  • No information communicated by color alone
  • All form fields have associated labels
  • All images have alt text or role="presentation"
  • All icon-only buttons have aria-label
  • All custom widgets follow the matching WAI-ARIA pattern
  • Components work with screen readers (test with VoiceOver or NVDA)
  • Components respect prefers-reduced-motion
  • Components work without JavaScript where reasonably possible (forms, links)

The CI pipeline runs axe-core against every component story. Violations block merge.


Browser and device support

  • Browsers: Last 2 versions of Chrome, Firefox, Safari, Edge. Safari on iOS 15+. Chrome on Android 11+.
  • Viewports: 320px minimum width. 1920px reference desktop width. Tested at 360, 768, 1024, 1280, 1440, 1920.
  • Pointer: Mouse, touch, and keyboard equally supported. Pen and hybrid devices follow the touch model.

File structure

src/
├── tokens/
│   ├── primitives.css      # raw values
│   ├── semantic.css        # role-based aliases
│   └── themes/             # theme overrides
├── components/
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.test.tsx
│   │   ├── Button.stories.tsx
│   │   └── index.ts
│   ├── Input/
│   └── ...
├── hooks/
├── utils/
└── index.ts                # public API surface

Every component lives in its own folder with co-located tests and stories. The root index.ts controls the public API. Anything not exported from index.ts is internal and subject to change without notice.


Testing expectations

Each component requires:

  1. Unit tests for prop variants, controlled/uncontrolled behavior, and event handling
  2. Accessibility tests via jest-axe
  3. Visual regression tests via Chromatic or Percy against the Storybook stories
  4. Keyboard interaction tests for any focus-managing component (Modal, Dropdown, Tabs)

Stories cover every variant, every size, and the three required states: default, hover (via Storybook controls), focus.


Versioning and releases

Semver.

  • Major: breaking changes to component APIs, removed exports, renamed tokens
  • Minor: new components, new tokens, new variants, new props with defaults
  • Patch: bug fixes, internal refactors, visual refinements that do not change behavior

Deprecations get a one-version warning before removal. The console warns on use of any deprecated API, with a link to the migration guide.

Changelogs use Changesets. Every PR that touches the public API requires a changeset.


How to contribute

If you are adding a component, follow this order:

  1. Open an issue describing the use case. Reference at least two real product surfaces that would use it.
  2. Get a token review. New tokens are reviewed before any component work begins.
  3. Build the component with full state coverage, accessibility specs, and tests.
  4. Write the Storybook stories first. The story file is the visual spec.
  5. Open the PR with a changeset.
  6. Pair with me for the design review.

If you are modifying an existing component, the bar is lower but the same accessibility and test requirements apply.


Open questions for engineering

These are things I want input on, not directives:

  1. Animation library. Tailwind transitions handle most cases. Framer Motion or React Spring for the more complex sequences (modal entrance, drawer slide, list reordering)?
  2. Form library. React Hook Form is my default. Confirm or push back.
  3. Theme runtime. The Theme Editor writes CSS custom properties at runtime. Confirm this is acceptable in your server-rendering setup, or propose an alternative (CSS-in-JS, build-time generation).
  4. Icon delivery. Lucide is the default. If bundle size becomes a concern, we can switch to an icon-as-component approach with tree-shaking.

Contact

For design questions: me, Alfie. Slack or email works. For escalations: open an issue with the design-system label.

Document version 0.1.

Open the Design System Playground