Building Accessible React Components from the Ground Up
A guide to building React components with accessibility first: ARIA patterns, keyboard navigation, focus management and screen reader testing.

Accessibility Is Not a Feature—It Is a Baseline
Accessibility is not something you add to a finished component. If a button does not work with a keyboard, it is not a complete button. If a modal traps focus incorrectly, it is a broken modal. Accessibility is the minimum bar for a component being functional, not an enhancement you bolt on before an audit.
The good news: building accessible React components is not harder than building inaccessible ones. It requires understanding a few patterns and applying them consistently. The patterns become second nature after a few components.
Semantic HTML as the Foundation
The most impactful accessibility improvement is using the correct HTML element. A <button> gets keyboard interaction, focus management, and screen reader announcements for free. A <div onClick> gets none of them.
// ❌ Custom div pretending to be a button
function BadButton({ onClick, children }: {
onClick: () => void;
children: React.ReactNode;
}) {
return (
<div
className="btn"
onClick={onClick}
>
{children}
</div>
);
// Missing: keyboard support, focus, role, tabindex
}
// ✅ Actual button element — accessible by default
function GoodButton({ onClick, children, disabled = false }: {
onClick: () => void;
children: React.ReactNode;
disabled?: boolean;
}) {
return (
<button
className="btn"
onClick={onClick}
disabled={disabled}
type="button"
>
{children}
</button>
);
// Gets for free: focus, keyboard activation, disabled state,
// screen reader role announcement
}Before reaching for ARIA attributes, ask whether the correct HTML element already provides what you need. In most cases, it does.
Keyboard Navigation Patterns
Every interactive element must be operable with a keyboard. This means handling focus order, arrow key navigation within composite widgets, and escape keys for dismissible elements.
import { useRef, useCallback, KeyboardEvent } from "react";
interface TabItem {
id: string;
label: string;
content: React.ReactNode;
}
function Tabs({ items }: { items: TabItem[] }) {
const [activeIndex, setActiveIndex] = useState(0);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
let newIndex = activeIndex;
switch (event.key) {
case "ArrowRight":
newIndex = (activeIndex + 1) % items.length;
break;
case "ArrowLeft":
newIndex = (activeIndex - 1 + items.length) % items.length;
break;
case "Home":
newIndex = 0;
break;
case "End":
newIndex = items.length - 1;
break;
default:
return;
}
event.preventDefault();
setActiveIndex(newIndex);
tabRefs.current[newIndex]?.focus();
},
[activeIndex, items.length]
);
return (
<div>
<div
role="tablist"
aria-label="Content tabs"
onKeyDown={handleKeyDown}
>
{items.map((item, index) => (
<button
key={item.id}
ref={(el) => { tabRefs.current[index] = el; }}
role="tab"
id={`tab-${item.id}`}
aria-selected={index === activeIndex}
aria-controls={`panel-${item.id}`}
tabIndex={index === activeIndex ? 0 : -1}
onClick={() => setActiveIndex(index)}
>
{item.label}
</button>
))}
</div>
{items.map((item, index) => (
<div
key={item.id}
role="tabpanel"
id={`panel-${item.id}`}
aria-labelledby={`tab-${item.id}`}
hidden={index !== activeIndex}
tabIndex={0}
>
{item.content}
</div>
))}
</div>
);
}The tab component follows the WAI-ARIA Tabs pattern: arrow keys move between tabs, only the active tab is in the tab order (tabIndex={0}), and inactive tabs are removed from the tab order (tabIndex={-1}).
Focus Management for Modals and Dialogs
Modals must trap focus within their boundaries and return focus to the trigger element when closed. Without this, keyboard users get lost behind the modal overlay.
import { useEffect, useRef, useCallback } from "react";
function useFocusTrap(isOpen: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement;
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const container = containerRef.current;
if (!container) return;
const focusableElements = container.querySelectorAll(focusableSelector);
const firstElement = focusableElements[0] as HTMLElement;
firstElement?.focus();
return () => {
previousFocusRef.current?.focus();
};
}
}, [isOpen]);
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
if (event.key !== "Tab") return;
const container = containerRef.current;
if (!container) return;
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableElements = container.querySelectorAll(focusableSelector);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[
focusableElements.length - 1
] as HTMLElement;
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}, []);
return { containerRef, handleKeyDown };
}
function Modal({
isOpen,
onClose,
title,
children,
}: {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}) {
const { containerRef, handleKeyDown } = useFocusTrap(isOpen);
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onKeyDown={(e) => {
handleKeyDown(e);
if (e.key === "Escape") onClose();
}}
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}Live Regions for Dynamic Content
When content updates dynamically—form validation errors, notification toasts, loading states—screen readers need to be told that something changed. ARIA live regions handle this.
import { useState } from "react";
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
return (
<div>
{/* Polite announcement for search results count */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{isLoading
? "Searching..."
: `${results.length} results found for "${query}"`}
</div>
{/* Results list */}
<ul aria-label={`Search results for ${query}`}>
{results.map((result, i) => (
<li key={i}>{result}</li>
))}
</ul>
</div>
);
}
function FormWithValidation() {
const [errors, setErrors] = useState<Record<string, string>>({});
return (
<form>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<div
id="email-error"
role="alert"
className="error-message"
>
{errors.email}
</div>
)}
</div>
</form>
);
}Use aria-live="polite" for non-urgent updates (search results, status changes) and role="alert" for important messages that need immediate attention (validation errors, error states).
Testing Accessibility
Accessible components need automated tests that verify ARIA attributes, keyboard interactions, and focus management.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
describe("Tabs component", () => {
const items = [
{ id: "1", label: "Tab 1", content: <p>Content 1</p> },
{ id: "2", label: "Tab 2", content: <p>Content 2</p> },
{ id: "3", label: "Tab 3", content: <p>Content 3</p> },
];
it("supports arrow key navigation", async () => {
const user = userEvent.setup();
render(<Tabs items={items} />);
const firstTab = screen.getByRole("tab", { name: "Tab 1" });
await user.click(firstTab);
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("tab", { name: "Tab 2" })).toHaveFocus();
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("tab", { name: "Tab 3" })).toHaveFocus();
// Wraps around
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("tab", { name: "Tab 1" })).toHaveFocus();
});
it("sets correct ARIA attributes", () => {
render(<Tabs items={items} />);
const activeTab = screen.getByRole("tab", { name: "Tab 1" });
expect(activeTab).toHaveAttribute("aria-selected", "true");
expect(activeTab).toHaveAttribute("tabindex", "0");
const inactiveTab = screen.getByRole("tab", { name: "Tab 2" });
expect(inactiveTab).toHaveAttribute("aria-selected", "false");
expect(inactiveTab).toHaveAttribute("tabindex", "-1");
});
it("shows correct panel when tab is selected", async () => {
const user = userEvent.setup();
render(<Tabs items={items} />);
expect(screen.getByText("Content 1")).toBeVisible();
expect(screen.queryByText("Content 2")).not.toBeVisible();
await user.click(screen.getByRole("tab", { name: "Tab 2" }));
expect(screen.getByText("Content 2")).toBeVisible();
});
});Automated testing catches regressions in ARIA attributes and keyboard behavior. Combine this with manual screen reader testing on at least one screen reader (VoiceOver on macOS, NVDA on Windows) for each new component pattern.
Key Takeaways
Accessibility starts with semantic HTML. Use the correct elements before reaching for ARIA—a <button> is more accessible than any number of ARIA attributes on a <div>. Build keyboard navigation following WAI-ARIA patterns so users get consistent, predictable behavior across components.
Focus management is critical for modals, dropdowns, and any component that creates a new interaction context. Live regions keep screen reader users informed about dynamic content changes. Test accessibility with automated tools for ARIA attributes and keyboard interactions, supplemented by manual screen reader testing.
Building accessible components is not extra work—it is the work of building components correctly.


