frontend-web-accessibility

Building accessible web applications

Web Accessibility

Scope: WCAG 2.1 AA, ARIA, keyboard navigation, screen readers, color contrast Lines: ~300 Last Updated: 2025-10-18

When to Use This Skill

Activate this skill when:

Core Concepts

WCAG Principles (POUR)

Perceivable - Information must be presentable to users Operable - UI must be operable by all users Understandable - Information must be understandable Robust - Content must work across technologies

Conformance Levels

Level A - Minimum (basic accessibility) Level AA - Recommended (target for most sites) Level AAA - Enhanced (not always achievable)

Target: WCAG 2.1 AA for production applications.


Semantic HTML

Use Correct Elements

// ❌ Bad: Divs for everything
<div onClick={handleClick}>Click me</div>
<div>Important message</div>

// ✅ Good: Semantic elements
<button onClick={handleClick}>Click me</button>
<main>Important message</main>

Document Structure

<body>
  <header>
    <nav>
      <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/about">About</a></li>
      </ul>
    </nav>
  </header>

  <main>
    <article>
      <h1>Page Title</h1>
      <section>
        <h2>Section Title</h2>
        <p>Content...</p>
      </section>
    </article>

    <aside>
      <h2>Related Links</h2>
      <ul>...</ul>
    </aside>
  </main>

  <footer>
    <p>&copy; 2025 Company</p>
  </footer>
</body>

Heading Hierarchy

// ❌ Bad: Skipping levels
<h1>Page Title</h1>
<h3>Subsection</h3>
<h5>Detail</h5>

// ✅ Good: Proper hierarchy
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>

Rules:


ARIA (Accessible Rich Internet Applications)

When to Use ARIA

First Rule of ARIA: Don't use ARIA if you can use native HTML.

// ❌ Bad: ARIA on native element
<div role="button" tabIndex={0} onClick={...}>Click</div>

// ✅ Good: Native element
<button onClick={...}>Click</button>

Common ARIA Attributes

aria-label - Accessible name

<button aria-label="Close dialog">
  <XIcon />
</button>

aria-labelledby - Reference to label

<div role="dialog" aria-labelledby="dialog-title">
  <h2 id="dialog-title">Confirm Delete</h2>
  ...
</div>

aria-describedby - Additional description

<input
  type="password"
  aria-describedby="password-hint"
/>
<p id="password-hint">Must be at least 8 characters</p>

aria-live - Announce dynamic content

<div aria-live="polite" aria-atomic="true">
  {status}
</div>

aria-expanded - Disclosure state

<button
  aria-expanded={isOpen}
  aria-controls="dropdown-menu"
  onClick={() => setIsOpen(!isOpen)}
>
  Menu
</button>
<ul id="dropdown-menu" hidden={!isOpen}>
  <li>Item 1</li>
  <li>Item 2</li>
</ul>

aria-hidden - Hide from screen readers

<span aria-hidden="true">★</span>
<span className="sr-only">5 stars</span>

ARIA Roles

// Navigation landmark
<nav role="navigation">...</nav>

// Search landmark
<form role="search">...</form>

// Alert (announces to screen reader)
<div role="alert">Error: Form submission failed</div>

// Tab interface
<div role="tablist">
  <button role="tab" aria-selected={true}>Tab 1</button>
  <button role="tab" aria-selected={false}>Tab 2</button>
</div>
<div role="tabpanel">Content</div>

Keyboard Navigation

Focus Management

Tab Order - Follow DOM order

// ❌ Bad: Breaking tab order with CSS
<div style={{ display: 'flex', flexDirection: 'column-reverse' }}>
  <button>Second (visually first)</button>
  <button>First (visually second)</button>
</div>

// ✅ Good: DOM order matches visual order
<div style={{ display: 'flex', flexDirection: 'column' }}>
  <button>First</button>
  <button>Second</button>
</div>

tabIndex

// 0 = Natural tab order
<div tabIndex={0}>Focusable</div>

// -1 = Programmatically focusable (not in tab order)
<div tabIndex={-1}>Not in tab order</div>

// Positive numbers = Custom tab order (avoid!)

Keyboard Event Handlers

function KeyboardAccessibleButton({ onClick, children }: {
  onClick: () => void;
  children: React.ReactNode;
}) {
  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      onClick();
    }
  };

  return (
    <div
      role="button"
      tabIndex={0}
      onClick={onClick}
      onKeyDown={handleKeyDown}
    >
      {children}
    </div>
  );
}

// ✅ Better: Just use <button>
<button onClick={onClick}>{children}</button>

Focus Trap (Modals)

import { useEffect, useRef } from 'react';

function Modal({ isOpen, onClose, children }: {
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
}) {
  const dialogRef = useRef<HTMLDivElement>(null);

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

    const dialog = dialogRef.current;
    if (!dialog) return;

    // Focus first focusable element
    const focusableElements = dialog.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const firstElement = focusableElements[0] as HTMLElement;
    const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;

    firstElement?.focus();

    // Trap focus inside modal
    const handleTabKey = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;

      if (e.shiftKey) {
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement?.focus();
        }
      } else {
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement?.focus();
        }
      }
    };

    // Close on Escape
    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };

    dialog.addEventListener('keydown', handleTabKey);
    dialog.addEventListener('keydown', handleEscape);

    return () => {
      dialog.removeEventListener('keydown', handleTabKey);
      dialog.removeEventListener('keydown', handleEscape);
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <div
      ref={dialogRef}
      role="dialog"
      aria-modal="true"
      aria-labelledby="dialog-title"
    >
      <h2 id="dialog-title">Dialog Title</h2>
      {children}
    </div>
  );
}

Screen Reader Support

Hidden Text for Screen Readers

// CSS approach
<style>
  .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;
  }
</style>

<button>
  <XIcon aria-hidden="true" />
  <span className="sr-only">Close</span>
</button>

Live Regions

function Toast({ message }: { message: string }) {
  return (
    <div
      role="status"
      aria-live="polite"
      aria-atomic="true"
    >
      {message}
    </div>
  );
}

function ErrorAlert({ error }: { error: string }) {
  return (
    <div
      role="alert"
      aria-live="assertive"
      aria-atomic="true"
    >
      {error}
    </div>
  );
}

aria-live values:

Form Labels

// ❌ Bad: No label
<input type="text" placeholder="Email" />

// ✅ Good: Explicit label
<label htmlFor="email">Email</label>
<input id="email" type="email" />

// ✅ Good: Implicit label
<label>
  Email
  <input type="email" />
</label>

// ✅ Good: aria-label (when visual label not desired)
<input type="search" aria-label="Search products" />

Color and Contrast

Contrast Ratios (WCAG AA)

Normal text (< 18pt): 4.5:1 minimum Large text (≥ 18pt or 14pt bold): 3:1 minimum

// ❌ Bad: Insufficient contrast
<div style={{ color: '#999', backgroundColor: '#fff' }}>
  Text (2.8:1 - fails AA)
</div>

// ✅ Good: Sufficient contrast
<div style={{ color: '#767676', backgroundColor: '#fff' }}>
  Text (4.5:1 - passes AA)
</div>

Tools:

Don't Rely on Color Alone

// ❌ Bad: Color only
<span style={{ color: 'red' }}>Error</span>

// ✅ Good: Color + icon + text
<span style={{ color: 'red' }}>
  <ErrorIcon aria-hidden="true" />
  Error: Field is required
</span>

Images and Media

Alt Text

// ❌ Bad: Missing alt
<img src="logo.png" />

// ❌ Bad: Redundant alt
<img src="photo.jpg" alt="Image of a photo" />

// ✅ Good: Descriptive alt
<img src="logo.png" alt="Company Logo" />

// ✅ Good: Decorative image
<img src="decoration.png" alt="" />

Alt text guidelines:

Video Captions

<video controls>
  <source src="video.mp4" type="video/mp4" />
  <track
    kind="captions"
    src="captions.vtt"
    srcLang="en"
    label="English"
    default
  />
</video>

Common Accessible Components

Accessible Button

<button
  type="button"
  onClick={handleClick}
  disabled={isDisabled}
  aria-label="Close dialog"
>
  <XIcon aria-hidden="true" />
</button>

Accessible Link

// ❌ Bad: Non-descriptive link
<a href="/report.pdf">Click here</a>

// ✅ Good: Descriptive link
<a href="/report.pdf">Download 2025 Annual Report (PDF, 2MB)</a>

// ✅ Good: External link
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
  Visit Example
  <span className="sr-only">(opens in new tab)</span>
</a>

Accessible Dropdown

import { useState } from 'react';

function Dropdown({ label, items }: {
  label: string;
  items: Array<{ id: string; label: string; onClick: () => void }>;
}) {
  const [isOpen, setIsOpen] = useState(false);

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

      {isOpen && (
        <ul role="menu">
          {items.map(item => (
            <li key={item.id} role="none">
              <button
                role="menuitem"
                onClick={() => {
                  item.onClick();
                  setIsOpen(false);
                }}
              >
                {item.label}
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Testing Accessibility

Automated Testing

// Jest + Testing Library
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();
});

Manual Testing

Keyboard navigation:

  1. Tab through all interactive elements
  2. Shift+Tab to go backward
  3. Enter/Space to activate
  4. Arrow keys for custom widgets

Screen reader testing:

Browser DevTools:


Quick Reference

Accessibility Checklist

[ ] Semantic HTML (header, nav, main, footer)
[ ] Heading hierarchy (h1 → h6, no skipping)
[ ] Alt text for images
[ ] Labels for form inputs
[ ] Keyboard navigation (Tab, Enter, Space, Esc)
[ ] Focus indicators (visible outline)
[ ] Color contrast (4.5:1 for text)
[ ] ARIA labels for icon buttons
[ ] aria-live for dynamic content
[ ] Focus trap in modals
[ ] Skip navigation link
[ ] No reliance on color alone
[ ] Descriptive link text
[ ] Captions for video

Common ARIA Patterns

// Button
<button aria-label="Close">×</button>

// Toggle
<button aria-pressed={isActive}>Toggle</button>

// Expandable
<button aria-expanded={isOpen} aria-controls="content">Expand</button>

// Live region
<div aria-live="polite" role="status">{message}</div>

// Dialog
<div role="dialog" aria-modal="true" aria-labelledby="title">

// Tab
<div role="tablist">
  <button role="tab" aria-selected={true}>Tab 1</button>
</div>
<div role="tabpanel">Content</div>

Common Anti-Patterns

Div/span as button: Use <button> ✅ Native semantics

No focus indicators: Users can't see where they are ✅ Visible focus styles

Positive tabIndex: Breaks natural tab order ✅ Use 0 or -1 only

Color-only indicators: Not accessible to colorblind ✅ Use color + icon/text

Missing alt text: Screen readers can't describe images ✅ Descriptive alt text

Auto-playing media: Disorienting, annoying ✅ User-controlled playback


Related Skills


Level 3: Resources

Location: skills/frontend/web-accessibility/resources/

REFERENCE.md

Comprehensive 1,900+ line reference covering:

Scripts

check_accessibility.py (Python, executable)

analyze_aria.py (Python, executable)

test_keyboard_nav.js (Node.js/Puppeteer, executable)

Examples

react/accessible-modal.tsx

react/accessible-form.tsx

react/accessible-dropdown.tsx

html/semantic-examples.html

css/focus-visible.css


Last Updated: 2025-10-27 Format Version: 1.0 (Atomic)