#React#TypeScript

Practical Accessibility for React Components

Semantic HTML is not enough: custom React components such as modals, dropdowns, tabs, and toasts require explicit a11y handling for focus management, keyboard navigation, ARIA, and screen-reader announcements.

Key Points

  • Topic Focus: React、TypeScript
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Many frontend developers have a misconception about accessibility (a11y): “I use semantic HTML; <button>, <nav>, and <main> are all correct, so there should be no problem, right?”

Wrong. The moment you start building custom interactive components—modals, dropdown menus, tabs, and toasts—native HTML no longer provides the corresponding semantics. A “dialog” assembled from <div onClick> elements is just a collection of unnamed boxes to a screen reader; keyboard users do not even know where focus should go after it opens. The WAI-ARIA Authoring Practices (APG) is a library of design patterns for cases that “native HTML cannot express,” but many teams have never read it.

This article explains the a11y patterns for four common kinds of custom components, and why I strongly recommend that you do not hand-roll them.

1. Why Semantic HTML Is Still Not Enough

Native HTML elements provide three built-in layers of a11y:

  • Semantic roles: <button> inherently has role="button", and screen readers announce it as a button.
  • Keyboard behavior: They can receive focus with Tab, activate with Enter/Space, and automatically skip disabled elements.
  • Focus management: Browsers automatically draw focus rings and maintain tab order.

However, many React components have no native equivalent: Dialog, Menu, Tabs, Combobox, Toast, Tooltip... The closest native element you can find (apart from the dialog element) is often just a <div>. Once a <div> carries interaction, all three layers above are lost, and you must restore them manually:

Missing capabilityHow to restore it
Semantic roleARIA roles such as role="dialog"
FocusabilitytabIndex={0} and keyboard activation
Keyboard behaviorHandle Enter/Esc/Arrow in onKeyDown
Focus managementMove focus in on open, restore it on close, and trap it inside
Screen-reader announcementsaria-live, aria-modal, aria-expanded

The next four sections go straight to the code.

2. Modal Dialogs: Focus Trapping and Focus Restoration

A modal dialog has four hard a11y requirements:

  1. role="dialog" + aria-modal="true" tell screen readers that this is a modal.
  2. When it opens, focus enters the modal, usually on the first focusable element or the title.
  3. Focus trap: Tab cycles within the modal and cannot escape it, otherwise users can Tab into the background page and get lost.
  4. After it closes, focus returns to the trigger (focus restoration), and the Escape key closes it.
tsx
import { useEffect, useRef } from "react";

interface ModalProps {
  open: boolean;
  onClose: () => void;
  titleId: string;
  children: React.ReactNode;
}

function Modal({ open, onClose, titleId, children }: ModalProps) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const previousFocus = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (!open) return;

    // 记住打开前的焦点,关闭后回到这里
    previousFocus.current = document.activeElement as HTMLElement;

    const dialog = dialogRef.current;
    if (!dialog) return;

    const getFocusable = () =>
      Array.from(
        dialog.querySelectorAll<HTMLElement>(
          'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
        )
      ).filter((el) => el.offsetParent !== null); // 排除隐藏元素

    // 焦点进入模态
    const focusable = getFocusable();
    focusable[0]?.focus();

    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        onClose();
        return;
      }
      if (e.key !== "Tab") return;

      // 焦点陷阱:Tab 在模态内循环
      const items = getFocusable();
      if (items.length === 0) {
        e.preventDefault();
        dialog.focus();
        return;
      }
      const first = items[0];
      const last = items[items.length - 1];
      const active = document.activeElement;

      if (e.shiftKey && active === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && active === last) {
        e.preventDefault();
        first.focus();
      }
    };

    document.addEventListener("keydown", onKeyDown);
    return () => {
      document.removeEventListener("keydown", onKeyDown);
      // 焦点回退到触发器
      previousFocus.current?.focus();
    };
  }, [open, onClose]);

  if (!open) return null;

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

Notice previousFocus.current?.focus() in the useEffect cleanup function: it is the key to preventing focus from being lost after the modal closes. Omit that line, and a keyboard user's focus will jump to the top of the page after closing the dialog, effectively sending them back to the starting point.

3. Dropdowns/Menus: aria-expanded and Arrow Keys

A dropdown trigger and its menu must be connected through ARIA:

tsx
function Dropdown() {
  const [open, setOpen] = useState(false);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const menuId = useId();

  return (
    <>
      <button
        ref={triggerRef}
        aria-haspopup="true"
        aria-expanded={open}
        aria-controls={menuId}
        onClick={() => setOpen((v) => !v)}
        onKeyDown={(e) => {
          if (e.key === "ArrowDown") {
            e.preventDefault();
            setOpen(true);
          }
        }}
      >
        菜单
      </button>

      {open && (
        <ul role="menu" id={menuId}>
          <li
            role="menuitem"
            tabIndex={-1}
            onKeyDown={(e) => {
              if (e.key === "Escape") {
                setOpen(false);
                triggerRef.current?.focus(); // 关闭后回触发器
              }
            }}
          >
            编辑
          </li>
          {/* Arrow Up/Down 在 menuitem 间移动、Home/End 跳首尾 */}
        </ul>
      )}
    </>
  );
}

Key points:

  • aria-expanded is the only signal a screen reader has to distinguish a button from an expanded button. Without it, blind users have no idea whether the menu is open.
  • aria-haspopup="true" (or "menu") tells assistive technology that this is a menu-style popup.
  • Keyboard model: Enter/Space opens the menu, Arrow keys move through items, Escape closes it and returns focus to the trigger, and Tab usually does not participate in menu navigation (as APG recommends).
  • To close on scrolling or outside clicks, listen for click and scroll events, and remember to clean them up in the effect.

Honestly, the complete APG pattern for Dropdowns (including roving tabindex and type-ahead character navigation) is extremely tedious. I never hand-roll this component—see section seven.

4. Tabs: The Complete ARIA Pattern

Tabs are among the most ARIA-intensive components. The three-part combination of role="tablist" / tab / tabpanel must be paired correctly, and the roving patterns for aria-selected, aria-controls, aria-labelledby, and tabIndex are all essential.

tsx
import { useId, useRef, useState } from "react";

interface TabItem {
  id: string;
  label: string;
  content: React.ReactNode;
}

function Tabs({ tabs }: { tabs: TabItem[] }) {
  const [active, setActive] = useState(0);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const onKeyDown = (e: React.KeyboardEvent, index: number) => {
    const count = tabs.length;
    const focusTab = (next: number) => {
      setActive(next);
      requestAnimationFrame(() => tabRefs.current[next]?.focus());
    };
    switch (e.key) {
      case "ArrowRight":
        e.preventDefault();
        focusTab((index + 1) % count);
        break;
      case "ArrowLeft":
        e.preventDefault();
        focusTab((index - 1 + count) % count);
        break;
      case "Home":
        e.preventDefault();
        focusTab(0);
        break;
      case "End":
        e.preventDefault();
        focusTab(count - 1);
        break;
    }
  };

  return (
    <div>
      <div role="tablist" aria-label="设置">
        {tabs.map((tab, i) => (
          <button
            key={tab.id}
            ref={(el) => {
              tabRefs.current[i] = el;
            }}
            role="tab"
            id={`tab-${tab.id}`}
            aria-selected={active === i}
            aria-controls={`panel-${tab.id}`}
            tabIndex={active === i ? 0 : -1} // roving tabindex
            onClick={() => setActive(i)}
            onKeyDown={(e) => onKeyDown(e, i)}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab, i) => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`panel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          tabIndex={0}
          hidden={active !== i}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

A few easy ways to get this wrong:

  • Use a roving tabIndex: only the current tab is 0; every other tab is -1. After the Tab key reaches the tablist, it stops only once, and users switch tabs with the Arrow keys. If every tab is 0, users must press Tab N times to get through the tablist, which is a usability disaster.
  • Use aria-selected, not aria-current: they are not interchangeable; Tabs must use aria-selected.
  • tabpanel needs hidden: inactive panels must be hidden with the hidden attribute, not CSS alone. That prevents screen readers from reading hidden content.
  • Arrow-key switching must move focus too: calling only setActive is not enough. After pressing an Arrow key, users expect focus to move with it, so call focus after rendering completes with requestAnimationFrame.

5. Toasts/Notifications: aria-live Makes Screen Readers Announce Them Automatically

The difficult part of a Toast is not styling. It is how to notify screen readers about asynchronously added DOM. If you simply insert a <div> into the page, blind users will hear nothing because their virtual buffer does not know there is new content.

The solution is an aria-live region:

tsx
// 断言型错误:立刻打断,适合严重错误
<div role="alert" aria-live="assertive">
  保存失败,请重试
</div>

// 礼貌型通知:等屏幕阅读器空闲再念,适合 Toast
<div role="status" aria-live="polite">
  已保存到草稿
</div>

Practical recommendations:

  • Prefer role="status" (equivalent to aria-live="polite" + aria-atomic="true"), and use role="alert" only for genuine error alerts. assertive forcibly interrupts the user's current activity, so overusing it is annoying.
  • The container must be present in the DOM from the beginning, even if empty. Do not mount it only when a notification needs to be sent—some screen readers do not reliably detect live regions inserted later.
  • Keep dynamic text stable: rapidly changing content can make NVDA announce repeatedly, so add a little debounce.

6. Three Non-Negotiable Rules of Focus Management

Pulling the scattered points above together, custom component focus management comes down to three rules:

  1. Focus trap: For “takeover” containers such as modals and drawers, Tab must cycle within the component and cannot escape. Otherwise, focusable elements on the background page can leak in.
  2. Focus restore: Any component that takes focus after opening—modals, menus, Command Palettes—must return focus to the element that triggered it when it closes. Otherwise, keyboard users are teleported straight to the top of the page.
  3. Visible focus rings: outline: none is an a11y disaster area. If you truly dislike the default outline, you must write replacement :focus-visible styles. Never remove it without a replacement. Tailwind users should remember not to remove outlines in a global reset.

7. Do Not Hand-Roll It: Use Radix UI or react-aria

After all that, my real recommendation is: do not hand-write these components for production.

  • Radix UI: unstyled (with optional Tailwind themes), friendly to uncontrolled usage, and highly React-like API design. Focus management and ARIA are all built in. Use @radix-ui/react-dialog, react-dropdown-menu, and react-tabs directly.
  • react-aria (Adobe): lower-level and hook-based. It provides complete a11y behavior while leaving styling entirely to you, making it suitable for design-system teams that need deep customization.

Hand-writing a fully APG-compliant Dropdown can take 300 lines of code to cover keyboard navigation, roving tabindex, type-ahead, focus restoration, outside-click closing, Esc closing, SSR safety... Miss one and it is a bug. Radix does all of this and has been validated by hundreds of thousands of projects. An engineer's value is not in reinventing modal dialogs, but in spending effort on the genuinely complex interactions in the product domain.

The only reasonable reasons to write one yourself are learning a11y or building a component library with no budget for third-party dependencies. Even then, you should test against the official APG examples point by point.

8. Testing: Walk It with a Keyboard, Listen with a Screen Reader

Writing the code is not the end; you must test it. There are two gates:

First gate: keyboard testing. Unplug the mouse and use only the keyboard:

  • Can Tab/Shift+Tab reach every interactive element?
  • Does focus order match visual order?
  • After a modal opens, is focus trapped inside it? After it closes, does focus return to the trigger?
  • Can every operation be completed using only the keyboard?

Second gate: screen-reader testing. Test at least one:

  • Built-in VoiceOver on macOS (enable it with Cmd+F5; it is free and sufficient).
  • NVDA on Windows (free).
  • Listen to what it actually announces: are the role, name, state, and value all correct? Do you hear “button,” the expanded state, and Toast content?

Many teams perform only visual QA before launch, leaving a11y in terrible shape. Five minutes for each of these two gates can catch nearly 80% of a11y issues.

Summary

  • Semantic HTML covers only native components; custom interactive components such as modals, menus, Tabs, and Toasts must manually restore ARIA and keyboard behavior.
  • A modal's four-piece a11y set is role="dialog" + aria-modal + a focus trap + focus restoration, plus Escape to close.
  • Tabs must use roving tabindex and pair the tab/tabpanel set with aria-selected / aria-controls / aria-labelledby; inactive panels use hidden.
  • Toasts use role="status" (polite) or role="alert" (assertive), with a container that remains in the DOM.
  • The three non-negotiable rules of focus management are trapping, restoration, and visible focus rings—do not leave outline: none without a replacement.
  • Do not hand-roll these in production. Use Radix UI or react-aria and reserve your effort for complex domain interactions.

Accessibility is not an “extra benefit” for a small group. It is part of engineering quality—a component that even one keyboard user cannot get through is, fundamentally, unfinished.

Related

Related Content