Skip to main content

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

ElementPurpose
<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 text
  • jsx-a11y/anchor-has-content — links must have content
  • jsx-a11y/click-events-have-key-events — click handlers need keyboard equivalents
  • jsx-a11y/no-noninteractive-element-interactions — non-interactive elements should not have handlers
  • jsx-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

  1. Keyboard only: Navigate the entire app using only Tab, Shift+Tab, Enter, Space, Arrow keys, and Escape
  2. Screen reader: Test with VoiceOver (macOS), NVDA (Windows), or Orca (Linux)
  3. Zoom: Verify the UI works at 200% browser zoom
  4. Color contrast: Check all text meets WCAG AA (4.5:1 for normal text, 3:1 for large text)
  5. Reduced motion: Test with prefers-reduced-motion enabled

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