Skip to content

Building Accessible React Components from Scratch

How to build truly accessible React components — covering ARIA attributes, keyboard navigation, focus management, and screen reader testing patterns.

4 min read
React component tree annotated with ARIA roles and keyboard interaction points

Accessibility is not an afterthought or a compliance checkbox. It is a core engineering requirement. Inaccessible components exclude users who rely on screen readers, keyboard navigation, or alternative input devices. Building accessible React components from the start is easier than retrofitting them later.

Most accessibility bugs fall into a few categories: missing labels, broken keyboard navigation, and incorrect ARIA usage. Fix these patterns and you cover the majority of issues.

Semantic HTML First

The simplest accessibility fix is using the right HTML element. Native elements come with built-in keyboard handling, focus management, and screen reader announcements.

tsxtsx
// ❌ Div pretending to be a button — no keyboard support, no role
function BadButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
  return (
    <div className="btn" onClick={onClick}>
      {children}
    </div>
  );
}
// Screen reader sees: generic container
// Keyboard user: cannot focus or activate with Enter/Space
tsxtsx
// ✅ Actual button element — keyboard, focus, and role built-in
function GoodButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
  return (
    <button type="button" className="btn" onClick={onClick}>
      {children}
    </button>
  );
}
// Screen reader sees: "Submit, button"
// Keyboard user: Tab to focus, Enter or Space to activate

Semantic elements to prefer: <button> over <div onClick>, <a href> over <span onClick>, <nav> over <div class="nav">, <main> over <div id="content">, <dialog> over <div class="modal">.

Labeling Interactive Elements

Every interactive element needs an accessible name. Without it, screen readers announce the element's role but not its purpose.

tsxtsx
// ❌ Icon button with no accessible name
function IconButton({ icon, onClick }: { icon: string; onClick: () => void }) {
  return (
    <button onClick={onClick}>
      <svg aria-hidden="true">{/* icon SVG */}</svg>
    </button>
  );
}
// Screen reader announces: "button" — which button?
tsxtsx
// ✅ Icon button with aria-label
function IconButton({
  icon,
  label,
  onClick,
}: {
  icon: string;
  label: string;
  onClick: () => void;
}) {
  return (
    <button onClick={onClick} aria-label={label}>
      <svg aria-hidden="true">{/* icon SVG */}</svg>
    </button>
  );
}
 
// Usage:
<IconButton icon="trash" label="Delete item" onClick={handleDelete} />
// Screen reader announces: "Delete item, button"

For form inputs, always use <label> elements with htmlFor pointing to the input's id. The aria-label attribute is for cases where a visible label does not exist, like icon-only buttons.

Keyboard Navigation in Custom Components

Custom dropdowns, menus, and tabs need keyboard support that mirrors their native counterparts. The WAI-ARIA Authoring Practices define the expected keyboard interactions.

tsxtsx
import { useState, useRef, useCallback } from 'react';
 
interface DropdownProps {
  label: string;
  options: { value: string; label: string }[];
  value: string;
  onChange: (value: string) => void;
}
 
function Dropdown({ label, options, value, onChange }: DropdownProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const listRef = useRef<HTMLUListElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const selectedOption = options.find(o => o.value === value);
 
  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      switch (e.key) {
        case 'ArrowDown':
          e.preventDefault();
          if (!isOpen) {
            setIsOpen(true);
            setActiveIndex(0);
          } else {
            setActiveIndex(i => Math.min(i + 1, options.length - 1));
          }
          break;
        case 'ArrowUp':
          e.preventDefault();
          setActiveIndex(i => Math.max(i - 1, 0));
          break;
        case 'Enter':
        case ' ':
          e.preventDefault();
          if (isOpen && activeIndex >= 0) {
            onChange(options[activeIndex].value);
            setIsOpen(false);
            buttonRef.current?.focus();
          } else {
            setIsOpen(true);
            setActiveIndex(0);
          }
          break;
        case 'Escape':
          setIsOpen(false);
          buttonRef.current?.focus();
          break;
      }
    },
    [isOpen, activeIndex, options, onChange]
  );
 
  return (
    <div onKeyDown={handleKeyDown}>
      <button
        ref={buttonRef}
        aria-haspopup="listbox"
        aria-expanded={isOpen}
        aria-label={label}
        onClick={() => setIsOpen(o => !o)}
      >
        {selectedOption?.label ?? 'Select...'}
      </button>
 
      {isOpen && (
        <ul
          ref={listRef}
          role="listbox"
          aria-label={label}
          aria-activedescendant={
            activeIndex >= 0 ? `option-${activeIndex}` : undefined
          }
        >
          {options.map((option, index) => (
            <li
              key={option.value}
              id={`option-${index}`}
              role="option"
              aria-selected={option.value === value}
              data-active={index === activeIndex}
              onClick={() => {
                onChange(option.value);
                setIsOpen(false);
                buttonRef.current?.focus();
              }}
            >
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Key keyboard patterns: Arrow keys navigate options. Enter/Space selects. Escape closes and returns focus to the trigger. aria-activedescendant tells screen readers which option is currently highlighted.

Focus Management in Modals

Modals must trap focus — Tab should cycle through focusable elements inside the modal, not escape to the page behind it.

tsxtsx
import { useEffect, useRef } from 'react';
 
function Modal({
  isOpen,
  onClose,
  title,
  children,
}: {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);
 
  useEffect(() => {
    if (isOpen) {
      // Save the element that had focus before modal opened
      previousFocusRef.current = document.activeElement as HTMLElement;
 
      // Focus the modal container
      modalRef.current?.focus();
 
      return () => {
        // Restore focus when modal closes
        previousFocusRef.current?.focus();
      };
    }
  }, [isOpen]);
 
  useEffect(() => {
    if (!isOpen) return;
 
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === 'Escape') {
        onClose();
        return;
      }
 
      if (e.key !== 'Tab') return;
 
      const modal = modalRef.current;
      if (!modal) return;
 
      const focusable = modal.querySelectorAll<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
 
      const first = focusable[0];
      const last = focusable[focusable.length - 1];
 
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
 
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, onClose]);
 
  if (!isOpen) return null;
 
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-label={title}
        tabIndex={-1}
        onClick={e => e.stopPropagation()}
      >
        <h2>{title}</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

Three focus requirements: capture focus when the modal opens, trap focus inside the modal, and restore focus to the trigger when the modal closes.

Live Regions for Dynamic Content

When content updates without a page navigation — like a toast notification, form validation error, or loading state — screen readers need to be told about the change.

tsxtsx
// ❌ Dynamic error message — screen reader never announces it
function Form() {
  const [error, setError] = useState('');
 
  return (
    <form>
      <input type="email" />
      {error && <span className="error">{error}</span>}
    </form>
  );
}
tsxtsx
// ✅ Live region announces the error to screen readers
function Form() {
  const [error, setError] = useState('');
 
  return (
    <form>
      <input type="email" aria-describedby="email-error" />
      <span
        id="email-error"
        role="alert"
        aria-live="assertive"
        className="error"
      >
        {error}
      </span>
    </form>
  );
}
// When error text changes, screen reader interrupts to announce it

aria-live="assertive" interrupts the current announcement — use it for errors. aria-live="polite" waits until the screen reader finishes its current speech — use it for status updates like "3 results found."

Key Takeaways

  1. Use semantic HTML before reaching for ARIA — native elements handle keyboard, focus, and roles automatically
  2. Label every interactive element — icon buttons need aria-label, form inputs need <label> elements
  3. Implement keyboard navigation for custom components following WAI-ARIA patterns — Arrow keys, Enter, Escape
  4. Trap focus in modals — save previous focus, cycle Tab within the modal, restore focus on close
  5. Use live regions for dynamic content — role="alert" for errors, aria-live="polite" for status updates
  6. Test with a screen reader — VoiceOver on Mac, NVDA on Windows, or ChromeVox in Chrome
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX