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
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 hasrole="button", and screen readers announce it as a button. - Keyboard behavior: They can receive focus with Tab, activate with Enter/Space, and automatically skip
disabledelements. - 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 capability | How to restore it |
|---|---|
| Semantic role | ARIA roles such as role="dialog" |
| Focusability | tabIndex={0} and keyboard activation |
| Keyboard behavior | Handle Enter/Esc/Arrow in onKeyDown |
| Focus management | Move focus in on open, restore it on close, and trap it inside |
| Screen-reader announcements | aria-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:
role="dialog"+aria-modal="true"tell screen readers that this is a modal.- When it opens, focus enters the modal, usually on the first focusable element or the title.
- Focus trap: Tab cycles within the modal and cannot escape it, otherwise users can Tab into the background page and get lost.
- After it closes, focus returns to the trigger (focus restoration), and the Escape key closes it.
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:
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-expandedis 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
clickandscrollevents, 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.
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 is0; 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 is0, users must press Tab N times to get through the tablist, which is a usability disaster. - Use
aria-selected, notaria-current: they are not interchangeable; Tabs must usearia-selected. tabpanelneedshidden: inactive panels must be hidden with thehiddenattribute, not CSS alone. That prevents screen readers from reading hidden content.- Arrow-key switching must move focus too: calling only
setActiveis not enough. After pressing an Arrow key, users expect focus to move with it, so callfocusafter rendering completes withrequestAnimationFrame.
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:
// 断言型错误:立刻打断,适合严重错误
<div role="alert" aria-live="assertive">
保存失败,请重试
</div>
// 礼貌型通知:等屏幕阅读器空闲再念,适合 Toast
<div role="status" aria-live="polite">
已保存到草稿
</div>
Practical recommendations:
- Prefer
role="status"(equivalent toaria-live="polite"+aria-atomic="true"), and userole="alert"only for genuine error alerts.assertiveforcibly 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:
- 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.
- 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.
- Visible focus rings:
outline: noneis an a11y disaster area. If you truly dislike the default outline, you must write replacement:focus-visiblestyles. 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, andreact-tabsdirectly. - 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 usehidden. - Toasts use
role="status"(polite) orrole="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: nonewithout 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
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.
Article
What Value Do Server Components Offer Content Sites?
Technical trends and engineering practices around Next.js and React, from an independent developer's perspective.