Chapter 14: Accessibility (a11y)
Accessibility is not a feature — it is a quality of your application. An inaccessible app excludes users. With the European Accessibility Act (June 2025) and existing ADA requirements, it is also a legal obligation for many applications.
This chapter covers practical accessibility implementation for React + TypeScript applications.
Foundation: Semantic HTML
The single most impactful accessibility practice is using the right HTML elements:
// ✅ Semantic — screen readers understand the structure
<header>
<nav aria-label="Main navigation">
<ul>
<li><Link to="/dashboard">Dashboard</Link></li>
<li><Link to="/projects">Projects</Link></li>
</ul>
</nav>
</header>
<main>
<h1>Projects</h1>
<section aria-labelledby="active-heading">
<h2 id="active-heading">Active Projects</h2>
{/* content */}
</section>
</main>
// ❌ div soup — screen readers see undifferentiated content
<div className="header">
<div className="nav">
<div onClick={goToDashboard}>Dashboard</div>
<div onClick={goToProjects}>Projects</div>
</div>
</div>
<div className="main">
<div className="title">Projects</div>
<div className="section">
<div className="subtitle">Active Projects</div>
</div>
</div>
Key Semantic Elements
| Element | Purpose |
|---|---|
<button> | Clickable actions (not <div onClick>) |
<a href> | Navigation links |
<nav> | Navigation sections (add aria-label when multiple) |
<main> | Primary content area (one per page) |
<header>, <footer> | Page/section header and footer |
<section> | Thematic grouping (with heading) |
<article> | Self-contained content |
<form> | Form wrapper |
<fieldset> + <legend> | Form field grouping |
<label> | Form field labels (use htmlFor) |
<table>, <th>, <td> | Tabular data (not layout) |
Keyboard Navigation
Every interactive element must be operable by keyboard:
// ✅ Keyboard-accessible custom component
function CustomDropdown({ options, onSelect }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case "Enter":
case " ":
e.preventDefault();
if (isOpen && focusedIndex >= 0) {
onSelect(options[focusedIndex]);
setIsOpen(false);
} else {
setIsOpen(true);
}
break;
case "ArrowDown":
e.preventDefault();
if (!isOpen) setIsOpen(true);
setFocusedIndex((i) => Math.min(i + 1, options.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setFocusedIndex((i) => Math.max(i - 1, 0));
break;
case "Escape":
setIsOpen(false);
break;
}
};
return (
<div onKeyDown={handleKeyDown}>
<button
aria-haspopup="listbox"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
>
Select option
</button>
{isOpen && (
<ul role="listbox">
{options.map((option, index) => (
<li
key={option.value}
role="option"
aria-selected={index === focusedIndex}
onClick={() => onSelect(option)}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
}
Why this matters: shadcn/ui components built on Radix UI handle keyboard navigation automatically. But custom components must implement it manually.
Focus Management
After navigation or dynamic content changes, manage focus programmatically:
// Focus management after route change
function PageContent({ title, children }: { title: string; children: React.ReactNode }) {
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
// Focus the heading after navigation so screen readers announce the new page
headingRef.current?.focus();
}, [title]);
return (
<main>
<h1 ref={headingRef} tabIndex={-1} className="outline-none">
{title}
</h1>
{children}
</main>
);
}
Focus Trapping in Modals
// Radix UI Dialog handles this automatically:
import * as Dialog from "@radix-ui/react-dialog";
<Dialog.Root>
<Dialog.Trigger asChild>
<Button>Open Dialog</Button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-background p-6">
{/* Focus is trapped inside this content */}
<Dialog.Title>Create Project</Dialog.Title>
<Dialog.Description>Fill in the details below.</Dialog.Description>
{/* form content */}
<Dialog.Close asChild>
<Button variant="ghost">Cancel</Button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
ARIA Attributes
Use ARIA when HTML semantics are insufficient:
// Live regions — announce dynamic changes
function NotificationArea({ notifications }: { notifications: Notification[] }) {
return (
<div aria-live="polite" aria-atomic="false">
{notifications.map((n) => (
<div key={n.id} role="status">
{n.message}
</div>
))}
</div>
);
}
// Loading states
function DataTable({ isLoading, data }: DataTableProps) {
return (
<div aria-busy={isLoading}>
{isLoading ? (
<div role="status" aria-label="Loading data">
<Spinner />
<span className="sr-only">Loading...</span>
</div>
) : (
<table>{/* data */}</table>
)}
</div>
);
}
Screen-Reader Only Text
/* Tailwind provides sr-only utility */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
// Icon-only buttons MUST have accessible labels
<Button variant="ghost" size="icon" aria-label="Delete project">
<TrashIcon className="h-4 w-4" />
</Button>
// Or use sr-only text
<Button variant="ghost" size="icon">
<TrashIcon className="h-4 w-4" />
<span className="sr-only">Delete project</span>
</Button>
TypeScript-Enforced Accessibility
Use TypeScript to catch accessibility violations at compile time:
// Require either children (visible text) or aria-label
type IconButtonProps =
| {
"aria-label": string;
children?: never;
icon: React.ReactNode;
}
| {
"aria-label"?: string;
children: React.ReactNode;
icon?: React.ReactNode;
};
function IconButton(props: IconButtonProps & ButtonHTMLAttributes<HTMLButtonElement>) {
// TypeScript ensures the button is always labeled
return <button {...props}>{props.icon}{props.children}</button>;
}
// ✅ Compiles — has aria-label
<IconButton icon={<TrashIcon />} aria-label="Delete" />
// ✅ Compiles — has visible children
<IconButton>Delete</IconButton>
// ❌ Type error — no label at all
<IconButton icon={<TrashIcon />} />
Forms Accessibility
function LoginForm() {
return (
<form aria-labelledby="login-heading">
<h2 id="login-heading">Sign In</h2>
<div className="space-y-4">
{/* Label associated with input via htmlFor */}
<div>
<label htmlFor="email" className="text-sm font-medium">
Email Address
</label>
<input
id="email"
type="email"
autoComplete="email"
required
aria-required="true"
aria-describedby={errors.email ? "email-error" : undefined}
aria-invalid={!!errors.email}
/>
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-destructive">
{errors.email}
</p>
)}
</div>
<div>
<label htmlFor="password" className="text-sm font-medium">
Password
</label>
<input
id="password"
type="password"
autoComplete="current-password"
required
aria-required="true"
aria-describedby="password-hint"
/>
<p id="password-hint" className="text-xs text-muted-foreground">
Must be at least 8 characters
</p>
</div>
<Button type="submit">Sign In</Button>
</div>
</form>
);
}
ESLint Accessibility Rules
pnpm add -D eslint-plugin-jsx-a11y
// eslint.config.js
import jsxA11y from "eslint-plugin-jsx-a11y";
export default [
// ... other configs
jsxA11y.flatConfigs.recommended,
];
Key rules this enables:
jsx-a11y/alt-text— images must have alt textjsx-a11y/anchor-has-content— links must have contentjsx-a11y/click-events-have-key-events— click handlers need keyboard equivalentsjsx-a11y/no-noninteractive-element-interactions— non-interactive elements should not have handlersjsx-a11y/label-has-associated-control— labels must be associated with inputs
Testing Accessibility
Automated: axe-core
// src/test/a11y.tsx
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);
// In component tests:
it("has no accessibility violations", async () => {
const { container } = render(<ProjectCard project={mockProject} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Manual Testing Checklist
- Keyboard only: Navigate the entire app using only Tab, Shift+Tab, Enter, Space, Arrow keys, and Escape
- Screen reader: Test with VoiceOver (macOS), NVDA (Windows), or Orca (Linux)
- Zoom: Verify the UI works at 200% browser zoom
- Color contrast: Check all text meets WCAG AA (4.5:1 for normal text, 3:1 for large text)
- Reduced motion: Test with
prefers-reduced-motionenabled
Summary
- ✅ Semantic HTML is the foundation — use the right elements
- ✅ Keyboard navigation for every interactive element
- ✅ Focus management after navigation and dynamic content changes
- ✅ ARIA attributes when HTML semantics are insufficient
- ✅ TypeScript can enforce labeling requirements at compile time
- ✅ Forms need labels, error messages, and proper ARIA attributes
- ✅ eslint-plugin-jsx-a11y catches common violations during development
- ✅ axe-core provides automated accessibility testing
- ✅ Radix UI (via shadcn/ui) handles most a11y concerns automatically