โ™ฟ 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-axe and 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

graph LR A[Build Accessible Features] --> B[Reach More Users] B --> C[Get More Feedback] C --> D[Improve Product] D --> E[Better User Experience] E --> A style A fill:#667eea,stroke:#764ba2,stroke-width:2px,color:#fff style B fill:#48bb78,stroke:#38a169,stroke-width:2px,color:#fff style C fill:#ed8936,stroke:#dd6b20,stroke-width:2px,color:#fff style D fill:#4299e1,stroke:#3182ce,stroke-width:2px,color:#fff style E fill:#9f7aea,stroke:#805ad5,stroke-width:2px,color:#fff

๐Ÿ“‹ 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:

graph TD A[WCAG Principles] --> B[Perceivable] A --> C[Operable] A --> D[Understandable] A --> E[Robust] B --> B1[Users can perceive information] B --> B2[Not invisible to senses] C --> C1[Users can operate interface] C --> C2[Not require impossible interactions] D --> D1[Users can understand content] D --> D2[Interface operates predictably] E --> E1[Content works with assistive tech] E --> E2[Works as tech evolves] style A fill:#667eea,stroke:#764ba2,stroke-width:3px,color:#fff style B fill:#48bb78,stroke:#38a169,stroke-width:2px,color:#fff style C fill:#ed8936,stroke:#dd6b20,stroke-width:2px,color:#fff style D fill:#4299e1,stroke:#3182ce,stroke-width:2px,color:#fff style E fill:#9f7aea,stroke:#805ad5,stroke-width:2px,color:#fff

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:

Div soup versus semantic HTML The left column is a stack of identical unlabeled div boxes; the right column is the same layout using header, nav, main, and footer landmarks that a screen reader can announce. โŒ Div soup โœ… Semantic <div> <div> <div> <div> <header> <nav> <main> <footer> Same pixels, but only the right side is navigable by landmark.
Figure 1: Both layouts look identical on screen. Only the semantic version gives assistive tech real structure to navigate.

โŒ 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:

graph TD A[ARIA Attributes] --> B[Roles] A --> C[Properties] A --> D[States] B --> B1["What IS this?"] B --> B2["role='button'"] B --> B3["role='dialog'"] C --> C1["What are its characteristics?"] C --> C2["aria-label='Close'"] C --> C3["aria-required='true'"] D --> D1["What is its current condition?"] D --> D2["aria-expanded='true'"] D --> D3["aria-checked='false'"] style A fill:#667eea,stroke:#764ba2,stroke-width:3px,color:#fff style B fill:#48bb78,stroke:#38a169,stroke-width:2px,color:#fff style C fill:#ed8936,stroke:#dd6b20,stroke-width:2px,color:#fff style D fill:#9f7aea,stroke:#805ad5,stroke-width:2px,color:#fff

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-expanded tells users if content is visible
  • aria-controls links the button to the content it controls
  • aria-labelledby associates the region with its button
  • hidden attribute 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 strings
  • htmlFor connects the label to its input (clicking the label focuses the field)
  • aria-required indicates a required field
  • aria-invalid flips to "true" when validation fails, so the screen reader announces the field as invalid
  • aria-describedby ties the hint and error text to the input, so both are read out
  • role="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.

โŒจ๏ธ Keyboard Navigation

Many users navigate websites exclusively with a keyboardโ€”whether by necessity (motor impairments), preference (power users), or temporary circumstance (broken trackpad). Every interactive element must be fully keyboard accessible.

๐Ÿ“– Keyboard Navigation

Keyboard navigation lets users interact with your app using only the keyboard. It requires focusable elements, a logical tab order, visible focus indicators, and keyboard event handlers on every interactive control. The golden test: unplug your mouse. If you can't reach and operate something, neither can a keyboard-only user.

โŒจ๏ธ Essential Keyboard Shortcuts

These are the standard keyboard interactions users expect:

Key Action Example Elements
Tab Move focus forward All interactive elements
Shift + Tab Move focus backward All interactive elements
Enter Activate element Links, buttons, form submission
Space Activate element Buttons, checkboxes, radio buttons
Esc Close/cancel Modals, dropdowns, menus
Arrow Keys Navigate within component Menus, tabs, radio groups, sliders
Home / End Jump to start/end Lists, text inputs
Page Up / Page Down Scroll by page Long lists, scrollable areas

๐Ÿ’ก Testing Keyboard Navigation

Put away your mouse and try navigating your site with only the keyboard. If you can't reach or activate something, neither can keyboard users!

  • Can you see where focus is at all times?
  • Can you reach every interactive element?
  • Can you activate buttons and links?
  • Can you escape from modals and menus?
  • Is the tab order logical?

๐ŸŽฏ Making Elements Keyboard Accessible

When you need to make a non-interactive element keyboard accessible, follow these requirements:

// โŒ BAD: Div with click handler but no keyboard support
function BadClickableDiv() {
  return (
    <div onClick={handleClick} className="card">
      Click me
    </div>
  );
}

// โœ… GOOD: Proper button element
function GoodButton() {
  return (
    <button onClick={handleClick} className="card-button">
      Click me
    </button>
  );
}

// โœ… ACCEPTABLE: Div made keyboard accessible (if button isn't possible)
function AccessibleDiv() {
  const handleKeyDown = (e: React.KeyboardEvent) => {
    // Both Enter and Space should activate
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault(); // Prevent page scroll on Space
      handleClick();
    }
  };

  return (
    <div
      onClick={handleClick}
      onKeyDown={handleKeyDown}
      tabIndex={0}
      role="button"
      className="card"
    >
      Click me
    </div>
  );
}

โš ๏ธ Requirements for Custom Interactive Elements

  • tabIndex={0} - Makes element focusable
  • role="button" - Tells screen readers what it is
  • onKeyDown - Handle Enter and Space keys
  • onClick - Handle mouse clicks
  • Visible focus indicator (via CSS)

๐Ÿ“ Focus Indicators

Users must be able to see which element has focus. Never remove focus outlines without providing an alternative!

// โŒ BAD: Removing focus outline with no replacement
const badStyles = {
  button: {
    outline: 'none' // Never do this!
  }
};

// โœ… GOOD: Custom focus styles that are visible
const goodStyles = `
  button:focus {
    outline: 3px solid #667eea;
    outline-offset: 2px;
  }

  /* Or use box-shadow for a subtler look */
  button:focus {
    outline: none; /* Only if you provide alternative */
    box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.5);
  }

  /* Modern approach with :focus-visible */
  button:focus-visible {
    outline: 3px solid #667eea;
    outline-offset: 2px;
  }
`;

โœ… Focus Indicator Best Practices

  • High contrast: Minimum 3:1 contrast ratio with background
  • Visible: Must be clearly visible against all backgrounds
  • Consistent: Use the same style across your site
  • Sufficient size: At least 2px thick or equivalent
  • Use :focus-visible: Shows focus only for keyboard, not mouse

๐Ÿ”ข Tab Order and tabIndex

The tab order should follow the visual flow of the page. Control it with the tabIndex attribute:

tabIndex Value Meaning Use Case
tabIndex={0} Element in natural tab order Making custom elements focusable
tabIndex={-1} Focusable by script, not tab key Elements focused programmatically
tabIndex={1+} Forced tab order (anti-pattern!) Don't use positive values

โš ๏ธ Never Use Positive tabIndex Values

Positive tabIndex values (1, 2, 3, etc.) create a confusing tab order that doesn't match the visual layout. They're considered an anti-pattern and should never be used. If you need to change tab order, reorganize your DOM instead.

โŒจ๏ธ Keyboard Event Handling

Here's a reusable hook for handling keyboard interactions:

import { KeyboardEvent } from 'react';

// Custom hook for keyboard interactions
function useKeyboardInteraction(
  onActivate: () => void,
  options: {
    enableSpace?: boolean;
    enableEnter?: boolean;
    preventDefault?: boolean;
  } = {}
) {
  const {
    enableSpace = true,
    enableEnter = true,
    preventDefault = true
  } = options;

  const handleKeyDown = (e: KeyboardEvent) => {
    const isSpace = e.key === ' ' || e.key === 'Spacebar';
    const isEnter = e.key === 'Enter';

    if ((enableSpace && isSpace) || (enableEnter && isEnter)) {
      if (preventDefault) {
        e.preventDefault();
      }
      onActivate();
    }
  };

  return { onKeyDown: handleKeyDown };
}

// Usage example
function CustomButton({ onClick, children }: { 
  onClick: () => void; 
  children: React.ReactNode;
}) {
  const keyboardProps = useKeyboardInteraction(onClick);

  return (
    <div
      role="button"
      tabIndex={0}
      onClick={onClick}
      {...keyboardProps}
      className="custom-button"
    >
      {children}
    </div>
  );
}

๐ŸŽฎ Advanced Keyboard Patterns

Some widgets require more complex keyboard interactions. Here's a tab component with proper keyboard support:

import { useState, useRef, useEffect, KeyboardEvent } from 'react';

interface Tab {
  id: string;
  label: string;
  content: React.ReactNode;
}

interface TabsProps {
  tabs: Tab[];
  defaultTab?: string;
}

function AccessibleTabs({ tabs, defaultTab }: TabsProps) {
  const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const handleKeyDown = (e: KeyboardEvent, index: number) => {
    let newIndex = index;

    switch (e.key) {
      case 'ArrowLeft':
        e.preventDefault();
        newIndex = index === 0 ? tabs.length - 1 : index - 1;
        break;
      case 'ArrowRight':
        e.preventDefault();
        newIndex = index === tabs.length - 1 ? 0 : index + 1;
        break;
      case 'Home':
        e.preventDefault();
        newIndex = 0;
        break;
      case 'End':
        e.preventDefault();
        newIndex = tabs.length - 1;
        break;
      default:
        return;
    }

    // Focus and activate the new tab
    tabRefs.current[newIndex]?.focus();
    setActiveTab(tabs[newIndex].id);
  };

  return (
    <div className="tabs">
      {/* Tab list */}
      <div role="tablist" aria-label="Content tabs">
        {tabs.map((tab, index) => {
          const isActive = tab.id === activeTab;
          
          return (
            <button
              key={tab.id}
              ref={el => tabRefs.current[index] = el}
              role="tab"
              id={`tab-${tab.id}`}
              aria-selected={isActive}
              aria-controls={`panel-${tab.id}`}
              tabIndex={isActive ? 0 : -1}
              onClick={() => setActiveTab(tab.id)}
              onKeyDown={(e) => handleKeyDown(e, index)}
              className={isActive ? 'tab active' : 'tab'}
            >
              {tab.label}
            </button>
          );
        })}
      </div>

      {/* Tab panels */}
      {tabs.map(tab => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`panel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          hidden={tab.id !== activeTab}
          tabIndex={0}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

๐Ÿ’ก Keyboard Patterns in Tabs

  • Arrow Left/Right - Navigate between tabs
  • Home - Jump to first tab
  • End - Jump to last tab
  • Only active tab is in tab order (tabIndex={0})
  • Inactive tabs are focusable by script only (tabIndex={-1})
  • aria-selected indicates which tab is active

๐ŸŽฏ 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:

graph LR A["Trigger clicked"] --> B["Save current focus"] B --> C["Move focus into dialog"] C --> D["Trap Tab inside dialog"] D -->|"Esc or Close"| E["Restore saved focus"] E --> F["User back where they started"] style A fill:#667eea,stroke:#764ba2,stroke-width:2px,color:#fff style C fill:#4299e1,stroke:#3182ce,stroke-width:2px,color:#fff style D fill:#ed8936,stroke:#dd6b20,stroke-width:2px,color:#fff style E fill:#48bb78,stroke:#38a169,stroke-width:2px,color:#fff

๐Ÿ”„ 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

  1. Save previous focus: Remember what was focused before opening
  2. Move focus to modal: Focus the dialog element when it opens
  3. Trap focus: Keep tab navigation within the modal
  4. Handle Escape: Close modal when Escape is pressed
  5. Restore focus: Return focus to trigger element when closing
  6. Use proper ARIA: role="dialog" and aria-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 close event โ€” no keydown listener needed
  • Everything behind it becomes inert automatically (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-controls tying 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> and useId
  • 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:

graph TD A[Plan for Accessibility] --> B[Design with Users in Mind] B --> C[Build Semantic HTML First] C --> D[Add ARIA When Needed] D --> E[Test with Real Users] E --> F[Iterate and Improve] F --> A style A fill:#667eea,stroke:#764ba2,stroke-width:2px,color:#fff style B fill:#48bb78,stroke:#38a169,stroke-width:2px,color:#fff style C fill:#ed8936,stroke:#dd6b20,stroke-width:2px,color:#fff style D fill:#4299e1,stroke:#3182ce,stroke-width:2px,color:#fff style E fill:#9f7aea,stroke:#805ad5,stroke-width:2px,color:#fff style F fill:#f687b3,stroke:#ed64a6,stroke-width:2px,color:#fff

๐Ÿ› ๏ธ 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

๐ŸŽฏ Immediate Action Items

๐Ÿ’ก This Week's Challenge

  1. Pick one of your React projects
  2. Run it through axe DevTools
  3. Fix the top 5 issues found
  4. Test with keyboard navigation only
  5. 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!