Implementing Accessible UI Components in React: A Practical Guide
Build truly accessible React components with ARIA patterns, keyboard navigation, focus management, screen reader support, and WCAG 2.2 compliance strategies.
Implementing Accessible UI Components in React: A Practical Guide
Accessibility isn't a feature — it's a requirement. One billion people worldwide live with disabilities, and your web application must work for all of them. Beyond the moral imperative, accessibility improves SEO (screen readers and search engines parse content similarly), increases your audience, and in many jurisdictions, it's legally required. This guide covers the practical patterns I use to build accessible React components from day one.
Accessibility Foundations
WCAG 2.2 at a Glance
WCAG organizes requirements into four principles (POUR):
| Principle | Meaning | Key Requirements |
|---|---|---|
| Perceivable | Users can perceive content | Alt text, captions, contrast (4.5:1) |
| Operable | Users can interact | Keyboard access, no traps, enough time |
| Understandable | Users can comprehend | Clear language, predictable behavior |
| Robust | Works with assistive tech | Valid HTML, ARIA, semantic markup |
Target Level AA — it covers 95% of accessibility needs without extreme constraints.
Accessible Component Patterns
1. Modal Dialog
The most commonly broken component. Here's how to do it right:
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement;
dialogRef.current?.showModal();
} else {
dialogRef.current?.close();
previousFocusRef.current?.focus();
}
}, [isOpen]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
}, [onClose]);
// Trap focus inside modal
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === dialogRef.current) {
onClose();
}
}, [onClose]);
if (!isOpen) return null;
return createPortal(
<dialog
ref={dialogRef}
onKeyDown={handleKeyDown}
onClick={handleBackdropClick}
aria-labelledby="modal-title"
className="backdrop:bg-black/60 bg-gray-900 rounded-xl p-0 max-w-lg w-full
border border-gray-800 shadow-2xl"
>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 id="modal-title" className="text-xl font-semibold text-white">
{title}
</h2>
<button
onClick={onClose}
aria-label="Close dialog"
className="p-1 rounded-lg hover:bg-gray-800 text-gray-400
hover:text-white transition"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
{children}
</div>
</dialog>,
document.body
);
}
Key accessibility features:
- Native
<dialog>element handles focus trapping automatically aria-labelledbyconnects the title to the dialog- Focus returns to trigger element on close
- Escape key closes the dialog
- Backdrop click closes the dialog
2. Tabs Component
'use client';
import { useState, useRef, useCallback } from 'react';
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
export function Tabs({ tabs }: { tabs: Tab[] }) {
const [activeTab, setActiveTab] = useState(tabs[0].id);
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const currentIndex = tabs.findIndex((t) => t.id === activeTab);
let nextIndex = currentIndex;
switch (e.key) {
case 'ArrowLeft':
nextIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1;
break;
case 'ArrowRight':
nextIndex = currentIndex === tabs.length - 1 ? 0 : currentIndex + 1;
break;
case 'Home':
nextIndex = 0;
break;
case 'End':
nextIndex = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
const nextTab = tabs[nextIndex];
setActiveTab(nextTab.id);
tabRefs.current.get(nextTab.id)?.focus();
},
[activeTab, tabs]
);
return (
<div>
<div
role="tablist"
aria-label="Content tabs"
onKeyDown={handleKeyDown}
className="flex border-b border-gray-800"
>
{tabs.map((tab) => (
<button
key={tab.id}
ref={(el) => {
if (el) tabRefs.current.set(tab.id, el);
}}
role="tab"
id={`tab-${tab.id}`}
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium transition border-b-2 -mb-px ${
activeTab === tab.id
? 'border-cyan-500 text-cyan-400'
: 'border-transparent text-gray-400 hover:text-gray-200'
}`}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={activeTab !== tab.id}
tabIndex={0}
className="py-4"
>
{tab.content}
</div>
))}
</div>
);
}
Key features: Arrow key navigation, roving tabindex, proper role/aria attributes.
3. Accessible Form with Validation
'use client';
import { useId, useState } from 'react';
export function ContactForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const nameId = useId();
const emailId = useId();
const messageId = useId();
const validate = (formData: FormData) => {
const newErrors: Record<string, string> = {};
if (!formData.get('name')) newErrors.name = 'Name is required';
if (!formData.get('email')) newErrors.email = 'Email is required';
if (!formData.get('message')) newErrors.message = 'Message is required';
return newErrors;
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const newErrors = validate(formData);
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) {
// Focus the first error field
const firstErrorField = e.currentTarget.querySelector(
`[name="${Object.keys(newErrors)[0]}"]`
) as HTMLElement;
firstErrorField?.focus();
return;
}
// Submit form...
};
return (
<form onSubmit={handleSubmit} noValidate aria-label="Contact form">
<div className="space-y-4">
<div>
<label htmlFor={nameId} className="block text-sm font-medium text-gray-300 mb-1">
Name <span aria-hidden="true" className="text-red-400">*</span>
</label>
<input
id={nameId}
name="name"
type="text"
required
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? `${nameId}-error` : undefined}
className="w-full bg-gray-800 rounded-lg px-4 py-2 text-white
border border-gray-700 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500"
/>
{errors.name && (
<p id={`${nameId}-error`} role="alert" className="text-sm text-red-400 mt-1">
{errors.name}
</p>
)}
</div>
<div>
<label htmlFor={emailId} className="block text-sm font-medium text-gray-300 mb-1">
Email <span aria-hidden="true" className="text-red-400">*</span>
</label>
<input
id={emailId}
name="email"
type="email"
required
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? `${emailId}-error` : undefined}
className="w-full bg-gray-800 rounded-lg px-4 py-2 text-white
border border-gray-700 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500"
/>
{errors.email && (
<p id={`${emailId}-error`} role="alert" className="text-sm text-red-400 mt-1">
{errors.email}
</p>
)}
</div>
<div>
<label htmlFor={messageId} className="block text-sm font-medium text-gray-300 mb-1">
Message <span aria-hidden="true" className="text-red-400">*</span>
</label>
<textarea
id={messageId}
name="message"
rows={4}
required
aria-required="true"
aria-invalid={!!errors.message}
aria-describedby={errors.message ? `${messageId}-error` : undefined}
className="w-full bg-gray-800 rounded-lg px-4 py-2 text-white
border border-gray-700 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500"
/>
{errors.message && (
<p id={`${messageId}-error`} role="alert" className="text-sm text-red-400 mt-1">
{errors.message}
</p>
)}
</div>
<button
type="submit"
className="px-6 py-3 bg-cyan-600 text-white rounded-lg font-medium
hover:bg-cyan-700 focus-visible:ring-2 focus-visible:ring-cyan-500
focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900 transition"
>
Send Message
</button>
</div>
</form>
);
}
Key features: aria-invalid, aria-describedby linking errors to fields, role="alert" for live error announcements, focus management on validation failure.
Testing Accessibility
Automated Testing with jest-axe
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Modal } from '../Modal';
expect.extend(toHaveNoViolations);
describe('Modal accessibility', () => {
it('has no accessibility violations', async () => {
const { container } = render(
<Modal isOpen={true} onClose={() => {}} title="Test Modal">
<p>Modal content</p>
</Modal>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
Manual Testing Checklist
- Keyboard only: Navigate the entire page using only Tab, Enter, Space, Escape, Arrow keys
- Screen reader: Test with NVDA (Windows) or VoiceOver (Mac)
- Zoom: Ensure layout works at 200% zoom
- Color contrast: Use browser DevTools accessibility panel
- Reduced motion: Enable
prefers-reduced-motionand verify animations stop
Quick Wins
These changes take minutes but have outsized impact:
<!-- Add skip link -->
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4
bg-cyan-600 text-white px-4 py-2 rounded-lg z-50">
Skip to main content
</a>
<!-- Use semantic HTML -->
<main id="main-content">
<article>
<header><h1>Page Title</h1></header>
<section aria-label="Projects">...</section>
<section aria-label="Experience">...</section>
</article>
</main>
<!-- Images always need alt text -->
<img src="/project.jpg" alt="Dashboard interface showing analytics charts" />
<img src="/decorative-line.svg" alt="" role="presentation" />
Key Takeaways
- Use native HTML elements first —
<dialog>,<button>,<nav>provide free accessibility - ARIA is a supplement, not a replacement — Add ARIA only when native semantics aren't enough
- Keyboard navigation is non-negotiable — Every interactive element must be keyboard accessible
- Focus management — Return focus after modals, manage roving tabindex in complex widgets
- Test with real tools — Automated tests catch ~30% of issues; screen readers catch the rest
- aria-label on sections — Give every major section a label for screen reader navigation
Accessibility is a spectrum, not a checkbox. Start with semantic HTML, add ARIA where needed, test with keyboard and screen reader, and iterate. Your users — all of them — deserve an inclusive experience.