โฟ Lesson 10.3: Accessibility (a11y)
Great software works for everyone โ including the person navigating by keyboard, the user with a screen reader, and the shopper zooming to 200% on a phone in bright sunlight. In this lesson you'll learn to build inclusive React applications with semantic HTML, ARIA, keyboard support, and modern WCAG 2.2 compliance. Think of accessibility like a building's ramp: it was added for wheelchair users, but the parent with a stroller, the courier with a hand truck, and the traveler with a suitcase all use it too. Good accessibility quietly helps everybody. ๐
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Explain why web accessibility matters โ legally, ethically, and commercially
- Reach for semantic HTML first, and use ARIA roles, states, and properties only where HTML falls short
- Implement full keyboard navigation, focus management, and focus traps in React components
- Build accessible forms with labels, error announcements, and live regions
- Manage focus on route changes and generate stable ids with
useId - Apply the WCAG 2.2 principles (POUR) and target Level AA
- Test accessibility automatically with
vitest-axe/jest-axeand manually with a keyboard and screen reader
Estimated Time: 75-90 minutes
Project: Audit and fix accessibility issues in a React app
In This Lesson
๐ Introduction to Web Accessibility
Web accessibility (often abbreviated as a11y โ the letter "a", then 11 letters, then "y", the same way "internationalization" becomes "i18n") means building websites and applications that everyone can use, including people with disabilities. That's a broader group than many developers first imagine. It includes people with:
- Visual impairments: blindness, low vision, or color blindness (roughly 1 in 12 men has some form of color-vision deficiency)
- Hearing impairments: deafness or hearing loss
- Motor impairments: limited ability to use a mouse, so they rely on the keyboard, switch devices, or voice control
- Cognitive impairments: learning disabilities, memory or attention differences
- Temporary or situational limitations: a broken arm, holding a baby one-handed, or squinting at a phone on a sunny sidewalk
That last group is the key insight: accessibility is not a niche feature for "other people." Over a long enough life, almost everyone experiences some limitation โ permanent, temporary, or situational. When you build for the edges, the middle gets better too.
๐ What is A11y?
Accessibility (a11y) is the practice of making your web applications usable by as many people as possible. It isn't only about permanent disabilities โ it also serves users with temporary impairments, situational limitations, and anyone relying on assistive technologies like screen readers, magnifiers, or voice control.
๐ The Statistics
Consider these important facts:
๐ก By the Numbers
- 1.3 billion people โ about 16% of the world's population โ experience significant disability (WHO, 2023)
- 71% of users with disabilities will leave a website that's hard to use rather than struggle with it
- People with disabilities and their families control trillions of dollars in annual disposable income worldwide โ a market you lock out when your app is inaccessible
- 95.9% of the top one million homepages had detectable WCAG 2 failures (WebAIM Million, 2024)
Read that last number again: an automated scan of a million homepages found accessibility errors on nearly all of them. The bar is low, which means a little care makes your app stand out.
โ๏ธ Legal Requirements
Accessibility isn't just good practiceโit's often required by law:
| Region | Law/Standard | Requirements |
|---|---|---|
| United States | ADA, Section 508 | Websites must be accessible to people with disabilities |
| European Union | EN 301 549 ยท European Accessibility Act (EAA) | Public sector sites must meet WCAG 2.1 AA; as of June 2025 the EAA extends similar requirements to many private-sector products and services |
| United Kingdom | Equality Act 2010 | Service providers must make reasonable adjustments |
| Canada | ACA (AODA) | WCAG 2.0 Level AA compliance required |
| Australia | DDA | Websites must be accessible under discrimination law |
โ ๏ธ Legal Consequences
Companies have faced significant lawsuits for inaccessible websites. Notable cases include Domino's Pizza (Supreme Court case), Target ($6M settlement), and Netflix ($755K settlement). Don't wait for a lawsuitโbuild accessibility in from the start.
๐ญ Why Accessibility Matters
Beyond legal compliance, there are many compelling reasons to prioritize accessibility:
๐ฏ Business Benefits
โ Why Companies Should Care
- Larger market reach: Access to millions of potential customers
- Better SEO: Semantic HTML and structure helps search engines
- Improved usability: Accessible sites are easier for everyone to use
- Better code quality: Accessible code tends to be cleaner and more maintainable
- Future-proofing: Works better on diverse devices and technologies
- Corporate responsibility: Shows commitment to inclusion and diversity
๐ฅ User Benefits
Accessibility features benefit many users, not just those with disabilities:
// Example: Keyboard shortcuts help power users
// Screen reader labels help voice control users
// High contrast modes help users in bright sunlight
// Captions help users in noisy environments
// A real-world example:
interface AccessibilityBenefits {
feature: string;
primaryUsers: string;
secondaryUsers: string[];
}
const benefits: AccessibilityBenefits[] = [
{
feature: "Keyboard navigation",
primaryUsers: "Motor impaired users",
secondaryUsers: ["Power users", "Users without mouse"]
},
{
feature: "Video captions",
primaryUsers: "Deaf/hard of hearing",
secondaryUsers: ["Non-native speakers", "Users in quiet spaces", "Users in noisy environments"]
},
{
feature: "Clear language",
primaryUsers: "Cognitive disabilities",
secondaryUsers: ["Non-native speakers", "Users under stress", "All users!"]
},
{
feature: "Large click targets",
primaryUsers: "Motor impaired users",
secondaryUsers: ["Mobile users", "Elderly users", "Users with tremors"]
}
];
๐ก The Curb Cut Effect
The "curb cut effect" refers to how accessibility features designed for people with disabilities end up helping everyone. Curb cuts were originally designed for wheelchair users but are now used by parents with strollers, delivery workers with carts, travelers with luggage, and more. The same principle applies to web accessibility!
๐ The Virtuous Cycle
๐ WCAG Guidelines Overview
The Web Content Accessibility Guidelines (WCAG) are the international standard for web accessibility. Published by the W3C, these guidelines help make web content more accessible to people with disabilities.
๐ WCAG Versions
WCAG 2.2 became the W3C Recommendation in October 2023 and is the current version you should target โ it's fully backward-compatible with 2.1, adding nine new success criteria (things like larger target sizes and focus that isn't hidden behind sticky headers). WCAG 3.0 is a longer-term project still in early drafts. Many laws still name 2.1 AA as the baseline, but building to 2.2 AA satisfies them and future-proofs your work.
๐ฏ The Four Principles (POUR)
WCAG is organized around four core principles. Content must be:
1๏ธโฃ Perceivable
Information and UI components must be presentable to users in ways they can perceive.
Click to see Perceivable guidelines
- Text Alternatives: Provide text alternatives for non-text content
- Time-based Media: Provide alternatives for time-based media
- Adaptable: Create content that can be presented in different ways
- Distinguishable: Make it easier to see and hear content
Examples: Alt text for images, captions for videos, sufficient color contrast
2๏ธโฃ Operable
UI components and navigation must be operable by all users.
Click to see Operable guidelines
- Keyboard Accessible: Make all functionality available from keyboard
- Enough Time: Provide users enough time to read and use content
- Seizures: Don't design content that could cause seizures
- Navigable: Help users navigate and find content
- Input Modalities: Make it easier to operate via various inputs
Examples: Full keyboard support, skip links, clear focus indicators
3๏ธโฃ Understandable
Information and operation of the UI must be understandable.
Click to see Understandable guidelines
- Readable: Make text content readable and understandable
- Predictable: Make pages appear and operate in predictable ways
- Input Assistance: Help users avoid and correct mistakes
Examples: Clear labels, consistent navigation, error suggestions
4๏ธโฃ Robust
Content must be robust enough to work with current and future technologies.
Click to see Robust guidelines
- Compatible: Maximize compatibility with current and future tools
- Parse-able: Ensure content can be reliably interpreted
- Name, Role, Value: Ensure UI components are properly identified
Examples: Valid HTML, ARIA attributes, semantic markup
๐ Conformance Levels
WCAG has three levels of conformance:
| Level | Description | Common Use |
|---|---|---|
| A | Basic accessibility features - minimum level | Rarely sufficient; baseline requirements |
| AA โจ | Deals with common barriers - recommended level | Most legal requirements target this level |
| AAA | Highest level - specialized enhancements | Often not feasible for entire sites |
โ Target Level AA
WCAG 2.1 Level AA is the gold standard for most organizations. It's legally required in many jurisdictions and provides a good balance between accessibility and feasibility. Level AAA is great as a stretch goal but isn't always achievable for all content.
๐ฏ Key Success Criteria for React Developers
Here are the most important WCAG criteria that React developers should focus on:
Click to see key success criteria
- 1.1.1 Non-text Content (A): alt text for images
- 1.4.3 Contrast (AA): 4.5:1 for normal text, 3:1 for large text
- 2.1.1 Keyboard (A): all functionality via keyboard
- 2.1.2 No Keyboard Trap (A): users can always navigate away
- 2.4.3 Focus Order (A): logical focus order
- 2.4.7 Focus Visible (AA): visible focus indicator
- 3.3.1 Error Identification (A): errors clearly identified
- 3.3.2 Labels or Instructions (A): labels for inputs
- 4.1.2 Name, Role, Value (A): name and role programmatically determined
New in WCAG 2.2 (worth knowing today):
- 2.4.11 Focus Not Obscured (AA): the focused element can't be fully hidden behind sticky headers or cookie banners
- 2.5.8 Target Size (Minimum) (AA): interactive targets are at least 24×24 CSS pixels (or have enough spacing)
- 3.3.8 Accessible Authentication (AA): don't force a cognitive test (like retyping a code from memory) to log in
- 3.3.7 Redundant Entry (A): don't make users re-enter information they already provided in the same process
๐๏ธ Semantic HTML in React
Semantic HTML means using the right HTML elements for their intended purpose. This provides meaning and structure that assistive technologies can understand and communicate to users.
๐ What is Semantic HTML?
Semantic HTML uses elements that clearly describe their meaning to both the browser and the developer. Instead of generic <div> and <span> elements everywhere, semantic HTML uses meaningful tags like <header>, <nav>, <article>, and <footer>. A screen reader can't see that your bold, underlined <div> is a heading โ but give it an <h2> and it announces "heading level 2" and lets the user jump straight to it.
Here's the core idea in one picture. On the left, everything is a <div> โ a screen reader sees a flat, meaningless wall of "group, group, group." On the right, semantic elements create real landmarks the user can navigate to directly:
โ Non-Semantic vs โ Semantic
Let's compare non-semantic and semantic approaches:
// โ BAD: Non-semantic "div soup"
function BadApp() {
return (
<div className="app">
<div className="header">
<div className="logo">My Site</div>
<div className="menu">
<div className="link">Home</div>
<div className="link">About</div>
<div className="link">Contact</div>
</div>
</div>
<div className="content">
<div className="post">
<div className="title">Blog Post</div>
<div className="text">Post content...</div>
</div>
</div>
<div className="footer">
<div>ยฉ 2026 My Site</div>
</div>
</div>
);
}
// โ
GOOD: Semantic HTML
function GoodApp() {
return (
<div className="app">
<header>
<h1>My Site</h1>
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
</header>
<main>
<article>
<h2>Blog Post</h2>
<p>Post content...</p>
</article>
</main>
<footer>
<p>ยฉ 2026 My Site</p>
</footer>
</div>
);
}
โ Why Semantic HTML Matters
- Screen readers: Understand page structure and announce landmarks
- Keyboard navigation: Can jump between sections easily
- SEO: Search engines understand content hierarchy
- Maintainability: Code is easier to read and understand
- Styling: Can target elements more semantically with CSS
๐บ๏ธ Landmark Elements
Landmark elements define regions of your page that screen reader users can navigate to directly:
| Element | Purpose | ARIA Equivalent |
|---|---|---|
<header> |
Introductory content or navigational aids | role="banner" |
<nav> |
Navigation links | role="navigation" |
<main> |
Main content of the document | role="main" |
<aside> |
Tangentially related content | role="complementary" |
<footer> |
Footer information | role="contentinfo" |
<section> |
Thematic grouping of content | role="region" |
<article> |
Self-contained composition | role="article" |
<form> |
Form for user input | role="form" |
๐ Practical Example: Blog Layout
interface BlogPost {
id: string;
title: string;
content: string;
author: string;
date: string;
}
interface BlogLayoutProps {
posts: BlogPost[];
}
function BlogLayout({ posts }: BlogLayoutProps) {
return (
<>
{/* Page header with site branding */}
<header>
<h1>My Tech Blog</h1>
<p>Thoughts on web development and accessibility</p>
</header>
{/* Main navigation */}
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/archive">Archive</a></li>
</ul>
</nav>
{/* Main content area - must be unique per page */}
<main>
<h2>Recent Posts</h2>
{posts.map(post => (
<article key={post.id}>
<header>
{/* Article header (different from page header) */}
<h3>{post.title}</h3>
<p>
By {post.author} on <time dateTime={post.date}>
{new Date(post.date).toLocaleDateString()}
</time>
</p>
</header>
<p>{post.content}</p>
<footer>
<a href={`/post/${post.id}`}>Read more</a>
</footer>
</article>
))}
</main>
{/* Sidebar with related content */}
<aside aria-label="Sidebar">
<section>
<h2>Categories</h2>
<ul>
<li><a href="/category/react">React</a></li>
<li><a href="/category/typescript">TypeScript</a></li>
<li><a href="/category/a11y">Accessibility</a></li>
</ul>
</section>
</aside>
{/* Page footer */}
<footer>
<p>ยฉ 2024 My Tech Blog. All rights reserved.</p>
<nav aria-label="Footer navigation">
<ul>
<li><a href="/privacy">Privacy Policy</a></li>
<li><a href="/terms">Terms of Service</a></li>
</ul>
</nav>
</footer>
</>
);
}
๐ก Landmark Navigation
Screen reader users can press keyboard shortcuts to jump directly to landmarks:
- NVDA/JAWS: Press D to jump between landmarks
- VoiceOver: Use the rotor (VO + U) to navigate by landmarks
- Users can quickly skip navigation and jump straight to
<main>
๐ค Heading Hierarchy
Proper heading structure is crucial for navigation and understanding:
// โ BAD: Skipped heading levels, no hierarchy
function BadHeadingStructure() {
return (
<div>
<h1>Page Title</h1>
<h4>Section Title</h4> {/* Skipped h2 and h3! */}
<h2>Another Section</h2> {/* Out of order */}
<h5>Subsection</h5>
</div>
);
}
// โ
GOOD: Logical heading hierarchy
function GoodHeadingStructure() {
return (
<div>
<h1>Page Title</h1>
<h2>First Main Section</h2>
<p>Content here...</p>
<h3>Subsection of First Section</h3>
<p>More specific content...</p>
<h3>Another Subsection</h3>
<p>Content here...</p>
<h2>Second Main Section</h2>
<p>Content here...</p>
<h3>Subsection of Second Section</h3>
<p>Content here...</p>
</div>
);
}
โ ๏ธ Heading Rules
- Only one
<h1>per page (typically the page title) - Don't skip heading levels (h1 โ h2 โ h3, not h1 โ h3)
- Headings describe content structure, not just visual styling
- Screen readers use headings to navigate - make them meaningful
๐ Links vs Buttons
Use the right element for the right purpose:
// โ BAD: Wrong elements for the job
function BadInteractiveElements() {
return (
<div>
{/* Don't use divs as buttons */}
<div onClick={() => handleSubmit()}>Submit</div>
{/* Don't use buttons as links */}
<button onClick={() => window.location.href = '/about'}>
About Us
</button>
{/* Don't use spans as clickable elements */}
<span onClick={() => openModal()}>Open</span>
</div>
);
}
// โ
GOOD: Right elements for the job
function GoodInteractiveElements() {
const navigate = useNavigate();
return (
<div>
{/* Use button for actions */}
<button onClick={handleSubmit} type="button">
Submit
</button>
{/* Use link for navigation */}
<a href="/about">About Us</a>
{/* Or use React Router Link */}
<Link to="/about">About Us</Link>
{/* Use button for modal trigger */}
<button onClick={openModal} type="button">
Open
</button>
</div>
);
}
| Use... | When... | Example |
|---|---|---|
<button> |
Triggering an action on the same page | Submit form, open modal, toggle menu |
<a> |
Navigating to a different page or location | Links to pages, sections, external sites |
<input type="submit"> |
Submitting a form | Form submission button |
โ Key Differences
- Links navigate to a URL (can be opened in new tabs, bookmarked)
- Buttons perform actions (submit forms, open modals, toggle state)
- Keyboard: Links use Enter, buttons use Enter and Space
- Screen readers: Announce as "link" or "button" with different expectations
๐ญ ARIA Attributes Basics
ARIA (Accessible Rich Internet Applications) is a set of attributes that define ways to make web content and applications more accessible to people with disabilities. ARIA fills in the gaps where HTML semantics fall short.
๐ What is ARIA?
ARIA adds accessibility information to HTML elements through attributes. It communicates the role (what something is), state (its current condition), and properties (its characteristics) of UI components to assistive technologies. Think of ARIA as a set of subtitles for your custom widgets โ it tells the screen reader what the pixels can't.
โ ๏ธ No ARIA is better than bad ARIA
Here's the counterintuitive truth: incorrect ARIA is worse than none at all. A wrong role or a stale aria-expanded actively lies to screen-reader users, sending them somewhere that doesn't exist or hiding controls that do. The WebAIM Million survey consistently finds that pages with ARIA average more detected errors than pages without it โ not because ARIA is bad, but because it's easy to misuse. So reach for native HTML first, and add ARIA deliberately.
โ๏ธ The Five Rules of ARIA
Before using ARIA, understand these fundamental rules from the W3C:
โ Rule #1: Don't Use ARIA
If you can use a native HTML element or attribute, do that instead. Native HTML is always more robust and better supported. Use ARIA only when HTML doesn't provide what you need.
// โ BAD: Unnecessary ARIA
<div role="button" tabIndex={0} onClick={handleClick}>Click me</div>
// โ
GOOD: Use native button
<button onClick={handleClick}>Click me</button>
// โ BAD: Unnecessary role
<nav role="navigation">...</nav>
// โ
GOOD: nav element already has role="navigation"
<nav>...</nav>
๐ก Rule #2: Don't Change Native Semantics
Don't change the native semantics of an element unless you really have to. For example, don't make a heading into a button.
// โ BAD: Changing semantic meaning
<h2 role="button">Click me</h2>
// โ
GOOD: Use proper structure
<h2>Section Title</h2>
<button>Click me</button>
๐ก Rule #3: All Interactive ARIA Controls Must Be Keyboard Accessible
If you add a role like button or link to a non-interactive element, you MUST make it keyboard accessible with tabIndex and keyboard event handlers.
๐ก Rule #4: Don't Use role="presentation" or aria-hidden="true" on Focusable Elements
Don't hide interactive elements from screen readers. Users need to know what they're focusing on.
๐ก Rule #5: All Interactive Elements Must Have an Accessible Name
Every interactive element must have a text label that screen readers can announce. Use visible text, aria-label, or aria-labelledby.
๐ฏ ARIA Attribute Categories
ARIA attributes fall into three main categories:
1๏ธโฃ ARIA Roles
Roles define what an element is or does:
// Common ARIA roles
interface ARIARoleExamples {
widget: string[]; // Interactive elements
composite: string[]; // Composite widgets
document: string[]; // Document structure
landmark: string[]; // Page regions
}
const roles: ARIARoleExamples = {
// Widget roles
widget: [
'button', // A clickable button
'checkbox', // A checkable input
'link', // A hyperlink
'menuitem', // An option in a menu
'option', // An item in a listbox
'radio', // A radio button
'searchbox', // A search input
'slider', // A slider control
'spinbutton', // A number input with up/down
'switch', // An on/off switch
'tab', // A tab in a tablist
'textbox', // A text input
],
// Composite widget roles
composite: [
'combobox', // Input + listbox combo
'menu', // A menu of options
'listbox', // A list of options
'tablist', // A list of tabs
'tree', // A tree view
],
// Document structure roles
document: [
'article', // Self-contained content
'heading', // A heading
'img', // An image
'list', // A list of items
'listitem', // An item in a list
'row', // A row in a table
'table', // A table
],
// Landmark roles
landmark: [
'banner', // Page header (use <header> instead)
'navigation', // Navigation (use <nav> instead)
'main', // Main content (use <main> instead)
'complementary', // Aside (use <aside> instead)
'contentinfo', // Footer (use <footer> instead)
'form', // Form (use <form> instead)
'region', // Generic landmark
'search', // Search functionality
]
};
2๏ธโฃ ARIA Properties
Properties describe characteristics that don't usually change:
| Property | Purpose | Example |
|---|---|---|
aria-label |
Provides a text label | aria-label="Close dialog" |
aria-labelledby |
References element(s) that label this one | aria-labelledby="title-id" |
aria-describedby |
References element(s) that describe this one | aria-describedby="help-text" |
aria-required |
Indicates required form field | aria-required="true" |
aria-placeholder |
Defines placeholder text | aria-placeholder="Enter email" |
aria-valuemin/max |
Defines range limits | aria-valuemin="0" |
3๏ธโฃ ARIA States
States describe conditions that change over time:
| State | Purpose | Example |
|---|---|---|
aria-expanded |
Indicates if element is expanded | aria-expanded="true" |
aria-checked |
Indicates checkbox/radio state | aria-checked="true" |
aria-hidden |
Hides element from screen readers | aria-hidden="true" |
aria-disabled |
Indicates element is disabled | aria-disabled="true" |
aria-selected |
Indicates selection state | aria-selected="true" |
aria-pressed |
Indicates toggle button state | aria-pressed="true" |
aria-invalid |
Indicates validation error | aria-invalid="true" |
aria-busy |
Indicates loading state | aria-busy="true" |
๐ Practical ARIA Examples
Example 1: Accessible Button Icon
// โ BAD: Icon button with no label
function BadIconButton() {
return (
<button onClick={handleDelete}>
<TrashIcon />
</button>
);
}
// โ
GOOD: Icon button with aria-label
function GoodIconButton() {
return (
<button
onClick={handleDelete}
aria-label="Delete item"
>
<TrashIcon aria-hidden="true" />
</button>
);
}
// โ
ALSO GOOD: Icon button with visible text
function BetterIconButton() {
return (
<button onClick={handleDelete}>
<TrashIcon aria-hidden="true" />
<span>Delete</span>
</button>
);
}
Example 2: Expandable Section
import { useState, useId, type ReactNode } from 'react';
interface AccordionProps {
title: string;
children: ReactNode;
}
function Accordion({ title, children }: AccordionProps) {
const [isExpanded, setIsExpanded] = useState(false);
// useId gives a stable, unique, SSR-safe id โ never build ids from user text
const contentId = useId();
const buttonId = useId();
return (
<div className="accordion">
<h3>
<button
type="button"
id={buttonId}
onClick={() => setIsExpanded((open) => !open)}
aria-expanded={isExpanded}
aria-controls={contentId}
>
{title}
<span aria-hidden="true">{isExpanded ? 'โผ' : 'โถ'}</span>
</button>
</h3>
<div
id={contentId}
role="region"
aria-labelledby={buttonId}
hidden={!isExpanded}
>
{children}
</div>
</div>
);
}
// Usage
function AccordionDemo() {
return (
<Accordion title="What is accessibility?">
<p>Accessibility means making your website usable by everyone...</p>
</Accordion>
);
}
โ Key ARIA Techniques
aria-expandedtells users if content is visiblearia-controlslinks the button to the content it controlsaria-labelledbyassociates the region with its buttonhiddenattribute hides content from everyone (including screen readers)aria-hidden="true"hides decorative icons from screen readers
Example 3: Form Field with Error
import { useState, useId } from 'react';
function AccessibleFormField() {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
// One useId() call gives a unique prefix we derive related ids from โ
// safe even if this component renders many times on the page.
const baseId = useId();
const fieldId = `${baseId}-input`;
const errorId = `${baseId}-error`;
const hintId = `${baseId}-hint`;
const validateEmail = (value: string) => {
if (!value) {
setError('Email is required');
return false;
}
if (!value.includes('@')) {
setError('Please enter a valid email address');
return false;
}
setError('');
return true;
};
return (
<div className="form-field">
<label htmlFor={fieldId}>
Email Address
<span aria-label="required">*</span>
</label>
<p id={hintId} className="hint">
We'll never share your email with anyone else.
</p>
<input
type="email"
id={fieldId}
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => validateEmail(email)}
aria-required="true"
aria-invalid={error ? 'true' : 'false'}
aria-describedby={`${hintId} ${error ? errorId : ''}`}
/>
{error && (
<p id={errorId} className="error" role="alert">
{error}
</p>
)}
</div>
);
}
๐ก Form Accessibility Features
useId()generates a collision-free id prefix โ the modern React way to link labels, hints, and errors without hardcoded stringshtmlForconnects the label to its input (clicking the label focuses the field)aria-requiredindicates a required fieldaria-invalidflips to"true"when validation fails, so the screen reader announces the field as invalidaria-describedbyties the hint and error text to the input, so both are read outrole="alert"announces the error the moment it appears โ even if focus is elsewhere
One caveat worth remembering: useId is for linking elements, not for keys in a list. For key props, use a stable id from your data.
๐ฏ Focus Management in SPAs
Single-page applications (SPAs) present unique focus management challenges. When content changes without a page reload, we must manually manage focus to ensure keyboard and screen reader users aren't lost.
๐ Why Focus Management Matters
On a traditional website, focus resets to the top when a new page loads. In an SPA, the page never reloads โ so after you click a link and swap the content, focus is still sitting on the link that no longer exists. A sighted mouse user won't notice, but a keyboard or screen-reader user is now stranded in the old context. Managing focus on route changes, modal openings, and dynamic updates is what keeps those users oriented.
A modal is the classic case. Here's the full lifecycle of focus when a dialog opens and closes โ every arrow is a step you're responsible for in an SPA:
๐ Focus Management on Route Changes
When users navigate to a new route, focus should move to the new page's main content:
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
function App() {
const location = useLocation();
const mainRef = useRef<HTMLElement>(null);
// Focus main content area on route change
useEffect(() => {
mainRef.current?.focus();
}, [location.pathname]);
return (
<div>
<header>
<nav>{/* Navigation */}</nav>
</header>
<main
ref={mainRef}
tabIndex={-1}
className="main-content"
>
{/* Route content */}
</main>
</div>
);
}
โ Focus on Route Change
- Use
tabIndex={-1}on main content area - Focus it programmatically after route changes
- This tells screen readers "you're on a new page"
- Users can immediately read the new content
๐ช Focus Management in Modals
Modals require careful focus management to create a proper "focus trap":
import { useEffect, useRef, KeyboardEvent } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
function AccessibleModal({ isOpen, onClose, title, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
// Save focus when modal opens, restore when it closes
useEffect(() => {
if (isOpen) {
// Save currently focused element
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus the modal
modalRef.current?.focus();
// Prevent body scroll
document.body.style.overflow = 'hidden';
} else {
// Restore focus to element that opened modal
previousFocusRef.current?.focus();
// Restore body scroll
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
// Handle Escape key
useEffect(() => {
const handleEscape = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
// Trap focus within modal
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements || focusableElements.length === 0) return;
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
if (e.shiftKey && document.activeElement === firstElement) {
// Tab backwards from first element
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
// Tab forward from last element
e.preventDefault();
firstElement.focus();
}
};
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal"
onClick={(e) => e.stopPropagation()}
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<header className="modal-header">
<h2 id="modal-title">{title}</h2>
<button
onClick={onClose}
aria-label="Close dialog"
className="modal-close"
>
ร
</button>
</header>
<div className="modal-content">
{children}
</div>
<footer className="modal-footer">
<button onClick={onClose}>Cancel</button>
<button onClick={onClose} className="primary">Confirm</button>
</footer>
</div>
</div>
);
}
๐ก Modal Focus Requirements
- Save previous focus: Remember what was focused before opening
- Move focus to modal: Focus the dialog element when it opens
- Trap focus: Keep tab navigation within the modal
- Handle Escape: Close modal when Escape is pressed
- Restore focus: Return focus to trigger element when closing
- Use proper ARIA:
role="dialog"andaria-modal="true"
โ
Modern shortcut: the native <dialog> element
The example above is worth understanding because it shows what a modal must do. But in 2026 you often don't have to hand-roll it. The native HTML <dialog> element, opened with dialogRef.current.showModal(), gives you several of these behaviors for free:
- Focus moves into the dialog automatically, and the browser traps Tab inside it
- Esc closes it and fires a
closeevent โ no keydown listener needed - Everything behind it becomes
inertautomatically (the rest of the page can't be focused or clicked) - It renders in the browser's top layer, above every
z-index
You still restore focus to the trigger and give it an accessible name via aria-labelledby. The standalone inert attribute is also useful on its own for freezing background regions. Reach for a battle-tested library (React Aria, Radix, Headless UI) when you need full ARIA authoring-practices compliance across browsers.
โก Focus Management Hook
Create a reusable hook for focus management:
import { useRef, useEffect } from 'react';
// Hook to manage focus for dynamic content
function useFocusManagement(isVisible: boolean) {
const elementRef = useRef<HTMLElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isVisible) {
// Save current focus
previousFocusRef.current = document.activeElement as HTMLElement;
// Move focus to the element
elementRef.current?.focus();
} else if (previousFocusRef.current) {
// Restore focus
previousFocusRef.current.focus();
}
}, [isVisible]);
return elementRef;
}
// Usage
function Dropdown({ isOpen, onClose }: DropdownProps) {
const dropdownRef = useFocusManagement(isOpen);
if (!isOpen) return null;
return (
<div
ref={dropdownRef}
tabIndex={-1}
className="dropdown"
>
{/* Dropdown content */}
</div>
);
}
๐ Skip Links
Skip links allow keyboard users to bypass repetitive content:
function PageLayout({ children }: { children: React.ReactNode }) {
return (
<>
{/* Skip link - first focusable element */}
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<header>
<nav>
{/* Long navigation menu */}
</nav>
</header>
<main id="main-content" tabIndex={-1}>
{children}
</main>
</>
);
}
// CSS for skip link
const skipLinkStyles = `
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #667eea;
color: white;
padding: 8px;
text-decoration: none;
z-index: 1000;
}
.skip-link:focus {
top: 0;
}
`;
โ Skip Link Best Practices
- Position skip link as the first focusable element
- Make it visible when focused (don't hide it completely)
- Link to the main content area with
id="main-content" - Make the target focusable with
tabIndex={-1} - Consider adding multiple skip links (to navigation, search, etc.)
๐ข ARIA Live Regions
ARIA live regions announce dynamic content changes to screen reader users. When content updates without a page reload, screen readers won't notice unless you tell them with live regions.
๐ What are Live Regions?
Live regions are marked areas that screen readers watch for changes. When their content updates, the screen reader announces the change automatically โ even if the user's focus is somewhere else entirely. This is how you tell a blind user "your search returned 12 results" or "the form was saved" without moving their focus or interrupting their flow.
๐ ARIA Live Politeness Levels
Live regions have different politeness levels that control how urgently changes are announced:
| Attribute | Politeness | Use Case |
|---|---|---|
aria-live="off" |
No announcements | Default - don't announce changes |
aria-live="polite" |
Wait for pause | Status messages, non-critical updates |
aria-live="assertive" |
Interrupt immediately | Errors, urgent alerts |
role="status" |
Polite (implicit) | Status messages, confirmations |
role="alert" |
Assertive (implicit) | Errors, warnings |
โ ๏ธ Use Assertive Sparingly
aria-live="assertive" interrupts whatever the screen reader is currently saying. Use it only for truly urgent messages like errors or critical warnings. Overuse creates a poor experience.
๐ Practical Examples
Example 1: Form Validation Messages
import { useState } from 'react';
function FormWithLiveValidation() {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const validateEmail = (value: string) => {
if (!value.includes('@')) {
setError('Please enter a valid email address');
} else {
setError('');
}
};
return (
<form>
<label htmlFor="email">Email Address</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => validateEmail(email)}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? 'email-error' : undefined}
/>
{/* Live region for error - announces immediately */}
{error && (
<p
id="email-error"
role="alert"
className="error"
>
{error}
</p>
)}
</form>
);
}
Example 2: Loading States
interface DataItem {
id: string;
name: string;
}
function DataList() {
const [data, setData] = useState<DataItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
return (
<div>
<button type="button" onClick={fetchData}>Load Data</button>
{/* Loading status - polite announcement */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
>
{isLoading && 'Loading data...'}
{!isLoading && data.length > 0 && `Loaded ${data.length} items`}
</div>
<ul>
{data.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
Example 3: Toast Notifications
import { useState, useEffect } from 'react';
interface Toast {
id: string;
message: string;
type: 'success' | 'error' | 'info';
}
function ToastContainer() {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = (message: string, type: Toast['type']) => {
const id = Date.now().toString();
setToasts(prev => [...prev, { id, message, type }]);
// Auto-dismiss after 5 seconds
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 5000);
};
return (
<>
{/* Live region for announcements */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{toasts[toasts.length - 1]?.message}
</div>
{/* Visual toasts */}
<div className="toast-container" aria-label="Notifications">
{toasts.map(toast => (
<div
key={toast.id}
className={`toast toast-${toast.type}`}
role={toast.type === 'error' ? 'alert' : 'status'}
>
{toast.message}
<button
onClick={() => setToasts(prev => prev.filter(t => t.id !== toast.id))}
aria-label="Dismiss notification"
>
ร
</button>
</div>
))}
</div>
</>
);
}
// Screen reader only styles
const srOnlyStyles = `
.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;
}
`;
โ Live Region Best Practices
- Keep it brief: Announce concise, meaningful messages
- Use aria-atomic: Set to "true" to announce entire region
- Don't overuse: Too many announcements are overwhelming
- Test thoroughly: Behavior varies between screen readers
- Separate visual and announcements: Use hidden live regions
- Prefer role="status" and role="alert": Over explicit aria-live
๐ Live Region Hook
Create a reusable hook for announcements:
import { useEffect, useRef } from 'react';
function useAnnounce() {
const liveRegionRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
// Create live region if it doesn't exist
if (!liveRegionRef.current) {
const liveRegion = document.createElement('div');
liveRegion.setAttribute('role', 'status');
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
liveRegion.className = 'sr-only';
document.body.appendChild(liveRegion);
liveRegionRef.current = liveRegion;
}
return () => {
// Cleanup on unmount
if (liveRegionRef.current) {
document.body.removeChild(liveRegionRef.current);
liveRegionRef.current = null;
}
};
}, []);
const announce = (message: string, priority: 'polite' | 'assertive' = 'polite') => {
if (!liveRegionRef.current) return;
// Clear previous message
liveRegionRef.current.textContent = '';
// Set priority
liveRegionRef.current.setAttribute('aria-live', priority);
// Small delay ensures screen readers notice the change
setTimeout(() => {
if (liveRegionRef.current) {
liveRegionRef.current.textContent = message;
}
}, 100);
};
return announce;
}
// Usage
interface SearchResult {
id: string;
title: string;
}
function SearchResults() {
const [results, setResults] = useState<SearchResult[]>([]);
const announce = useAnnounce();
const handleSearch = async (query: string) => {
const data = await fetchResults(query);
setResults(data);
announce(`Found ${data.length} results for ${query}`);
};
return (
<div>
{/* Search UI */}
</div>
);
}
๐๏ธ Hands-on Exercises
Let's practice what we've learned! These exercises will help you apply accessibility concepts to real-world scenarios.
๐ฏ Exercise 1: Fix the Inaccessible Navigation
This navigation has several accessibility issues. Identify and fix them:
View the broken code
// โ BROKEN: Multiple accessibility issues
function BrokenNav() {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="nav">
<div onClick={() => setIsOpen(!isOpen)}>
Menu โฐ
</div>
{isOpen && (
<div className="menu">
<div onClick={() => navigate('/')}>Home</div>
<div onClick={() => navigate('/about')}>About</div>
<div onClick={() => navigate('/contact')}>Contact</div>
</div>
)}
</div>
);
}
Hint: What's wrong?
<div onClick>isn't focusable or keyboard-operable โ it should be a<button>- No
aria-expanded/aria-controlstying the toggle to the menu it opens - No landmark โ the whole thing should live in a
<nav> - Menu items are
<div>s doing navigation; they should be links in a list - No way to close the menu with Esc
Solution
// โ
FIXED: Fully accessible navigation
function AccessibleNav() {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLElement>(null);
const toggleMenu = () => {
setIsOpen(!isOpen);
};
// Close on Escape
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen]);
return (
<nav aria-label="Main navigation">
<button
onClick={toggleMenu}
aria-expanded={isOpen}
aria-controls="main-menu"
aria-label="Toggle navigation menu"
>
Menu โฐ
</button>
{isOpen && (
<ul id="main-menu" ref={menuRef}>
<li><Link to="/">Home</Link></li>
<li><Link to="/about">About</Link></li>
<li><Link to="/contact">Contact</Link></li>
</ul>
)}
</nav>
);
}
๐ฏ Exercise 2: Create an Accessible Modal
Build a modal dialog from scratch with proper focus management and keyboard support.
Requirements
- Focus modal when opened
- Trap focus within modal
- Close on Escape key
- Restore focus when closed
- Use proper ARIA attributes
- Prevent body scroll when open
Hint: Start here
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
function Modal({ isOpen, onClose, title, children }: ModalProps) {
// Your implementation here
// Don't forget:
// - useRef for modal element
// - useRef for previous focus
// - useEffect for focus management
// - useEffect for Escape key
// - useEffect for body scroll
// - Focus trap in onKeyDown
return (
// Your JSX here
);
}
๐ฏ Exercise 3: Audit an Existing Component
Use this checklist to audit one of your existing React components for accessibility:
Accessibility Audit Checklist
Semantic HTML
- โ Uses semantic elements (nav, main, header, etc.)
- โ Proper heading hierarchy (h1 โ h2 โ h3)
- โ Buttons for actions, links for navigation
- โ Form labels connected to inputs
Keyboard Navigation
- โ All interactive elements reachable by Tab
- โ Logical tab order
- โ Visible focus indicators
- โ No keyboard traps
- โ Escape closes modals/menus
ARIA
- โ ARIA used only when HTML isn't sufficient
- โ All interactive elements have accessible names
- โ aria-expanded for expandable elements
- โ aria-live for dynamic updates
- โ Proper roles for custom widgets
Visual
- โ Color contrast meets WCAG AA (4.5:1)
- โ Images have alt text
- โ Text can be resized to 200%
- โ No information conveyed by color alone
Forms
- โ Labels for all inputs
- โ Error messages announced to screen readers
- โ Required fields marked
- โ Instructions provided
๐ฏ Exercise 4: Catch Regressions with Automated a11y Tests
Manual testing is essential, but it doesn't scale to every pull request. Automated tools like axe-core can catch a large share of common issues โ missing labels, bad contrast, invalid ARIA โ in your test suite. In a Vite + Vitest project you wire it up with vitest-axe (the classic jest-axe works the same way in Jest):
npm install --save-dev vitest-axe @testing-library/react jsdom
import { render } from '@testing-library/react';
import { axe } from 'vitest-axe';
import { expect, test } from 'vitest';
import { SignupForm } from './SignupForm';
test('SignupForm has no detectable a11y violations', async () => {
const { container } = render(<SignupForm />);
// axe-core scans the rendered DOM and reports WCAG violations
const results = await axe(container);
expect(results).toHaveNoViolations();
});
โ ๏ธ Automated testing is a floor, not a ceiling
Tools like axe reliably catch only about 30โ40% of WCAG issues. They can tell you an input has no label, but not whether that label makes sense, whether the tab order is logical, or whether your custom widget actually feels usable with a screen reader. Automated checks are a safety net for regressions โ never a substitute for the manual pass below.
Your manual smoke test (do this before every release)
- โ Keyboard only: unplug the mouse; Tab through everything, activate with Enter/Space, close with Esc
- โ Focus visible: you can always see where focus is
- โ Screen reader: run one flow with NVDA (Windows) or VoiceOver (macOS, Cmd+F5)
- โ Zoom to 200%: nothing overlaps or gets clipped
- โ Contrast: spot-check text with your browser's DevTools contrast checker
๐ Summary
Congratulations! You've learned the fundamentals of web accessibility in React. Let's recap the key concepts:
๐ฏ Key Takeaways
โ What You've Learned
- Why accessibility matters: Legal, ethical, and business benefits
- WCAG 2.2 guidelines: The four principles (POUR), conformance levels, and target Level AA
- Semantic HTML first: Using the right elements for meaning and structure
- ARIA โ deliberately: Roles, properties, and states for custom widgets, and why no ARIA beats bad ARIA
- Keyboard navigation: Making all functionality keyboard accessible
- Focus management: Controlling focus in SPAs and modals, plus the native
<dialog>anduseId - Live regions: Announcing dynamic updates to screen readers
- Testing: Automated checks with vitest-axe/jest-axe, backed by manual keyboard and screen-reader passes
๐จ The Accessibility Mindset
Building accessible applications isn't just about following rulesโit's about adopting a mindset:
๐ ๏ธ Essential Tools
Keep these tools in your accessibility toolkit:
| Tool | Purpose | Link |
|---|---|---|
| axe DevTools | In-browser automated accessibility scanning | deque.com/axe |
| vitest-axe / jest-axe | axe-core in your unit tests โ catch regressions in CI | github.com/chaance/vitest-axe |
| eslint-plugin-jsx-a11y | Flags a11y mistakes in JSX as you type | eslint-plugin-jsx-a11y |
| WAVE | Visual accessibility evaluation | wave.webaim.org |
| Lighthouse | Built into Chrome DevTools | Chrome DevTools โ Lighthouse |
| NVDA | Free Windows screen reader | nvaccess.org |
| VoiceOver | Built-in macOS/iOS screen reader | Built into Apple devices |
| Color Contrast Analyzer | Check color contrast ratios | tpgi.com/color-contrast-checker |
๐ Quick Reference
Common ARIA Patterns Cheat Sheet
// Button
<button type="button">Click Me</button>
// Icon button
<button aria-label="Delete"><TrashIcon aria-hidden="true" /></button>
// Toggle button
<button aria-pressed={isPressed}>Bold</button>
// Expandable section
<button aria-expanded={isOpen} aria-controls="content-id">
Section Title
</button>
<div id="content-id" hidden={!isOpen}>Content</div>
// Modal
<div role="dialog" aria-modal="true" aria-labelledby="title">
<h2 id="title">Dialog Title</h2>
</div>
// Alert
<div role="alert">Error message</div>
// Status
<div role="status" aria-live="polite">Loading...</div>
// Tab interface
<div role="tablist">
<button role="tab" aria-selected={isActive}>Tab 1</button>
</div>
<div role="tabpanel">Panel content</div>
๐ก Remember
Accessibility is not a feature โ it's a fundamental requirement. Every user deserves equal access to your application, regardless of their abilities. Build accessibility in from the start (it's far cheaper than retrofitting), lean on semantic HTML, add ARIA deliberately, automate the easy checks, and test with real people and real assistive tech. Keep learning!
๐ What's Next?
You've mastered accessibility fundamentals! Here's how to continue your journey:
๐ Further Learning
- Next Lesson: Lesson 10.4 - Build and Deployment
- Practice: Audit and fix accessibility issues in your existing projects
- Read: WCAG 2.2 Quick Reference and the ARIA Authoring Practices Guide
- Test: Use screen readers (NVDA, VoiceOver) to experience your sites
- Learn: The A11Y Project for practical tips
๐ฏ Immediate Action Items
๐ก This Week's Challenge
- Pick one of your React projects
- Run it through axe DevTools
- Fix the top 5 issues found
- Test with keyboard navigation only
- Try using a screen reader
๐ Continue Building
Apply what you've learned in the next lesson where we'll deploy our accessible applications to production!