React
Building Accessible Components in React Without Slowing Down
Practical patterns for keyboard support, focus management, and ARIA in React—without over-engineering.
Accessible React components don’t require a different architecture—they need the right elements, keyboard behavior, and ARIA where semantics aren’t enough. Here’s how to build them without slowing delivery.
Start with the right elements
Use semantic HTML first. Buttons for actions, links for navigation, headings for structure. Use <button> for toggles and dialogs, not <div onClick>. That gives you focus, keyboard activation, and screen reader behavior for free. Reserve divs and spans for layout and styling when no semantic element fits.
// Prefer
<button type="button" onClick={handleOpen} aria-expanded={isOpen}>
Open menu
</button>
// Avoid
<div role="button" tabIndex={0} onClick={handleOpen}>
Open menu
</div>When you must use a non-semantic element (e.g. a custom card that acts as a link), add role, tabIndex, and keyboard handlers and ensure focus is visible.
Keyboard support for custom widgets
Custom dropdowns, modals, and tabs must be keyboard usable. Handle Enter and Space for activation, Arrow keys for movement where appropriate, and Escape to close. Manage focus: move focus into the widget when it opens (e.g. first focusable item or the trigger) and trap focus inside modals until they close. When a modal closes, return focus to the element that opened it.
function Dialog({ open, onClose, children }) {
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement;
ref.current?.focus();
const handleKey = (e) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKey);
return () => {
document.removeEventListener("keydown", handleKey);
previouslyFocused?.focus();
};
}, [open, onClose]);
if (!open) return null;
return (
<div
ref={ref}
role="dialog"
aria-modal="true"
tabIndex={-1}
>
{children}
</div>
);
}Reuse a well-tested primitive (e.g. Radix, Reach, Ariakit) when you can; they handle focus trap, arrows, and ARIA. If you build from scratch, mirror their behavior.
ARIA when semantics aren’t enough
Use ARIA to describe state and relationships that HTML doesn’t convey. Examples: aria-expanded for expand/collapse, aria-selected for tabs or listbox options, aria-describedby for hints and errors, aria-live for dynamic announcements. Don’t override semantics: prefer role="button" on a button only when the control isn’t really a button. Test with a screen reader (NVDA, VoiceOver) and keyboard-only; that will surface missing labels and focus issues.
Don’t block the main thread
Accessibility isn’t only semantics. If the UI is laggy, keyboard and screen reader users suffer too. Keep heavy work off the main thread (workers, chunking), virtualize long lists, and avoid layout thrash. Focus management and ARIA are quick to add; performance is a prerequisite for a good experience.
Summary
Use semantic elements, add keyboard support and focus management for custom widgets, and use ARIA for state and relationships. Prefer robust primitives when possible; otherwise implement the same patterns. Combine that with solid performance and you’ll ship accessible components without slowing down.
Related reading
- ReactError Boundaries and Resilient UI: Patterns for ProductionHow to use error boundaries so one failing component doesn’t take down the whole app—and how to recover.
- ReactForms in React: React Hook Form Patterns You'll Actually ReuseProduction patterns for React Hook Form — register vs Controller, Zod resolvers, useFormContext, field arrays, server errors, and the re-render traps that make forms feel slow.
- ReactReact Component Architecture That Scales in Real ProjectsHow to structure React components so they stay maintainable as your app grows—without over-engineering. From first components to large codebases.
More in React