โฟ Lesson 10.3: Accessibility (a11y)
Build inclusive React applications that work for everyone. Learn how to make your web applications accessible to users with disabilities through semantic HTML, ARIA attributes, keyboard navigation, and WCAG compliance.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Understand the importance of web accessibility and legal requirements
- Use ARIA attributes correctly to enhance screen reader support
- Write semantic HTML that provides meaning and structure
- Implement full keyboard navigation in React components
- Test your applications with screen readers
- Apply WCAG 2.1 guidelines for AA and AAA compliance
- Use focus management techniques for single-page applications
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 - "a" followed by 11 letters, then "y") means building websites and applications that can be used by everyone, including people with disabilities. This includes individuals with:
- Visual impairments: Blindness, low vision, color blindness
- Hearing impairments: Deafness or hearing loss
- Motor impairments: Limited ability to use a mouse or keyboard
- Cognitive impairments: Learning disabilities, memory issues
- Temporary disabilities: Broken arm, situational limitations
๐ What is A11y?
Accessibility (a11y) is the practice of making your web applications usable by as many people as possible. It's not just about supporting people with permanent disabilitiesโit also benefits users with temporary impairments, situational limitations, or those using assistive technologies.
๐ The Statistics
Consider these important facts:
๐ก By the Numbers
- 1 billion people worldwide have some form of disability (WHO)
- 15-20% of the population has some type of disability
- 71% of users with disabilities will leave a website that's hard to use
- $6.9 billion in spending power from people with disabilities in the US alone
- 97% of homepages have WCAG 2 failures (WebAIM 2023)
โ๏ธ 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 | Public sector websites must meet WCAG 2.1 Level AA |
| 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.1 (June 2018) is the current standard, with WCAG 2.2 released in October 2023. WCAG 3.0 is in development. Most legal requirements reference WCAG 2.1 Level AA as the baseline.
๐ฏ 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 navigate away
- 2.4.3 Focus Order (A): Logical focus order
- 2.4.7 Focus Visible (AA): Visible focus indicator
- 3.2.1 On Focus (A): No context change on focus
- 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): Programmatically determined
๐๏ธ 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 using generic <div> and <span> elements everywhere, semantic HTML uses meaningful tags like <header>, <nav>, <article>, and <footer>.
โ 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>ยฉ 2024 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>ยฉ 2024 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 helps communicate the role, state, and properties of UI components to assistive technologies. Think of ARIA as a translator between your custom widgets and screen readers.
โ๏ธ 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 } from 'react';
interface AccordionProps {
title: string;
children: React.ReactNode;
}
function Accordion({ title, children }: AccordionProps) {
const [isExpanded, setIsExpanded] = useState(false);
const contentId = `accordion-content-${title.replace(/\s+/g, '-')}`;
return (
<div className="accordion">
<h3>
<button
onClick={() => setIsExpanded(!isExpanded)}
aria-expanded={isExpanded}
aria-controls={contentId}
id={`${contentId}-button`}
>
{title}
<span aria-hidden="true">{isExpanded ? 'โผ' : 'โถ'}</span>
</button>
</h3>
<div
id={contentId}
role="region"
aria-labelledby={`${contentId}-button`}
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 } from 'react';
function AccessibleFormField() {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const fieldId = 'email-input';
const errorId = 'email-error';
const hintId = 'email-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
htmlForconnects label to inputaria-requiredindicates required fieldaria-invalidindicates validation statearia-describedbyconnects hint and error textrole="alert"announces errors immediately
๐ฏ 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
In traditional websites, focus automatically resets when a new page loads. In SPAs, the page doesn't reload, so focus stays where it was. Users can become disoriented if focus isn't managed properly during route changes, modal openings, or dynamic content updates.
๐ 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"
โก 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 specially marked areas that screen readers monitor for changes. When content in a live region updates, the screen reader automatically announces the change to the user, even if they're focused elsewhere on the page.
๐ 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
function DataList() {
const [data, setData] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
return (
<div>
<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
function SearchResults() {
const [results, setResults] = useState<any[]>([]);
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?
- No semantic HTML elements
- No keyboard support
- No ARIA attributes
- No focus management
- Using divs instead of buttons/links
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
๐ 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 guidelines: Four principles (POUR) and conformance levels
- Semantic HTML: Using the right elements for meaning and structure
- ARIA attributes: Roles, properties, and states for custom widgets
- Keyboard navigation: Making all functionality keyboard accessible
- Focus management: Controlling focus in SPAs and modals
- Live regions: Announcing dynamic updates to screen readers
๐จ 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 | Automated accessibility testing | deque.com/axe |
| 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, test with real users, and 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.1 Quick Reference
- 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!