Web Accessibility (A11y)

Making the web accessible to everyone, including users with disabilities

What is Web Accessibility?

Web Accessibility (A11y) means designing and developing websites, tools, and technologies so that people with disabilities can use them. It ensures equal access and equal opportunity to people with diverse abilities.

Core Principles (POUR)

  • Perceivable: Information and user interface components must be presentable to users in ways they can perceive (sight, hearing, touch)
  • Operable: User interface components and navigation must be operable (keyboard, mouse, voice, etc.)
  • Understandable: Information and operation of user interface must be understandable
  • Robust: Content must be robust enough to be interpreted by a wide variety of user agents, including assistive technologies

Who Benefits from Accessibility?

  • 👁️ People with visual impairments (blindness, low vision, color blindness)
  • 👂 People with auditory impairments (deafness, hard of hearing)
  • 🧠 People with cognitive disabilities (dyslexia, ADHD, autism)
  • 🖐️ People with motor disabilities (limited mobility, tremors)
  • 👴 Elderly users with changing abilities
  • 📱 Users with temporary disabilities (broken arm, bright sunlight)
  • 🌐 Users with situational limitations (slow internet, small screens)

Why Accessibility Matters

1. Legal & Compliance

  • ADA (Americans with Disabilities Act): Required for US government websites and public accommodations
  • Section 508: Federal agencies must make electronic content accessible
  • WCAG (Web Content Accessibility Guidelines): International standard (A, AA, AAA levels)
  • European Accessibility Act: Applies to EU member states

2. Business Benefits

  • 📈 Larger Audience: 15% of world population has disabilities (1+ billion people)
  • 💰 Market Advantage: Accessible sites reach more customers
  • 🔍 Better SEO: Semantic HTML and alt text improve search rankings
  • 📱 Improved UX: Benefits all users, not just those with disabilities
  • Performance: Accessible sites are often faster and more efficient

3. Ethical Responsibility

The web should be accessible to everyone regardless of ability. It's about inclusivity and equal access to information and services.

Semantic HTML - Foundation of Accessibility

Semantic HTML uses meaningful elements that describe their content. This is crucial for screen readers and assistive technologies.

✅ Good: Semantic Elements

<!-- Proper semantic structure -->
<header>
  <nav>
    <ul>
      <li><a href="/">Home</a></li>
      <li><a href="/about">About</a></li>
    </ul>
  </nav>
</header>

<main>
  <article>
    <h1>Article Title</h1>
    <p>Article content...</p>
  </article>
  
  <aside>
    <h2>Related Links</h2>
    <ul>
      <li><a href="#">Link 1</a></li>
    </ul>
  </aside>
</main>

<footer>
  <p>&copy; 2026 Company Name</p>
</footer>

❌ Bad: Non-semantic Elements

<!-- Don't use divs for everything -->
<div class="header">
  <div class="nav">
    <div class="menu-item">Home</div>
    <div class="menu-item">About</div>
  </div>
</div>

<div class="main">
  <div class="content">
    <span class="title">Article Title</span>
    <div>Article content...</div>
  </div>
</div>

Key Semantic Elements

  • <header> - Site/page header
  • <nav> - Navigation links
  • <main> - Main content (only one per page)
  • <article> - Self-contained content
  • <section> - Thematic grouping of content
  • <aside> - Sidebar or tangential content
  • <footer> - Site/page footer
  • <h1>-<h6> - Headings (proper hierarchy)
  • <button> - Interactive buttons
  • <a> - Links for navigation

ARIA - Accessible Rich Internet Applications

ARIA provides a way to make web content and applications more accessible when semantic HTML alone isn't enough. It's especially useful for dynamic content and complex UI components.

⚠️ First Rule of ARIA

"Don't use ARIA if you can use semantic HTML instead!"
Native HTML elements have built-in accessibility features. Only use ARIA when HTML can't provide the semantics you need.

ARIA Attributes Categories

1. ARIA Roles

Define what an element is or does

<!-- Landmark roles -->
<div role="banner">Site header</div>
<div role="navigation">Nav menu</div>
<div role="main">Main content</div>
<div role="complementary">Sidebar</div>
<div role="contentinfo">Footer</div>

<!-- Widget roles -->
<div role="button">Click me</div>
<div role="tab">Tab 1</div>
<div role="tabpanel">Tab content</div>
<div role="dialog">Modal content</div>
<div role="alert">Important message!</div>
<div role="progressbar" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"></div>

<!-- ⚠️ Better: Use semantic HTML when possible -->
<button>Click me</button> <!-- Instead of div role="button" -->
<nav>Nav menu</nav> <!-- Instead of div role="navigation" -->

2. ARIA States & Properties

Describe the current state or properties of elements

<!-- aria-label: Provides accessible name -->
<button aria-label="Close dialog">
  <span aria-hidden="true">×</span>
</button>

<!-- aria-labelledby: References another element for label -->
<h2 id="modal-title">Confirmation</h2>
<div role="dialog" aria-labelledby="modal-title">
  Are you sure?
</div>

<!-- aria-describedby: Provides additional description -->
<input 
  type="password" 
  aria-describedby="password-hint"
  id="password"
/>
<span id="password-hint">
  Password must be at least 8 characters
</span>

<!-- aria-expanded: Indicates expansion state -->
<button aria-expanded="false" aria-controls="menu">
  Menu
</button>
<ul id="menu" hidden>
  <li>Item 1</li>
</ul>

<!-- aria-hidden: Hides from screen readers -->
<span aria-hidden="true">🎉</span>
<span class="sr-only">Celebration icon</span>

<!-- aria-live: Announces dynamic content -->
<div aria-live="polite" role="status">
  Item added to cart
</div>

<!-- aria-current: Indicates current item -->
<nav>
  <a href="/" aria-current="page">Home</a>
  <a href="/about">About</a>
</nav>

<!-- aria-disabled: Indicates disabled state -->
<button aria-disabled="true">Submit</button>

<!-- aria-required: Indicates required field -->
<input type="text" aria-required="true" />

<!-- aria-invalid: Indicates validation error -->
<input 
  type="email" 
  aria-invalid="true" 
  aria-describedby="email-error"
/>
<span id="email-error">Please enter a valid email</span>

3. ARIA Live Regions

Announce dynamic content changes to screen readers

<!-- aria-live values -->
<div aria-live="off">Not announced</div>
<div aria-live="polite">Announced when user is idle</div>
<div aria-live="assertive">Announced immediately</div>

<!-- Status messages -->
<div role="status" aria-live="polite">
  Form saved successfully
</div>

<!-- Alerts -->
<div role="alert" aria-live="assertive">
  Error: Connection lost
</div>

<!-- Loading states -->
<div role="status" aria-live="polite" aria-busy="true">
  Loading content...
</div>

React Example with ARIA

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

export default function AccessibleModal() {
  const [isOpen, setIsOpen] = useState(false);
  const modalRef = useRef<HTMLDivElement>(null);
  const openButtonRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    if (isOpen) {
      // Focus modal when opened
      modalRef.current?.focus();
    } else {
      // Return focus to button when closed
      openButtonRef.current?.focus();
    }
  }, [isOpen]);

  useEffect(() => {
    if (!isOpen) return;

    // Trap focus inside modal
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        setIsOpen(false);
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen]);

  return (
    <>
      <button
        ref={openButtonRef}
        onClick={() => setIsOpen(true)}
        aria-haspopup="dialog"
      >
        Open Modal
      </button>

      {isOpen && (
        <>
          {/* Backdrop */}
          <div
            style={{
              position: 'fixed',
              inset: 0,
              background: 'rgba(0,0,0,0.5)',
            }}
            onClick={() => setIsOpen(false)}
            aria-hidden="true"
          />

          {/* Modal */}
          <div
            ref={modalRef}
            role="dialog"
            aria-modal="true"
            aria-labelledby="modal-title"
            aria-describedby="modal-description"
            tabIndex={-1}
            style={{
              position: 'fixed',
              top: '50%',
              left: '50%',
              transform: 'translate(-50%, -50%)',
              background: 'white',
              padding: '20px',
              borderRadius: '8px',
            }}
          >
            <h2 id="modal-title">Confirm Action</h2>
            <p id="modal-description">
              Are you sure you want to proceed?
            </p>
            <div>
              <button onClick={() => setIsOpen(false)}>
                Cancel
              </button>
              <button onClick={() => {
                // Handle confirm
                setIsOpen(false);
              }}>
                Confirm
              </button>
            </div>

            <button
              onClick={() => setIsOpen(false)}
              aria-label="Close dialog"
              style={{
                position: 'absolute',
                top: '10px',
                right: '10px',
              }}
            >
              <span aria-hidden="true">×</span>
            </button>
          </div>
        </>
      )}
    </>
  );
}

Screen Readers & Assistive Technologies

Screen readers are software programs that read the content of the screen aloud to users who are blind or have low vision. Understanding how they work is crucial for building accessible websites.

Popular Screen Readers

  • NVDA (NonVisual Desktop Access): Free, Windows (most popular)
  • JAWS (Job Access With Speech): Commercial, Windows (enterprise)
  • VoiceOver: Built-in, macOS and iOS
  • TalkBack: Built-in, Android
  • Narrator: Built-in, Windows

How Screen Readers Navigate

  • 📋 Landmarks: Jump between regions (header, nav, main, footer)
  • 📑 Headings: Navigate by heading levels (H1, H2, H3...)
  • 🔗 Links: List all links on page
  • 📝 Forms: Jump between form fields
  • 🖼️ Images: Read alt text
  • 📊 Tables: Navigate by rows/columns
  • 📍 Lists: Announce list items

Screen Reader Only Text (sr-only)

/* CSS for screen reader only text */
.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;
}

/* For focusable elements */
.sr-only-focusable:focus {
  position: static;
  width: auto;
  height: auto;
  padding: inherit;
  margin: inherit;
  overflow: visible;
  clip: auto;
  white-space: normal;
}
<!-- Usage examples -->
<button>
  <svg aria-hidden="true">...</svg>
  <span class="sr-only">Delete item</span>
</button>

<!-- Skip to main content link -->
<a href="#main-content" class="sr-only-focusable">
  Skip to main content
</a>
<main id="main-content">
  <!-- Content -->
</main>

<!-- Icon with accessible text -->
<span aria-hidden="true">🔍</span>
<span class="sr-only">Search</span>

Testing with Screen Readers

// VoiceOver on macOS
// Cmd + F5 to toggle

// Common commands:
// - Control + Option + Right Arrow: Read next item
// - Control + Option + Command + H: Next heading
// - Control + Option + U: Rotor (lists headings, links, etc.)

// NVDA on Windows (Free!)
// Install from nvaccess.org
// Common commands:
// - NVDA + Down Arrow: Read next item
// - H: Next heading
// - Insert + F7: Elements list

Best Practices for Screen Readers

  • ✅ Use semantic HTML elements
  • ✅ Provide meaningful alt text for images
  • ✅ Use proper heading hierarchy (don't skip levels)
  • ✅ Label form fields properly
  • ✅ Announce dynamic content changes (aria-live)
  • ✅ Provide skip links to main content
  • ❌ Don't use "click here" for link text
  • ❌ Don't rely only on visual cues (color, position)
  • ❌ Don't disable focus outlines without alternatives

Keyboard Navigation & Focus Management

Many users rely on keyboards instead of mice. All functionality must be accessible via keyboard alone.

Standard Keyboard Navigation

  • Tab - Move focus forward
  • Shift + Tab - Move focus backward
  • Enter - Activate buttons/links
  • Space - Activate buttons, toggle checkboxes
  • Arrow keys - Navigate within components (tabs, menus)
  • Escape - Close dialogs/menus
  • Home/End - Jump to start/end

Focus Indicators

/* ❌ Don't do this! */
* {
  outline: none;
}

/* ✅ Provide clear focus indicators */
:focus {
  outline: 2px solid #4A90E2;
  outline-offset: 2px;
}

/* ✅ Remove only when providing alternative */
:focus {
  outline: none;
  box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.5);
}

/* ✅ Use :focus-visible for keyboard-only focus */
:focus-visible {
  outline: 2px solid #4A90E2;
  outline-offset: 2px;
}

/* Match hover and focus styles */
button:hover,
button:focus-visible {
  background: #357ABD;
  transform: scale(1.05);
}

Tab Index

<!-- tabindex="0" - Natural tab order, makes element focusable -->
<div tabindex="0" role="button">Focusable div</div>

<!-- tabindex="-1" - Not in tab order, but programmatically focusable -->
<div tabindex="-1" id="error-message">Error details</div>

<!-- ❌ tabindex="1+" - Avoid positive values! They break natural order -->
<input tabindex="1" /> <!-- Don't do this! -->

<!-- ✅ Use semantic elements instead -->
<button>I'm naturally focusable</button>
<a href="#">I'm naturally focusable</a>
<input type="text" /> <!-- Naturally focusable -->

Focus Trap in Modals

import { useEffect, useRef } from 'react';

export default function FocusTrap({ children, isOpen }: any) {
  const containerRef = useRef<HTMLDivElement>(null);
  const firstFocusableRef = useRef<HTMLElement | null>(null);
  const lastFocusableRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (!isOpen) return;

    const container = containerRef.current;
    if (!container) return;

    // Get all focusable elements
    const focusableElements = container.querySelectorAll<HTMLElement>(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );

    firstFocusableRef.current = focusableElements[0];
    lastFocusableRef.current = focusableElements[focusableElements.length - 1];

    // Focus first element
    firstFocusableRef.current?.focus();

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;

      if (e.shiftKey) {
        // Shift + Tab
        if (document.activeElement === firstFocusableRef.current) {
          e.preventDefault();
          lastFocusableRef.current?.focus();
        }
      } else {
        // Tab
        if (document.activeElement === lastFocusableRef.current) {
          e.preventDefault();
          firstFocusableRef.current?.focus();
        }
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen]);

  return <div ref={containerRef}>{children}</div>;
}

Skip Links

<!-- Allows keyboard users to skip navigation -->
<a href="#main-content" class="skip-link">
  Skip to main content
</a>

<nav>
  <!-- Navigation items -->
</nav>

<main id="main-content" tabindex="-1">
  <!-- Main content -->
</main>

<style>
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: #000;
  color: #fff;
  padding: 8px;
  z-index: 100;
}

.skip-link:focus {
  top: 0;
}
</style>

Images & Alternative Text

All images must have alternative text that conveys the same information as the image for users who can't see it.

When to Use Alt Text

<!-- ✅ Informative images - Describe the content -->
<img 
  src="chart.png" 
  alt="Bar chart showing 50% increase in sales from 2024 to 2025"
/>

<!-- ✅ Functional images - Describe the function -->
<a href="/home">
  <img src="logo.png" alt="Company Name - Home" />
</a>

<button>
  <img src="search-icon.png" alt="Search" />
</button>

<!-- ✅ Decorative images - Empty alt -->
<img src="decorative-border.png" alt="" />
<!-- Or use CSS background images -->

<!-- ✅ Complex images - Provide longer description -->
<figure>
  <img 
    src="complex-diagram.png" 
    alt="Architecture diagram"
    aria-describedby="diagram-description"
  />
  <figcaption id="diagram-description">
    Detailed description of the system architecture...
  </figcaption>
</figure>

<!-- ❌ Don't do this -->
<img src="cat.jpg" alt="image" />
<img src="cat.jpg" alt="cat.jpg" />
<img src="cat.jpg" alt="Click here" />
<img src="cat.jpg" /> <!-- Missing alt! -->

<!-- ✅ Better -->
<img src="cat.jpg" alt="Orange tabby cat sleeping on a blue couch" />

React/Next.js Image Components

import Image from 'next/image';

// ✅ Always provide alt text
<Image
  src="/product.jpg"
  alt="Modern desk lamp with adjustable arm"
  width={300}
  height={400}
/>

// ✅ Decorative images
<Image
  src="/decorative.jpg"
  alt=""
  aria-hidden="true"
  width={300}
  height={400}
/>

// ✅ Background images for decoration
<div 
  style={{ backgroundImage: 'url(/decorative.jpg)' }}
  role="presentation"
>
  Content
</div>

Text in Images

  • ❌ Avoid text in images when possible
  • ✅ Use actual HTML text instead
  • ✅ If text is in image, include all text in alt attribute
  • ✅ Use SVG with embedded text for scalability

Accessible Forms

Forms are critical for user interaction and must be fully accessible to all users.

Form Labels & Instructions

<!-- ✅ Always label inputs -->
<label for="email">Email Address</label>
<input type="email" id="email" name="email" required />

<!-- ✅ Complex labels with descriptions -->
<label for="password">
  Password
  <span aria-describedby="password-help">(required)</span>
</label>
<input 
  type="password" 
  id="password" 
  name="password"
  aria-describedby="password-help"
  required
/>
<small id="password-help">
  Must be at least 8 characters with numbers and letters
</small>

<!-- ✅ Placeholder is NOT a label replacement -->
<label for="search">Search</label>
<input 
  type="search" 
  id="search" 
  placeholder="Enter keywords..."
/>

<!-- ❌ Don't do this -->
<input type="text" placeholder="Name" /> <!-- No label! -->

<!-- ✅ Group related fields -->
<fieldset>
  <legend>Shipping Address</legend>
  <label for="street">Street</label>
  <input type="text" id="street" />
  
  <label for="city">City</label>
  <input type="text" id="city" />
</fieldset>

<!-- ✅ Radio buttons and checkboxes -->
<fieldset>
  <legend>Choose your subscription:</legend>
  
  <input type="radio" id="free" name="plan" value="free" />
  <label for="free">Free Plan</label>
  
  <input type="radio" id="pro" name="plan" value="pro" />
  <label for="pro">Pro Plan</label>
</fieldset>

Error Handling

<!-- Error summary at top -->
<div role="alert" aria-live="assertive">
  <h2>There are 2 errors in this form:</h2>
  <ul>
    <li><a href="#email">Email is required</a></li>
    <li><a href="#password">Password is too short</a></li>
  </ul>
</div>

<!-- Individual field errors -->
<label for="email">Email Address</label>
<input 
  type="email" 
  id="email"
  aria-invalid="true"
  aria-describedby="email-error"
/>
<span id="email-error" role="alert">
  Please enter a valid email address
</span>

<style>
/* Visual error styling */
input[aria-invalid="true"] {
  border-color: #f38ba8;
}

[role="alert"] {
  color: #f38ba8;
  font-weight: 600;
}
</style>

React Form Example

import { useState } from 'react';

export default function AccessibleForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [touched, setTouched] = useState<Record<string, boolean>>({});

  const validate = (name: string, value: string) => {
    if (name === 'email' && !value.includes('@')) {
      return 'Please enter a valid email';
    }
    if (name === 'password' && value.length < 8) {
      return 'Password must be at least 8 characters';
    }
    return '';
  };

  const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setTouched({ ...touched, [name]: true });
    const error = validate(name, value);
    setErrors({ ...errors, [name]: error });
  };

  return (
    <form>
      {/* Error summary */}
      {Object.keys(errors).some(key => errors[key]) && (
        <div role="alert" aria-live="polite">
          <h2>Please fix the following errors:</h2>
          <ul>
            {Object.entries(errors).map(([field, error]) => 
              error ? (
                <li key={field}>
                  <a href={`#${field}`}>{error}</a>
                </li>
              ) : null
            )}
          </ul>
        </div>
      )}

      {/* Email field */}
      <div>
        <label htmlFor="email">
          Email Address
          <span aria-label="required">*</span>
        </label>
        <input
          type="email"
          id="email"
          name="email"
          required
          aria-required="true"
          aria-invalid={touched.email && !!errors.email}
          aria-describedby={errors.email ? "email-error" : undefined}
          onBlur={handleBlur}
        />
        {touched.email && errors.email && (
          <span id="email-error" role="alert">
            {errors.email}
          </span>
        )}
      </div>

      {/* Password field */}
      <div>
        <label htmlFor="password">
          Password
          <span aria-label="required">*</span>
        </label>
        <input
          type="password"
          id="password"
          name="password"
          required
          aria-required="true"
          aria-invalid={touched.password && !!errors.password}
          aria-describedby="password-help password-error"
          onBlur={handleBlur}
        />
        <small id="password-help">
          Must be at least 8 characters
        </small>
        {touched.password && errors.password && (
          <span id="password-error" role="alert">
            {errors.password}
          </span>
        )}
      </div>

      <button type="submit">Submit</button>
    </form>
  );
}

Color & Contrast

Proper color contrast ensures text is readable for users with low vision or color blindness.

WCAG Contrast Requirements

  • Level AA (Minimum):
    • 4.5:1 for normal text (under 18px or under 14px bold)
    • 3:1 for large text (18px+ or 14px+ bold)
    • 3:1 for UI components and graphics
  • Level AAA (Enhanced):
    • 7:1 for normal text
    • 4.5:1 for large text

Testing Contrast

/* ✅ Good contrast (4.5:1+) */
.text {
  color: #000000; /* Black */
  background: #FFFFFF; /* White */
  /* Contrast ratio: 21:1 */
}

.button {
  color: #FFFFFF; /* White */
  background: #0066CC; /* Blue */
  /* Contrast ratio: 4.55:1 */
}

/* ❌ Poor contrast (below 4.5:1) */
.bad-text {
  color: #777777; /* Light gray */
  background: #FFFFFF; /* White */
  /* Contrast ratio: 4.4:1 - Not enough! */
}

/* Tools to check contrast: */
// - WebAIM Contrast Checker: webaim.org/resources/contrastchecker/
// - Chrome DevTools: Inspect element > Accessibility panel
// - Browser extension: "WAVE" or "axe DevTools"

Don't Rely on Color Alone

<!-- ❌ Color only -->
<span style="color: red;">Required field</span>

<!-- ✅ Color + icon + text -->
<span style="color: #f38ba8;">
  <span aria-hidden="true">*</span>
  <span class="sr-only">Required: </span>
  Required field
</span>

<!-- ❌ Color-coded status -->
<div style="background: green;">Success</div>
<div style="background: red;">Error</div>

<!-- ✅ Color + icon + text -->
<div style="background: #a6e3a1;">
  <span aria-hidden="true"></span>
  Success
</div>
<div style="background: #f38ba8;">
  <span aria-hidden="true"></span>
  Error
</div>

Focus Indicators

/* ✅ Visible focus with sufficient contrast */
:focus-visible {
  outline: 2px solid #0066CC;
  outline-offset: 2px;
}

/* ✅ High contrast mode compatible */
@media (prefers-contrast: high) {
  :focus-visible {
    outline-width: 3px;
  }
}

Responsive & Mobile Accessibility

Touch Targets

Make interactive elements large enough to tap easily.

/* ✅ WCAG 2.1: Minimum 44x44 CSS pixels */
button, 
a, 
input[type="checkbox"],
input[type="radio"] {
  min-width: 44px;
  min-height: 44px;
  /* Or use padding to reach 44px */
  padding: 12px 16px;
}

/* ✅ Spacing between touch targets */
.button-group button {
  margin: 8px;
}

/* ❌ Too small */
.icon-button {
  width: 20px;
  height: 20px; /* Not accessible! */
}

Viewport & Zoom

<!-- ✅ Allow zoom -->
<meta 
  name="viewport" 
  content="width=device-width, initial-scale=1"
/>

<!-- ❌ Don't disable zoom -->
<meta 
  name="viewport" 
  content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/>

/* ✅ Use relative units for text */
body {
  font-size: 16px; /* Base */
}

h1 {
  font-size: 2rem; /* Scales with user preferences */
}

/* ❌ Avoid fixed pixel sizes for text */
p {
  font-size: 12px; /* Doesn't scale! */
}

Orientation

/* ✅ Support both portrait and landscape */
@media (orientation: landscape) {
  .container {
    flex-direction: row;
  }
}

@media (orientation: portrait) {
  .container {
    flex-direction: column;
  }
}

/* Don't force orientation */
/* Let users choose! */

Testing Tools & Resources

Browser DevTools

  • Chrome DevTools:
    • Lighthouse (Accessibility audit)
    • Accessibility panel (inspect element)
    • Contrast ratio checker
  • Firefox DevTools:
    • Accessibility Inspector
    • Check for issues panel

Browser Extensions

  • axe DevTools: Comprehensive accessibility testing
  • WAVE: Visual feedback about accessibility
  • Lighthouse: Built into Chrome DevTools
  • NVDA: Free screen reader for Windows

Automated Testing Libraries

// Install jest-axe
npm install --save-dev jest-axe @axe-core/react

// Test with jest-axe
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

test('should have no accessibility violations', async () => {
  const { container } = render(<MyComponent />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

// Cypress accessibility testing
npm install --save-dev cypress-axe

// cypress/e2e/accessibility.cy.ts
describe('Accessibility', () => {
  it('has no detectable a11y violations', () => {
    cy.visit('/');
    cy.injectAxe();
    cy.checkA11y();
  });
});

Manual Testing Checklist

  • ✅ Navigate entire site with keyboard only (Tab, Shift+Tab, Enter, Space)
  • ✅ Test with screen reader (NVDA, VoiceOver, JAWS)
  • ✅ Zoom to 200% and verify layout doesn't break
  • ✅ Test in high contrast mode
  • ✅ Verify focus indicators are visible
  • ✅ Check color contrast ratios
  • ✅ Disable CSS and verify content makes sense
  • ✅ Test on mobile devices
  • ✅ Verify all images have alt text
  • ✅ Check form labels and error messages

Resources

  • 📚 WCAG Guidelines: w3.org/WAI/WCAG21/quickref/
  • 📚 MDN Web Accessibility: developer.mozilla.org/en-US/docs/Web/Accessibility
  • 📚 WebAIM: webaim.org
  • 📚 A11y Project: a11yproject.com
  • 📚 ARIA Authoring Practices: w3.org/WAI/ARIA/apg/

Quick Reference Checklist

Essential Accessibility Checklist

✅ HTML Structure

  • Use semantic HTML elements (<header>, <nav>, <main>, etc.)
  • Proper heading hierarchy (H1 → H2 → H3, don't skip)
  • One <main> element per page
  • Landmarks for page regions

✅ Images

  • All images have alt text
  • Decorative images use alt=""
  • Complex images have detailed descriptions

✅ Forms

  • All inputs have labels
  • Required fields marked with required or aria-required
  • Errors announced and linked to fields
  • Instructions provided before form

✅ Keyboard

  • All functionality accessible via keyboard
  • Visible focus indicators
  • Logical tab order
  • Skip links to main content
  • Modal focus trap

✅ ARIA

  • Use semantic HTML first
  • ARIA roles for custom components
  • ARIA states updated dynamically
  • Live regions for announcements

✅ Color & Contrast

  • Text has 4.5:1 contrast minimum
  • Don't rely on color alone
  • Focus indicators have 3:1 contrast

✅ Mobile

  • Touch targets at least 44x44px
  • Don't disable zoom
  • Support both orientations

✅ Testing

  • Automated testing (axe, Lighthouse)
  • Keyboard navigation testing
  • Screen reader testing
  • Zoom to 200%

Common Accessible Component Patterns

Accessible Button

// Use native button when possible
<button onClick={handleClick}>
  Click Me
</button>

// Icon button with label
<button aria-label="Close">
  <span aria-hidden="true">×</span>
</button>

// Icon + text
<button>
  <svg aria-hidden="true">...</svg>
  <span>Delete</span>
</button>

// If you must use div (avoid!)
<div
  role="button"
  tabIndex={0}
  onClick={handleClick}
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      handleClick();
    }
  }}
>
  Click Me
</div>

Accessible Tabs

export default function AccessibleTabs() {
  const [activeTab, setActiveTab] = useState(0);
  const tabs = ['Tab 1', 'Tab 2', 'Tab 3'];

  return (
    <div>
      <div role="tablist" aria-label="Example tabs">
        {tabs.map((tab, index) => (
          <button
            key={index}
            role="tab"
            aria-selected={activeTab === index}
            aria-controls={`panel-${index}`}
            id={`tab-${index}`}
            tabIndex={activeTab === index ? 0 : -1}
            onClick={() => setActiveTab(index)}
            onKeyDown={(e) => {
              if (e.key === 'ArrowRight') {
                setActiveTab((activeTab + 1) % tabs.length);
              } else if (e.key === 'ArrowLeft') {
                setActiveTab((activeTab - 1 + tabs.length) % tabs.length);
              }
            }}
          >
            {tab}
          </button>
        ))}
      </div>

      {tabs.map((tab, index) => (
        <div
          key={index}
          role="tabpanel"
          id={`panel-${index}`}
          aria-labelledby={`tab-${index}`}
          hidden={activeTab !== index}
          tabIndex={0}
        >
          Content for {tab}
        </div>
      ))}
    </div>
  );
}

Accessible Dropdown Menu

export default function AccessibleDropdown() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button
        aria-haspopup="true"
        aria-expanded={isOpen}
        onClick={() => setIsOpen(!isOpen)}
      >
        Menu
      </button>

      {isOpen && (
        <ul role="menu">
          <li role="none">
            <button role="menuitem" onClick={() => {}}>
              Option 1
            </button>
          </li>
          <li role="none">
            <button role="menuitem" onClick={() => {}}>
              Option 2
            </button>
          </li>
        </ul>
      )}
    </div>
  );
}

Accessible Loading State

export default function LoadingButton() {
  const [isLoading, setIsLoading] = useState(false);

  return (
    <button
      disabled={isLoading}
      aria-busy={isLoading}
      onClick={async () => {
        setIsLoading(true);
        await fetch('/api/endpoint');
        setIsLoading(false);
      }}
    >
      {isLoading ? (
        <>
          <span aria-hidden="true"></span>
          <span className="sr-only">Loading...</span>
        </>
      ) : (
        'Submit'
      )}
    </button>
  );
}