React Accessibility: ARIA Patterns, Keyboard Navigation, Focus Management, and Screen Reader Testing
Build accessible React applications. Covers ARIA roles and attributes, keyboard navigation patterns, focus management with useRef and focus traps, skip links, screen reader testing with axe and NVDA, and accessible form patterns.
Accessibility is often treated as a compliance checkbox โ a final audit before launch. That framing sets teams up for expensive retrofits. Accessibility built in from the start is cheaper and results in better UX for everyone: keyboard users, screen reader users, motor-impaired users, and sighted mouse users who benefit from the same semantic structure.
This guide covers the practical ARIA patterns, keyboard navigation, focus management, and testing approaches that matter most in React applications.
The Three Rules of ARIA
Before adding ARIA attributes, understand when not to use them:
-
Don't use ARIA if you can use a native HTML element.
<button>already hasrole="button", keyboard focus, and activation on Enter/Space. Don't use<div role="button">unless you have no other choice. -
Don't change native semantics unless you have to. Don't add
role="heading"to a<p>โ use<h1>โ<h6>. -
All interactive ARIA elements must be keyboard accessible. If you give something a role like
comboboxortree, you must also implement full keyboard support for that pattern.
Semantic HTML First
// โ Common mistake: divs everywhere
function BadButton({ onClick, children }: any) {
return (
<div onClick={onClick} style={{ cursor: "pointer" }}>
{children}
</div>
);
// Screen readers don't announce this as a button
// Keyboard users can't Tab to it or activate it
// No focus ring unless explicitly styled
}
// โ
Native button: focus, keyboard, announcement for free
function GoodButton({ onClick, children }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
onClick={onClick}
className="px-4 py-2 bg-blue-600 text-white rounded-lg
hover:bg-blue-700 focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed"
>
{children}
</button>
);
}
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Focus Management
Skip Link
Skip links let keyboard users jump past repeated navigation:
// components/skip-link.tsx
export function SkipLink() {
return (
<a
href="#main-content"
className={[
"sr-only focus:not-sr-only",
"fixed top-4 left-4 z-50",
"bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium",
"focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-blue-600",
].join(" ")}
>
Skip to main content
</a>
);
}
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SkipLink />
<Header />
<main id="main-content" tabIndex={-1}>
{children}
</main>
</body>
</html>
);
}
// tabIndex={-1} on main allows programmatic focus without adding to tab order
Focus Trap in Modals
When a modal opens, focus must be trapped inside until it closes:
// hooks/use-focus-trap.ts
import { useEffect, useRef } from "react";
const FOCUSABLE_SELECTORS = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(", ");
export function useFocusTrap(isActive: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isActive || !containerRef.current) return;
const container = containerRef.current;
const focusableElements = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Focus first element when trap activates
firstElement?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey) {
// Shift+Tab: if focus is on first element, wrap to last
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
// Tab: if focus is on last element, wrap to first
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};
container.addEventListener("keydown", handleKeyDown);
return () => container.removeEventListener("keydown", handleKeyDown);
}, [isActive]);
return containerRef;
}
// components/dialog.tsx โ accessible modal
import { useEffect, useRef } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { X } from "lucide-react";
interface DialogProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Dialog({ isOpen, onClose, title, children }: DialogProps) {
const containerRef = useFocusTrap(isOpen);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// Save current focus to restore on close
previousFocusRef.current = document.activeElement as HTMLElement;
} else {
// Restore focus when dialog closes
previousFocusRef.current?.focus();
}
}, [isOpen]);
// Close on Escape
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) onClose();
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={onClose}
aria-hidden="true"
/>
{/* Dialog */}
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50
bg-white rounded-xl shadow-xl p-6 w-full max-w-lg
focus:outline-none"
>
<div className="flex items-start justify-between mb-4">
<h2 id="dialog-title" className="text-lg font-semibold text-gray-900">
{title}
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 rounded-md p-1
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
aria-label="Close dialog"
>
<X className="w-5 h-5" />
</button>
</div>
{children}
</div>
</>
);
}
Live Regions for Dynamic Updates
// components/live-announcer.tsx
// Announce dynamic content changes to screen readers
import { useEffect, useRef } from "react";
interface LiveAnnouncerProps {
message: string;
politeness?: "polite" | "assertive";
}
export function LiveAnnouncer({ message, politeness = "polite" }: LiveAnnouncerProps) {
return (
<div
role="status"
aria-live={politeness}
aria-atomic="true"
className="sr-only"
>
{message}
</div>
);
}
// Usage in a search component:
function SearchResults({ query, results }: { query: string; results: any[] }) {
return (
<>
<LiveAnnouncer
message={
results.length === 0
? `No results found for "${query}"`
: `${results.length} result${results.length !== 1 ? "s" : ""} found for "${query}"`
}
/>
{/* Render results */}
</>
);
}
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Accessible Form Patterns
// components/form-field.tsx โ fully accessible form field
import { useId } from "react";
interface FormFieldProps {
label: string;
error?: string;
hint?: string;
required?: boolean;
children: (props: {
id: string;
"aria-describedby"?: string;
"aria-invalid"?: boolean;
"aria-required"?: boolean;
}) => React.ReactNode;
}
export function FormField({ label, error, hint, required, children }: FormFieldProps) {
const id = useId();
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;
// aria-describedby can reference multiple IDs
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block text-sm font-medium text-gray-700">
{label}
{required && (
<span className="ml-1 text-red-500" aria-label="required">*</span>
)}
</label>
{hint && (
<p id={hintId} className="text-xs text-gray-500">
{hint}
</p>
)}
{children({
id,
"aria-describedby": describedBy,
"aria-invalid": error ? true : undefined,
"aria-required": required,
})}
{error && (
<p id={errorId} role="alert" className="text-xs text-red-600 flex items-center gap-1">
<span aria-hidden="true">โ </span>
{error}
</p>
)}
</div>
);
}
// Usage:
function EmailField({ error }: { error?: string }) {
return (
<FormField label="Email address" error={error} required hint="We'll never share your email">
{(ariaProps) => (
<input
type="email"
{...ariaProps}
className={`w-full rounded-lg border px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500
${error ? "border-red-300 bg-red-50" : "border-gray-300"}`}
/>
)}
</FormField>
);
}
Keyboard Navigation: Roving tabindex
For widget collections (tabs, toolbars, radio groups), use roving tabindex โ only one item in the group is in the tab order at a time:
// hooks/use-roving-tabindex.ts
import { useRef, useCallback } from "react";
export function useRovingTabindex(count: number) {
const currentIndex = useRef(0);
const getTabIndex = useCallback(
(index: number) => (index === currentIndex.current ? 0 : -1),
[]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent, index: number) => {
const container = (e.currentTarget as HTMLElement).closest("[data-roving]");
if (!container) return;
const items = Array.from(
container.querySelectorAll<HTMLElement>("[data-roving-item]")
);
let nextIndex = index;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
nextIndex = (index + 1) % count;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
nextIndex = (index - 1 + count) % count;
} else if (e.key === "Home") {
e.preventDefault();
nextIndex = 0;
} else if (e.key === "End") {
e.preventDefault();
nextIndex = count - 1;
} else {
return;
}
currentIndex.current = nextIndex;
items[nextIndex]?.focus();
},
[count]
);
return { getTabIndex, handleKeyDown };
}
// Accessible tab list using roving tabindex
function TabList({ tabs, activeTab, onTabChange }: {
tabs: string[];
activeTab: number;
onTabChange: (index: number) => void;
}) {
const { getTabIndex, handleKeyDown } = useRovingTabindex(tabs.length);
return (
<div role="tablist" aria-label="Navigation tabs" data-roving>
{tabs.map((tab, index) => (
<button
key={tab}
role="tab"
id={`tab-${index}`}
aria-selected={index === activeTab}
aria-controls={`panel-${index}`}
tabIndex={getTabIndex(index)}
onClick={() => onTabChange(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
data-roving-item
className={`px-4 py-2 text-sm font-medium rounded-t-lg
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
${index === activeTab
? "bg-white text-blue-600 border-b-2 border-blue-600"
: "text-gray-600 hover:text-gray-900"
}`}
>
{tab}
</button>
))}
</div>
);
}
Automated Testing with axe
// __tests__/accessibility.test.tsx
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
import { Dialog } from "@/components/dialog";
import { FormField } from "@/components/form-field";
expect.extend(toHaveNoViolations);
describe("Accessibility", () => {
it("Dialog has no a11y violations", async () => {
const { container } = render(
<Dialog isOpen title="Test Dialog" onClose={() => {}}>
<p>Dialog content</p>
<button>Action</button>
</Dialog>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("FormField with error has no a11y violations", async () => {
const { container } = render(
<FormField label="Email" error="Invalid email" required>
{(props) => <input type="email" {...props} />}
</FormField>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
Manual Testing Checklist
| Test | Tool | How |
|---|---|---|
| Keyboard navigation | Browser | Tab through entire page โ every interactive element must be reachable |
| Focus visibility | Browser | Tab through โ focus ring must be visible at all times |
| Screen reader | NVDA (Windows), VoiceOver (Mac) | Navigate with arrow keys; check announcements |
| Color contrast | axe DevTools or Lighthouse | Check all text/background combinations |
| Zoom to 200% | Browser | No content should overflow or become inaccessible |
| Reduced motion | prefers-reduced-motion | Disable animations for users who prefer it |
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Accessibility audit (existing app) | 1 dev | 3โ5 days | $800โ1,500 |
| Fix critical violations (Level A) | 1 dev | 1โ2 weeks | $2,000โ4,000 |
| Full WCAG 2.1 AA compliance | 1โ2 devs | 3โ6 weeks | $8,000โ20,000 |
| Ongoing accessibility testing in CI | 1 dev | 1โ2 days to set up | $300โ600 |
See Also
- React Design System with Radix UI
- React Testing Library Patterns
- React Error Boundaries
- Next.js Performance Optimization
- TypeScript Utility Types for React
Working With Viprasol
Accessibility built in from the start costs a fraction of what retrofitting costs โ and it's the right thing to build. Our team implements semantic HTML, ARIA patterns, keyboard navigation, and focus management as standard practice, with axe-based automated testing in CI to catch regressions before they ship.
What we deliver:
- Skip links, landmark regions, and heading hierarchy audit
- Focus trap for modals and drawers
- Roving tabindex for tabs, toolbars, and radio groups
- Live regions for search results, toasts, and async updates
- Accessible form fields with
aria-describedby,aria-invalid, androle="alert" - jest-axe integration for automated CI accessibility testing
Talk to our team about accessibility for your application โ
Or explore our web development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.