Chapter 22

Accessibility (a11y) Engineering in Practice

Accessibility (a11y) is chronically underinvested in frontend engineering. Many developers' mental model stops at "add alt text to images" and treat a11y as an optional courtesy to users with disabilities. This framing misses the full picture. Accessibility directly affects SEO rankings, legal compliance, and the experience of every userโ€”including someone using a phone in bright sunlight, someone who temporarily cannot use a mouse due to a hand injury, and someone who relies on captions in a noisy environment.

Why a11y Is an Engineering Problem, Not a Charity Exercise

Legal exposure. The US ADA (Americans with Disabilities Act), the EU EAA (European Accessibility Act, fully effective in 2025), and growing national legislation require digital products to conform to WCAG standards. Non-compliance creates lawsuit risk. The US alone saw over 4,000 digital accessibility lawsuits in 2023.

SEO. Google's crawler behaves in many ways like a screen reader. It depends on semantic HTML, a logical heading hierarchy, and alt text to understand page content. Pages that score well on accessibility often score well in search rankings too.

Engineering quality. Writing semantic HTML, thoughtful ARIA annotations, and deliberate focus management forces you to produce components with clearer structure and more explicit logic. Accessibility is an outward indicator of internal code quality.

Semantic HTML: The First Principle

Using div elements for interactive controls is a pervasive antipattern in React codebases:

// Wrong: a div has no semantics, cannot receive keyboard focus,
// and is invisible to assistive technology
<div onClick={handleDelete} className="delete-btn">
  Delete
</div>

// Correct: a button natively handles keyboard operability,
// ARIA role, and focus management
<button onClick={handleDelete} type="button">
  Delete
</button>

Native HTML elements carry a large amount of accessibility behavior for free:

Element Built-in capabilities
<button> Keyboard focus, Enter/Space activation, role="button"
<a href> Keyboard focus, Enter activation, role="link", context menu
<input> Keyboard focus, association with <label>, role determined by type
<nav> role="navigation" landmark
<main> role="main" landmark, identifies the primary content
<header> role="banner" (when at the top level)
<footer> role="contentinfo" (when at the top level)

Correct Heading Hierarchy

// Wrong: heading levels skip for visual styling reasons
<h1>Dashboard</h1>
<h3>Sales Overview</h3>  {/* h2 was skipped */}
<h5>Today's Metrics</h5> {/* h4 was skipped */}

// Correct: heading levels reflect content hierarchy
<h1>Dashboard</h1>
<h2>Sales Overview</h2>
<h3>Today's Metrics</h3>

If you need large text that is semantically an h3, adjust the font size with CSS rather than misusing a heading level:

<h3 className="text-2xl font-bold">Visually looks like h1, semantically h3</h3>

ARIA Attributes: When and How

ARIA (Accessible Rich Internet Applications) supplements semantic HTML for UI patterns that native elements cannot express. The first rule of ARIA: if you can accomplish it with native HTML, do not use ARIA.

aria-label and aria-labelledby

Use aria-label when visible text does not adequately describe an element's purpose:

// An icon button has no visible textโ€”aria-label is mandatory
<button aria-label="Close dialog" onClick={onClose}>
  <XIcon />
</button>

// A search input with placeholder text but no associated label
<input
  type="search"
  aria-label="Search products"
  placeholder="Enter keywords..."
/>

aria-labelledby points to an existing text element on the page, reusing existing content as the accessible name:

<h2 id="section-title">User Settings</h2>
<section aria-labelledby="section-title">
  {/* The accessible name for this region comes from the element with id="section-title" */}
</section>

aria-describedby: Supplementary Information

<div>
  <label htmlFor="password">Password</label>
  <input
    id="password"
    type="password"
    aria-describedby="password-hint password-error"
  />
  <p id="password-hint">At least 8 characters, including uppercase, lowercase, and a number</p>
  {error && <p id="password-error" role="alert">{error}</p>}
</div>

aria-live: Announcing Dynamic Content

When content on the page changes dynamically (loading states, error messages, counters), screen readers do not automatically detect DOM mutations. aria-live signals to assistive technology that a region contains live content that should be announced:

function StatusMessage({ message }: { message: string }) {
  return (
    <div
      role="status"          // implies aria-live="polite"
      aria-live="polite"     // wait until current speech finishes before announcing
      aria-atomic="true"     // announce the full content as one unit, not incrementally
    >
      {message}
    </div>
  );
}

// Error messages should interrupt immediately: use role="alert" (implies aria-live="assertive")
function ErrorAlert({ message }: { message: string }) {
  return (
    <div role="alert">
      {message}
    </div>
  );
}

One critical implementation detail: aria-live regions must exist in the DOM at page load time. A live region that is dynamically inserted into the DOM will not necessarily be recognized by assistive technology. The correct pattern is to always render the container element and change only its contents to trigger announcements.

Keyboard Navigation and Focus Management

Keyboard operability is the cornerstone of accessibility. Many users navigate exclusively with a keyboard (or keyboard-equivalent devices such as switch controls) and cannot use a pointer device.

Focus Trapping: Implementing a Correct Modal

When a modal dialog opens, focus must be contained within it. Focus should not be able to escape to content behind the overlay:

import { useEffect, useRef } from 'react';

function Modal({ isOpen, onClose, children }: ModalProps) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // Save the element that was focused before the modal opened
      previousFocusRef.current = document.activeElement as HTMLElement;

      // Move focus into the modal
      modalRef.current?.focus();
    } else {
      // Restore focus when the modal closes
      previousFocusRef.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      tabIndex={-1}          // makes the div focusable without entering tab order
      onKeyDown={(e) => {
        if (e.key === 'Escape') onClose();
      }}
      className="modal"
    >
      <h2 id="modal-title">Confirm Deletion</h2>
      {children}
      <button onClick={onClose}>Cancel</button>
      <button onClick={handleConfirm}>Confirm Delete</button>
    </div>
  );
}

For production use, the focus-trap-react library handles all the edge cases of focus trapping: cycling Tab through focusable elements, setting initial focus, restoring focus on close, and handling portals.

For keyboard users, every page load requires pressing Tab repeatedly through navigation before reaching the main content. A skip link lets the user jump directly:

// Place this at the very beginning of <body>
function SkipLink() {
  return (
    <a href="#main-content" className="skip-link">
      Skip to main content
    </a>
  );
}

// CSS: hidden by default, visible when focused
// .skip-link {
//   position: absolute;
//   top: -100%;
//   left: 0;
// }
// .skip-link:focus {
//   top: 0;
// }

// The main content area
<main id="main-content" tabIndex={-1}>
  <PageContent />
</main>

Focus Management in React Router

During SPA route transitions, screen readers do not automatically detect that the page has changed. Focus may remain on an element that no longer exists:

import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

function RouteChangeAnnouncer() {
  const location = useLocation();
  const headingRef = useRef<HTMLHeadingElement>(null);

  useEffect(() => {
    // Move focus to the page heading on each route change
    headingRef.current?.focus();
  }, [location.pathname]);

  return (
    <div aria-live="polite" className="sr-only">
      Navigated to: {document.title}
    </div>
  );
}

Screen Reader Testing

Automated tools catch roughly 30-40% of accessibility issues. The rest can only be discovered by testing with real assistive technology.

macOS/iOS: VoiceOver

Windows: NVDA (free)

Manual testing checklist:

  1. All interactive elements are reachable by Tab
  2. Focus is always visible (do not use outline: none without an alternative)
  3. Focus moves into a modal when it opens, returns to the trigger when it closes
  4. All form inputs have associated labels
  5. Error messages are announced via role="alert" or aria-live
  6. All icon-only buttons have an aria-label
  7. Focus is in a sensible location after route transitions

Automated Checking Tools

@axe-core/react (Development Environment)

npm install -D @axe-core/react
// src/main.tsx (development only)
if (import.meta.env.DEV) {
  import('@axe-core/react').then(({ default: axe }) => {
    axe(React, ReactDOM, 1000); // runs checks every second, logs violations to the console
  });
}

axe-core outputs accessibility violations to the browser console including the specific DOM node, the rule being violated, its severity, and a suggested fix.

eslint-plugin-jsx-a11y (At Authoring Time)

npm install -D eslint-plugin-jsx-a11y
// eslint.config.js
import jsxA11y from 'eslint-plugin-jsx-a11y';

export default [
  jsxA11y.flatConfigs.recommended,
  {
    rules: {
      'jsx-a11y/click-events-have-key-events': 'error',
      'jsx-a11y/no-noninteractive-element-interactions': 'error',
      'jsx-a11y/alt-text': 'error',
      'jsx-a11y/label-has-associated-control': 'error',
    },
  },
];

This plugin catches common mistakes at write time: images missing alt, labels without associated controls, onClick handlers without corresponding onKeyDown, and more.

React-Specific Accessibility Pitfalls

Pitfall 1: key changes cause focus loss. When a list item's key changes due to a data update, React unmounts and remounts the element, losing focus. Always use stable IDs from your data (typically the record's id field) as keys rather than array indices.

Pitfall 2: conditional rendering and focus. When isEditing toggles:

// Focus jumps back to body when isEditing changes
{isEditing ? <input autoFocus /> : <p>{value}</p>}

// The autoFocus attribute moves focus into the input when it appears.
// Verify this matches user expectation in context.

Pitfall 3: portals and focus trapping. Modals rendered via ReactDOM.createPortal are in the DOM outside their React tree parent. Focus trapping still works (it is a visual/pointer concern), but Escape key event bubbling follows the DOM structure, not the React component hierarchy. Test portal behavior explicitly with a screen reader.

Pitfall 4: dynamic page titles. document.title should update on every route change. Screen reader users rely on it to understand which page they are on, since they cannot infer this from visual layout:

function usePageTitle(title: string) {
  useEffect(() => {
    const prevTitle = document.title;
    document.title = `${title} | MyApp`;
    return () => {
      document.title = prevTitle;
    };
  }, [title]);
}

Accessibility should not be a final-step audit before launch. It belongs in the engineering workflow from day one: semantic HTML from the start, eslint-plugin-jsx-a11y in the linting setup, @axe-core/react in development, and periodic screen reader testing built into the release process. The marginal cost is low. The compounded benefitโ€”in legal safety, SEO, and the quality of experience for every userโ€”is substantial.

Rate this chapter
4.5  / 5  (7 ratings)

๐Ÿ’ฌ Comments