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.

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.
// ❌ 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// ✅ 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 activateSemantic 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.
// ❌ 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?// ✅ 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.
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.
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.
// ❌ 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>
);
}// ✅ 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 itaria-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
- Use semantic HTML before reaching for ARIA — native elements handle keyboard, focus, and roles automatically
- Label every interactive element — icon buttons need
aria-label, form inputs need<label>elements - Implement keyboard navigation for custom components following WAI-ARIA patterns — Arrow keys, Enter, Escape
- Trap focus in modals — save previous focus, cycle Tab within the modal, restore focus on close
- Use live regions for dynamic content —
role="alert"for errors,aria-live="polite"for status updates - Test with a screen reader — VoiceOver on Mac, NVDA on Windows, or ChromeVox in Chrome


