Accessibility in Design Systems: A Practical Guide

A hands-on guide to building accessible components in a TypeScript design system, covering ARIA patterns, keyboard navigation, focus management, and automated testing.

technical10 min readBy Klivvr Engineering
Share:

Accessibility is not a feature you bolt on after shipping — it is a quality attribute that must be woven into every component from the first line of code. A design system is the highest-leverage place to invest in accessibility because every improvement propagates to every product that consumes it. Fix a focus trap in your Modal component once, and every modal in every application is fixed. Get keyboard navigation right in your Dropdown, and every dropdown everywhere just works. This article provides concrete, TypeScript-driven patterns for building components that are usable by everyone.

The Foundation: Semantic HTML and ARIA

The most powerful accessibility tool is the one built into the browser: semantic HTML. A <button> is already focusable, responds to Enter and Space keypresses, and announces itself as a button to screen readers. A <div onClick> does none of these things without significant manual work. The first rule of accessible component development is to use the correct HTML element.

When a native element does not exist for your use case — custom selects, tabs, accordions, dialogs — ARIA attributes fill the gap. But ARIA is a contract: applying role="tab" to an element means you are promising screen readers that it behaves like a tab, which means you must also implement the keyboard interaction model.

Here is an accessible Tab component that delivers on that contract:

import React, { useState, useRef, useCallback, KeyboardEvent } from 'react';
 
interface TabItem {
  id: string;
  label: string;
  content: React.ReactNode;
  disabled?: boolean;
}
 
interface TabsProps {
  items: TabItem[];
  defaultActiveId?: string;
  onChange?: (activeId: string) => void;
  'aria-label': string;
}
 
export function Tabs({ items, defaultActiveId, onChange, 'aria-label': ariaLabel }: TabsProps) {
  const [activeId, setActiveId] = useState(defaultActiveId || items[0]?.id);
  const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
 
  const enabledItems = items.filter((item) => !item.disabled);
 
  const handleSelect = useCallback(
    (id: string) => {
      setActiveId(id);
      onChange?.(id);
    },
    [onChange]
  );
 
  const handleKeyDown = useCallback(
    (event: KeyboardEvent<HTMLButtonElement>) => {
      const currentIndex = enabledItems.findIndex((item) => item.id === activeId);
      let nextIndex: number | null = null;
 
      switch (event.key) {
        case 'ArrowRight':
          nextIndex = (currentIndex + 1) % enabledItems.length;
          break;
        case 'ArrowLeft':
          nextIndex = (currentIndex - 1 + enabledItems.length) % enabledItems.length;
          break;
        case 'Home':
          nextIndex = 0;
          break;
        case 'End':
          nextIndex = enabledItems.length - 1;
          break;
        default:
          return;
      }
 
      event.preventDefault();
      const nextItem = enabledItems[nextIndex];
      handleSelect(nextItem.id);
      tabRefs.current.get(nextItem.id)?.focus();
    },
    [activeId, enabledItems, handleSelect]
  );
 
  return (
    <div>
      <div role="tablist" aria-label={ariaLabel}>
        {items.map((item) => (
          <button
            key={item.id}
            ref={(el) => {
              if (el) tabRefs.current.set(item.id, el);
            }}
            role="tab"
            id={`tab-${item.id}`}
            aria-selected={activeId === item.id}
            aria-controls={`panel-${item.id}`}
            aria-disabled={item.disabled || undefined}
            tabIndex={activeId === item.id ? 0 : -1}
            onClick={() => !item.disabled && handleSelect(item.id)}
            onKeyDown={handleKeyDown}
            disabled={item.disabled}
          >
            {item.label}
          </button>
        ))}
      </div>
      {items.map((item) => (
        <div
          key={item.id}
          role="tabpanel"
          id={`panel-${item.id}`}
          aria-labelledby={`tab-${item.id}`}
          hidden={activeId !== item.id}
          tabIndex={0}
        >
          {item.content}
        </div>
      ))}
    </div>
  );
}

Several key patterns are at work here. The roving tabindex pattern means only the active tab is in the tab order (tabIndex={0}); all others have tabIndex={-1}. This lets users Tab into the tab group and then use Arrow keys to navigate between tabs, matching the behavior users expect from the WAI-ARIA tabs pattern. The aria-selected, aria-controls, and aria-labelledby attributes create the semantic relationships that screen readers need to announce the component correctly.

Keyboard Navigation Patterns

Keyboard accessibility goes far beyond "you can press Tab." Different component types require different keyboard models. A design system should encode these models once and apply them consistently.

Here is a reusable hook for roving focus, the most common keyboard pattern in design systems:

import { useRef, useCallback, KeyboardEvent } from 'react';
 
interface UseRovingFocusOptions {
  orientation?: 'horizontal' | 'vertical' | 'both';
  loop?: boolean;
}
 
export function useRovingFocus<T extends HTMLElement>({
  orientation = 'horizontal',
  loop = true,
}: UseRovingFocusOptions = {}) {
  const elementsRef = useRef<T[]>([]);
  const currentIndexRef = useRef(0);
 
  const register = useCallback((index: number) => {
    return (el: T | null) => {
      if (el) {
        elementsRef.current[index] = el;
      }
    };
  }, []);
 
  const handleKeyDown = useCallback(
    (event: KeyboardEvent) => {
      const elements = elementsRef.current.filter(Boolean);
      const count = elements.length;
      if (count === 0) return;
 
      const prevKeys = orientation === 'vertical' ? ['ArrowUp'] : ['ArrowLeft'];
      const nextKeys = orientation === 'vertical' ? ['ArrowDown'] : ['ArrowRight'];
 
      if (orientation === 'both') {
        prevKeys.push('ArrowUp', 'ArrowLeft');
        nextKeys.push('ArrowDown', 'ArrowRight');
      }
 
      let nextIndex: number | null = null;
 
      if (nextKeys.includes(event.key)) {
        nextIndex = currentIndexRef.current + 1;
        if (nextIndex >= count) nextIndex = loop ? 0 : count - 1;
      } else if (prevKeys.includes(event.key)) {
        nextIndex = currentIndexRef.current - 1;
        if (nextIndex < 0) nextIndex = loop ? count - 1 : 0;
      } else if (event.key === 'Home') {
        nextIndex = 0;
      } else if (event.key === 'End') {
        nextIndex = count - 1;
      }
 
      if (nextIndex !== null) {
        event.preventDefault();
        currentIndexRef.current = nextIndex;
        elements[nextIndex]?.focus();
      }
    },
    [orientation, loop]
  );
 
  const getItemProps = useCallback(
    (index: number) => ({
      ref: register(index),
      tabIndex: index === currentIndexRef.current ? 0 : -1,
      onFocus: () => {
        currentIndexRef.current = index;
      },
    }),
    [register]
  );
 
  return { handleKeyDown, getItemProps };
}

This hook can be applied to tabs, toolbars, menu items, radio groups, and any other component that uses the roving tabindex pattern. By centralizing the logic, you guarantee consistent behavior across the system.

Focus Management in Modals and Overlays

When a modal opens, three things must happen for accessibility: focus moves into the modal, focus is trapped inside the modal, and when the modal closes, focus returns to the element that triggered it. Getting this wrong is one of the most common accessibility failures in web applications.

import { useEffect, useRef, useCallback } from 'react';
 
export function useFocusTrap(isActive: boolean) {
  const containerRef = useRef<HTMLDivElement>(null);
  const previouslyFocusedRef = useRef<HTMLElement | null>(null);
 
  const getFocusableElements = useCallback((): HTMLElement[] => {
    if (!containerRef.current) return [];
 
    const selector = [
      'a[href]',
      'button:not([disabled])',
      'input:not([disabled])',
      'select:not([disabled])',
      'textarea:not([disabled])',
      '[tabindex]:not([tabindex="-1"])',
    ].join(', ');
 
    return Array.from(containerRef.current.querySelectorAll<HTMLElement>(selector));
  }, []);
 
  useEffect(() => {
    if (!isActive) return;
 
    // Store the currently focused element
    previouslyFocusedRef.current = document.activeElement as HTMLElement;
 
    // Move focus into the trap
    const focusableElements = getFocusableElements();
    if (focusableElements.length > 0) {
      focusableElements[0].focus();
    }
 
    const handleKeyDown = (event: globalThis.KeyboardEvent) => {
      if (event.key !== 'Tab') return;
 
      const focusable = getFocusableElements();
      if (focusable.length === 0) return;
 
      const first = focusable[0];
      const last = focusable[focusable.length - 1];
 
      if (event.shiftKey) {
        if (document.activeElement === first) {
          event.preventDefault();
          last.focus();
        }
      } else {
        if (document.activeElement === last) {
          event.preventDefault();
          first.focus();
        }
      }
    };
 
    document.addEventListener('keydown', handleKeyDown);
 
    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      // Restore focus when the trap is deactivated
      previouslyFocusedRef.current?.focus();
    };
  }, [isActive, getFocusableElements]);
 
  return containerRef;
}
 
// Usage in a Modal component
function Modal({ isOpen, onClose, children }: ModalProps) {
  const focusTrapRef = useFocusTrap(isOpen);
 
  useEffect(() => {
    const handleEscape = (event: globalThis.KeyboardEvent) => {
      if (event.key === 'Escape') onClose();
    };
 
    if (isOpen) {
      document.addEventListener('keydown', handleEscape);
      document.body.style.overflow = 'hidden';
    }
 
    return () => {
      document.removeEventListener('keydown', handleEscape);
      document.body.style.overflow = '';
    };
  }, [isOpen, onClose]);
 
  if (!isOpen) return null;
 
  return (
    <div className="modal-overlay" onClick={onClose} role="presentation">
      <div
        ref={focusTrapRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        onClick={(e) => e.stopPropagation()}
      >
        {children}
      </div>
    </div>
  );
}

The useFocusTrap hook handles all three requirements: it focuses the first focusable element on activation, traps Tab cycling within the container, and restores focus to the triggering element on deactivation. The aria-modal="true" attribute tells assistive technologies that content behind the modal is inert.

Color Contrast and Design Token Validation

Accessibility is not just about keyboard navigation and ARIA — visual accessibility matters too. Your design tokens must meet WCAG contrast requirements. Rather than relying on manual checks, automate contrast validation as part of your token build pipeline.

// scripts/validate-contrast.ts
 
function hexToRgb(hex: string): [number, number, number] {
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
  if (!result) throw new Error(`Invalid hex color: ${hex}`);
  return [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)];
}
 
function relativeLuminance([r, g, b]: [number, number, number]): number {
  const [rs, gs, bs] = [r, g, b].map((c) => {
    const s = c / 255;
    return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
 
function contrastRatio(hex1: string, hex2: string): number {
  const l1 = relativeLuminance(hexToRgb(hex1));
  const l2 = relativeLuminance(hexToRgb(hex2));
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);
  return (lighter + 0.05) / (darker + 0.05);
}
 
// Define required contrast pairs
interface ContrastPair {
  foreground: string;
  background: string;
  label: string;
  level: 'AA' | 'AAA';
  size: 'normal' | 'large';
}
 
const requiredPairs: ContrastPair[] = [
  { foreground: '#111827', background: '#FFFFFF', label: 'text.primary on bg.primary', level: 'AA', size: 'normal' },
  { foreground: '#6B7280', background: '#FFFFFF', label: 'text.secondary on bg.primary', level: 'AA', size: 'normal' },
  { foreground: '#FFFFFF', background: '#2563EB', label: 'text.onPrimary on accent', level: 'AA', size: 'normal' },
  { foreground: '#FFFFFF', background: '#DC2626', label: 'text.onPrimary on error', level: 'AA', size: 'normal' },
];
 
const thresholds = {
  'AA-normal': 4.5,
  'AA-large': 3,
  'AAA-normal': 7,
  'AAA-large': 4.5,
};
 
let failures = 0;
 
for (const pair of requiredPairs) {
  const ratio = contrastRatio(pair.foreground, pair.background);
  const threshold = thresholds[`${pair.level}-${pair.size}`];
  const passed = ratio >= threshold;
 
  if (!passed) {
    console.error(
      `FAIL: ${pair.label} — ratio ${ratio.toFixed(2)}:1 (requires ${threshold}:1 for ${pair.level} ${pair.size} text)`
    );
    failures++;
  } else {
    console.log(
      `PASS: ${pair.label} — ratio ${ratio.toFixed(2)}:1`
    );
  }
}
 
if (failures > 0) {
  console.error(`\n${failures} contrast check(s) failed.`);
  process.exit(1);
}

Run this script in CI alongside your component tests. If a designer adjusts a color token that breaks contrast, the pipeline catches it before it reaches production.

Automated Accessibility Testing

Manual testing with screen readers is essential, but it does not scale. Automated tools catch a large class of issues — missing alt text, invalid ARIA attributes, missing labels, color contrast violations — before code is even reviewed.

Integrate jest-axe into your component test suite:

import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Button } from './Button';
import { Tabs } from './Tabs';
 
expect.extend(toHaveNoViolations);
 
describe('Button accessibility', () => {
  it('has no axe violations', async () => {
    const { container } = render(<Button>Click me</Button>);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
 
  it('has no axe violations when disabled', async () => {
    const { container } = render(<Button disabled>Disabled</Button>);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
 
  it('has no axe violations as a link', async () => {
    const { container } = render(<Button as="a" href="/about">About</Button>);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});
 
describe('Tabs accessibility', () => {
  const items = [
    { id: 'tab1', label: 'First', content: <p>First panel</p> },
    { id: 'tab2', label: 'Second', content: <p>Second panel</p> },
  ];
 
  it('has no axe violations', async () => {
    const { container } = render(<Tabs items={items} aria-label="Example tabs" />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

Add Storybook's @storybook/addon-a11y for visual accessibility audits during development. Every component story runs an axe check and displays results in the Storybook panel.

For a comprehensive testing strategy, combine automated checks with a manual testing checklist:

  • Navigate the entire component using only the keyboard.
  • Use VoiceOver (macOS), NVDA (Windows), or Orca (Linux) to verify screen reader announcements.
  • Zoom the page to 200% and confirm layouts do not break.
  • Test with Windows High Contrast mode enabled.
  • Verify that animations respect the prefers-reduced-motion media query.
// Respecting motion preferences in components
const fadeIn = `
  @media (prefers-reduced-motion: no-preference) {
    animation: fadeIn 200ms ease-in;
  }
 
  @media (prefers-reduced-motion: reduce) {
    animation: none;
  }
`;

Conclusion

A design system that is not accessible is a design system that excludes users. By using semantic HTML as the foundation, implementing correct ARIA patterns and keyboard interactions, managing focus in overlays, validating color contrast automatically, and running accessibility tests in CI, you build components that work for everyone. The investment is front-loaded — you solve each pattern once — and the returns compound across every application and every user your system reaches.

Related Articles

technical

Versioning and Publishing Component Libraries

A practical guide to versioning, releasing, and distributing TypeScript component libraries using semantic versioning, changesets, and automated CI/CD publishing pipelines.

10 min read
business

Measuring the ROI of a Design System

A practical framework for measuring the return on investment of a design system, covering efficiency metrics, quality improvements, consistency gains, and how to communicate value to stakeholders.

10 min read
business

Documentation That Developers Actually Read

How to create design system documentation that developers engage with, covering information architecture, interactive examples, API references, and documentation-as-code workflows.

12 min read