Back to Design Systems
Topic 7 of 8

Accessibility in Design Systems

Build inclusive components with WCAG compliance, ARIA patterns, and keyboard navigation

Why Accessibility is Non-Negotiable

Design systems are the perfect place to bake in accessibility. When your base components are accessible, every product built on them inherits that accessibility. Get it right once, benefit everywhere.

♿ WCAG 2.1 Quick Reference

Perceivable

Text alternatives, captions, color contrast

Operable

Keyboard access, no seizures, navigable

Understandable

Readable, predictable, input assistance

Robust

Compatible with assistive technologies

šŸ“– Full WCAG 2.1 Guidelines →

Color Contrast Requirements

// WCAG 2.1 Contrast Requirements:
// - AA: 4.5:1 for normal text, 3:1 for large text (18px+ or 14px bold)
// - AAA: 7:1 for normal text, 4.5:1 for large text

// Use colord library for checking contrast
import { colord, extend } from 'colord';
import a11yPlugin from 'colord/plugins/a11y';

extend([a11yPlugin]);

function checkContrast(foreground: string, background: string) {
  const ratio = colord(foreground).contrast(background);
  return {
    ratio: ratio.toFixed(2),
    passesAA: ratio >= 4.5,
    passesAALarge: ratio >= 3,
    passesAAA: ratio >= 7,
  };
}

// Test your design tokens
checkContrast('#ffffff', '#3b82f6'); // { ratio: '4.68', passesAA: true }
checkContrast('#6b7280', '#ffffff'); // { ratio: '4.69', passesAA: true }

// Tools:
// - WebAIM Contrast Checker: https://webaim.org/resources/contrastchecker/
// - Stark (Figma plugin): https://www.getstark.co/

Keyboard Navigation

// Every interactive element must be keyboard accessible
// Use semantic HTML first, add custom keyboard handlers when needed

import { useCallback, KeyboardEvent } from 'react';

// Custom hook for roving tabindex (menus, toolbars)
function useRovingTabindex<T extends HTMLElement>(items: T[]) {
  const [focusedIndex, setFocusedIndex] = useState(0);

  const handleKeyDown = useCallback((e: KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
      case 'ArrowRight':
        e.preventDefault();
        setFocusedIndex((i) => (i + 1) % items.length);
        break;
      case 'ArrowUp':
      case 'ArrowLeft':
        e.preventDefault();
        setFocusedIndex((i) => (i - 1 + items.length) % items.length);
        break;
      case 'Home':
        e.preventDefault();
        setFocusedIndex(0);
        break;
      case 'End':
        e.preventDefault();
        setFocusedIndex(items.length - 1);
        break;
    }
  }, [items.length]);

  return { focusedIndex, handleKeyDown };
}

// Accessible dropdown example
function Dropdown({ options, onSelect }) {
  const [isOpen, setIsOpen] = useState(false);
  const buttonRef = useRef<HTMLButtonElement>(null);

  return (
    <div>
      <button
        ref={buttonRef}
        aria-haspopup="listbox"
        aria-expanded={isOpen}
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={(e) => {
          if (e.key === 'Escape') {
            setIsOpen(false);
            buttonRef.current?.focus();
          }
        }}
      >
        Select option
      </button>
      {isOpen && (
        <ul role="listbox" aria-label="Options">
          {options.map((opt, i) => (
            <li
              key={opt.value}
              role="option"
              tabIndex={0}
              onClick={() => onSelect(opt)}
              onKeyDown={(e) => e.key === 'Enter' && onSelect(opt)}
            >
              {opt.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

ARIA Patterns

// Use ARIA only when HTML semantics aren't enough
// "No ARIA is better than bad ARIA"

// āŒ Bad: Using ARIA when HTML works
<div role="button" tabindex="0" onclick="...">Click me</div>

// āœ… Good: Use native HTML
<button onclick="...">Click me</button>

// Common ARIA patterns for design systems:

// 1. Modal Dialog
<div
  role="dialog"
  aria-modal="true"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-description"
>
  <h2 id="dialog-title">Delete Item</h2>
  <p id="dialog-description">Are you sure you want to delete this?</p>
  <button>Cancel</button>
  <button>Delete</button>
</div>

// 2. Tabs
<div role="tablist" aria-label="Settings tabs">
  <button role="tab" aria-selected="true" aria-controls="panel-1">General</button>
  <button role="tab" aria-selected="false" aria-controls="panel-2">Privacy</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
  General settings content
</div>

// 3. Alert / Toast
<div role="alert" aria-live="polite">
  Your changes have been saved.
</div>

// 4. Loading state
<button disabled aria-busy="true">
  <span className="spinner" aria-hidden="true" />
  Loading...
</button>

// Reference: WAI-ARIA Authoring Practices
// https://www.w3.org/WAI/ARIA/apg/patterns/

Focus Management

// useFocusTrap - keep focus inside modals/dialogs
import { useEffect, useRef } from 'react';

function useFocusTrap<T extends HTMLElement>() {
  const containerRef = useRef<T>(null);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const focusableElements = container.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const firstElement = focusableElements[0] as HTMLElement;
    const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;

    // Focus first element on mount
    firstElement?.focus();

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;

      if (e.shiftKey && document.activeElement === firstElement) {
        e.preventDefault();
        lastElement?.focus();
      } else if (!e.shiftKey && document.activeElement === lastElement) {
        e.preventDefault();
        firstElement?.focus();
      }
    };

    container.addEventListener('keydown', handleKeyDown);
    return () => container.removeEventListener('keydown', handleKeyDown);
  }, []);

  return containerRef;
}

// Usage in Dialog component
function Dialog({ isOpen, onClose, children }) {
  const dialogRef = useFocusTrap<HTMLDivElement>();
  const previouslyFocused = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      previouslyFocused.current = document.activeElement as HTMLElement;
    } else {
      previouslyFocused.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div ref={dialogRef} role="dialog" aria-modal="true">
      {children}
    </div>
  );
}

Screen Reader Testing

// Testing with screen readers is essential!

// macOS: VoiceOver (built-in)
// - Cmd + F5 to enable
// - Use VO keys: Ctrl + Option

// Windows: NVDA (free)
// - Download from https://www.nvaccess.org/
// - Insert key is the NVDA modifier

// Automated testing with axe-core
npm install @axe-core/react

// Setup in development
import React from 'react';
import ReactDOM from 'react-dom';

if (process.env.NODE_ENV !== 'production') {
  import('@axe-core/react').then((axe) => {
    axe.default(React, ReactDOM, 1000);
    // Reports accessibility issues to console
  });
}

// Storybook accessibility addon
npm install @storybook/addon-a11y

// .storybook/main.js
module.exports = {
  addons: ['@storybook/addon-a11y'],
};

// Run axe on every story automatically!

// jest-axe for unit tests
npm install jest-axe

import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);

test('Button is accessible', async () => {
  const { container } = render(<Button>Click me</Button>);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Accessible Design System Libraries

Radix UI

Unstyled, accessible primitives with full keyboard support

React Aria (Adobe)

Hooks for accessible UI primitives, extremely thorough

Headless UI

Unstyled, accessible components from Tailwind Labs

Downshift

Accessible autocomplete/dropdown primitives

āœ… Accessibility Checklist

  • ☐ All interactive elements are keyboard accessible
  • ☐ Focus states are visible and clear
  • ☐ Color contrast meets WCAG AA (4.5:1)
  • ☐ Images have alt text, decorative images use alt=""
  • ☐ Form inputs have associated labels
  • ☐ Error messages are announced to screen readers
  • ☐ Page has proper heading hierarchy (h1 → h2 → h3)
  • ☐ Focus is trapped in modals/dialogs
  • ☐ Skip links provided for navigation