Building Accessible React Components from the Ground Up
Build React components that are keyboard navigable, screen reader friendly and WCAG compliant by default, using ARIA, focus management and semantics.

Accessibility Is Not an Afterthought
Accessibility is a quality attribute of your software, not a separate feature to bolt on later. When you build components that handle focus correctly, expose proper semantics, and respond to keyboard input, you build components that work better for everyone—screen reader users, keyboard-only users, and mouse users alike.
Semantic HTML as the Foundation
Before reaching for ARIA attributes, use the right HTML elements. A <button> already announces itself as a button, handles Enter and Space keypresses, and is focusable. A <div onClick> does none of these things without significant extra work.
// ❌ Div pretending to be a button — inaccessible by default
function BadButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<div className="btn" onClick={onClick}>
{children}
</div>
);
}
// ✅ Semantic button — accessible automatically
function GoodButton({ onClick, children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button className="btn" onClick={onClick} {...props}>
{children}
</button>
);
}// Semantic structure matters for screen readers
function ArticleCard({ title, excerpt, date, href }: ArticleCardProps) {
return (
<article aria-labelledby={`title-${href}`}>
<header>
<time dateTime={date}>{formatDate(date)}</time>
<h3 id={`title-${href}`}>
<a href={href}>{title}</a>
</h3>
</header>
<p>{excerpt}</p>
</article>
);
}Focus Management in Dynamic Components
When content appears or disappears dynamically—modals, dropdowns, tab panels—focus must move predictably. Losing focus to the document body disorients keyboard and screen reader users.
function Modal({ isOpen, onClose, title, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// Store the element that had focus before opening
previousFocusRef.current = document.activeElement as HTMLElement;
// Move focus into the modal
modalRef.current?.focus();
return () => {
// Restore focus when modal closes
previousFocusRef.current?.focus();
};
}
}, [isOpen]);
// Trap focus inside the modal
function handleKeyDown(event: React.KeyboardEvent) {
if (event.key === "Escape") {
onClose();
return;
}
if (event.key !== "Tab") return;
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements?.length) return;
const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose} role="presentation">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}Building an Accessible Tabs Component
Tabs follow the WAI-ARIA tabs pattern: a tablist containing tab elements that control tabpanel elements. Arrow keys navigate between tabs, and only the active tab is in the tab order.
function Tabs({ tabs }: { tabs: Array<{ label: string; content: React.ReactNode }> }) {
const [activeIndex, setActiveIndex] = useState(0);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
function handleKeyDown(event: React.KeyboardEvent, index: number) {
let newIndex = index;
switch (event.key) {
case "ArrowRight":
newIndex = (index + 1) % tabs.length;
break;
case "ArrowLeft":
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case "Home":
newIndex = 0;
break;
case "End":
newIndex = tabs.length - 1;
break;
default:
return;
}
event.preventDefault();
setActiveIndex(newIndex);
tabRefs.current[newIndex]?.focus();
}
return (
<div>
<div role="tablist" aria-label="Content tabs">
{tabs.map((tab, index) => (
<button
key={index}
ref={(el) => { tabRefs.current[index] = el; }}
role="tab"
id={`tab-${index}`}
aria-selected={index === activeIndex}
aria-controls={`panel-${index}`}
tabIndex={index === activeIndex ? 0 : -1}
onClick={() => setActiveIndex(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={index}
role="tabpanel"
id={`panel-${index}`}
aria-labelledby={`tab-${index}`}
hidden={index !== activeIndex}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}Live Regions for Dynamic Updates
When content updates without a page reload—toast notifications, form validation errors, live data—screen readers need to be told. ARIA live regions announce changes automatically.
function useAnnounce() {
const [message, setMessage] = useState("");
const announce = useCallback((text: string, priority: "polite" | "assertive" = "polite") => {
// Clear first to re-trigger announcement for identical messages
setMessage("");
requestAnimationFrame(() => setMessage(text));
}, []);
const AnnouncerRegion = useMemo(
() =>
function Announcer() {
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{message}
</div>
);
},
[message]
);
return { announce, AnnouncerRegion };
}
// Usage in a form
function SearchForm() {
const { announce, AnnouncerRegion } = useAnnounce();
const [results, setResults] = useState<SearchResult[]>([]);
async function handleSearch(query: string) {
const data = await fetchResults(query);
setResults(data);
announce(`${data.length} results found for "${query}"`);
}
return (
<form role="search" onSubmit={(e) => {
e.preventDefault();
const query = new FormData(e.currentTarget).get("q") as string;
handleSearch(query);
}}>
<label htmlFor="search-input">Search</label>
<input id="search-input" name="q" type="search" />
<button type="submit">Search</button>
<AnnouncerRegion />
<ul aria-label="Search results">
{results.map((r) => (
<li key={r.id}>{r.title}</li>
))}
</ul>
</form>
);
}Testing Accessibility
Automated tools catch about 30% of accessibility issues. The rest requires manual testing with a keyboard and a screen reader. Integrate both into your workflow.
// jest + testing-library accessibility assertions
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);
describe("Tabs component", () => {
it("has no accessibility violations", async () => {
const { container } = render(
<Tabs
tabs={[
{ label: "First", content: <p>First panel</p> },
{ label: "Second", content: <p>Second panel</p> },
]}
/>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("supports keyboard navigation", async () => {
const user = userEvent.setup();
render(
<Tabs
tabs={[
{ label: "First", content: <p>First panel</p> },
{ label: "Second", content: <p>Second panel</p> },
]}
/>
);
const firstTab = screen.getByRole("tab", { name: "First" });
await user.click(firstTab);
expect(firstTab).toHaveFocus();
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("tab", { name: "Second" })).toHaveFocus();
expect(screen.getByRole("tab", { name: "Second" })).toHaveAttribute(
"aria-selected",
"true"
);
});
});Key Takeaways
Accessibility starts with semantic HTML. Use <button>, <nav>, <main>, <article> before reaching for ARIA. Manage focus deliberately when dynamic content appears or disappears—track previous focus, move to new content, restore on dismiss. Follow WAI-ARIA patterns for complex widgets like tabs, menus, and dialogs.
Use live regions to announce dynamic content changes to screen readers. Test with jest-axe for automated checks, then verify with keyboard navigation and a real screen reader. Accessibility is not a checklist to complete—it is a design constraint that produces better components for every user.


