Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Accessibility Deep-Dive

Comprehensive accessibility guidance for web applications. Semantic HTML first, ARIA as enhancement, keyboard-first interaction model.

Accessibility is not optional. It’s not a feature. It’s not “nice to have.” It’s a requirement for professional software.

Mindset: Use /pb-preamble thinking to challenge “works for me” assumptions. Use /pb-design-rules thinking - especially Clarity (is the interface obvious to ALL users?), Robustness (does it work with assistive technology?), and Repair (fail accessibly when things break).

Resource Hint: sonnet - accessibility audit follows structured WCAG checklists and component patterns


When to Use

  • Building new UI components or pages
  • Pre-release accessibility compliance check
  • After receiving accessibility-related bug reports or user feedback
  • Periodic audit of existing web application

Philosophy

Semantic HTML First

ARIA is a repair tool, not a feature. If you need ARIA, ask first: “Can I use semantic HTML instead?”

<!-- [NO] div with ARIA (repairing bad markup) -->
<div role="button" tabindex="0" aria-pressed="false" onclick="toggle()">
  Toggle
</div>

<!-- [YES] Semantic HTML (needs no repair) -->
<button type="button" aria-pressed="false" onclick="toggle()">
  Toggle
</button>

The first rule of ARIA: Don’t use ARIA if you can use semantic HTML.

The second rule of ARIA: If you must use ARIA, use it correctly.

Keyboard-First Interaction

Every interaction must work without a mouse:

  • Tab navigates between focusable elements
  • Enter/Space activates buttons and links
  • Arrow keys navigate within widgets (tabs, menus, sliders)
  • Escape closes modals and dismisses overlays
  • Focus is always visible

If an interaction only works on hover or click, it’s broken.

Progressive Enhancement

Build the accessible version first, then enhance:

<!-- Base: Works without JavaScript -->
<a href="/products">View Products</a>

<!-- Enhanced: Better UX with JavaScript -->
<a href="/products" onclick="openModal(event)">View Products</a>

If JavaScript fails, the link still works.


Semantic Structure

Document Landmarks

Use HTML5 landmarks for page structure:

<body>
  <header role="banner">
    <!-- Site header, logo, primary nav -->
  </header>

  <nav role="navigation" aria-label="Main">
    <!-- Primary navigation -->
  </nav>

  <main role="main">
    <!-- Primary content -->
  </main>

  <aside role="complementary">
    <!-- Related content, sidebar -->
  </aside>

  <footer role="contentinfo">
    <!-- Site footer -->
  </footer>
</body>

Note: Modern browsers understand <header>, <main>, etc. The role attributes are for older assistive technology.

Heading Hierarchy

Headings create an outline. Don’t skip levels.

<!-- [NO] Skipped levels, style-driven -->
<h1>Page Title</h1>
<h4>Section Title</h4>  <!-- Skipped h2, h3 -->
<h2>Another Section</h2>

<!-- [YES] Logical hierarchy -->
<h1>Page Title</h1>
<h2>Section Title</h2>
<h3>Subsection</h3>
<h2>Another Section</h2>

Use CSS for styling, headings for structure.

Lists

Use lists for groups of related items:

<!-- Navigation is a list of links -->
<nav aria-label="Main">
  <ul>
    <li><a href="/">Home</a></li>
    <li><a href="/products">Products</a></li>
    <li><a href="/about">About</a></li>
  </ul>
</nav>

<!-- Steps are an ordered list -->
<ol>
  <li>Add items to cart</li>
  <li>Enter shipping address</li>
  <li>Complete payment</li>
</ol>

Screen readers announce “list of 3 items” - helpful context.

Tables

Use tables for tabular data, not layout:

<table>
  <caption>Monthly Sales Report</caption>
  <thead>
    <tr>
      <th scope="col">Month</th>
      <th scope="col">Revenue</th>
      <th scope="col">Growth</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">January</th>
      <td>$10,000</td>
      <td>+5%</td>
    </tr>
  </tbody>
</table>
  • <caption> describes the table
  • scope="col" and scope="row" associate headers with cells

Interactive Elements

Links navigate to a new location:

<!-- Goes somewhere -->
<a href="/products">View Products</a>
<a href="#section">Jump to Section</a>

Buttons perform an action:

<!-- Does something -->
<button type="button" onclick="openModal()">Open Modal</button>
<button type="submit">Submit Form</button>
<!-- [NO] Link that acts like a button -->
<a href="#" onclick="doSomething(); return false;">Do Something</a>

<!-- [YES] Button for actions -->
<button type="button" onclick="doSomething()">Do Something</button>

Form Controls

Proper form markup:

<form>
  <!-- Text input with visible label -->
  <div>
    <label for="email">Email address</label>
    <input
      type="email"
      id="email"
      name="email"
      required
      aria-describedby="email-hint email-error"
    />
    <p id="email-hint">We'll never share your email.</p>
    <p id="email-error" role="alert" hidden>Please enter a valid email.</p>
  </div>

  <!-- Checkbox -->
  <div>
    <input type="checkbox" id="terms" name="terms" required />
    <label for="terms">I agree to the terms and conditions</label>
  </div>

  <!-- Radio group -->
  <fieldset>
    <legend>Preferred contact method</legend>
    <div>
      <input type="radio" id="contact-email" name="contact" value="email" />
      <label for="contact-email">Email</label>
    </div>
    <div>
      <input type="radio" id="contact-phone" name="contact" value="phone" />
      <label for="contact-phone">Phone</label>
    </div>
  </fieldset>

  <button type="submit">Subscribe</button>
</form>

Key patterns:

  • Every input has a <label> with matching for/id
  • Related inputs grouped in <fieldset> with <legend>
  • Error messages linked via aria-describedby
  • Errors announced via role="alert"

Custom Widgets

When semantic HTML isn’t enough, build accessible widgets:

Tabs:

<div class="tabs">
  <div role="tablist" aria-label="Product information">
    <button
      role="tab"
      id="tab-1"
      aria-selected="true"
      aria-controls="panel-1"
    >
      Description
    </button>
    <button
      role="tab"
      id="tab-2"
      aria-selected="false"
      aria-controls="panel-2"
      tabindex="-1"
    >
      Reviews
    </button>
  </div>

  <div
    role="tabpanel"
    id="panel-1"
    aria-labelledby="tab-1"
  >
    <!-- Description content -->
  </div>

  <div
    role="tabpanel"
    id="panel-2"
    aria-labelledby="tab-2"
    hidden
  >
    <!-- Reviews content -->
  </div>
</div>

Keyboard behavior:

  • Tab to tablist, then arrow keys between tabs
  • Selected tab has tabindex="0", others have tabindex="-1"
  • Enter/Space activates tab

Modal Dialog:

<div
  role="dialog"
  aria-modal="true"
  aria-labelledby="modal-title"
  aria-describedby="modal-desc"
>
  <h2 id="modal-title">Confirm Delete</h2>
  <p id="modal-desc">Are you sure you want to delete this item?</p>

  <div>
    <button type="button" onclick="closeModal()">Cancel</button>
    <button type="button" onclick="confirmDelete()">Delete</button>
  </div>
</div>

Requirements:

  • Focus trapped inside modal while open
  • Escape closes modal
  • Focus returns to trigger element on close
  • Background content has aria-hidden="true" and inert

Focus Management

Focus Order

Focus order should follow visual order (usually left-to-right, top-to-bottom in LTR languages).

<!-- [NO] tabindex messing with order -->
<button tabindex="3">Third</button>
<button tabindex="1">First</button>
<button tabindex="2">Second</button>

<!-- [YES] Natural DOM order -->
<button>First</button>
<button>Second</button>
<button>Third</button>

Only use tabindex:

  • tabindex="0" - Add to focus order (for custom focusable elements)
  • tabindex="-1" - Remove from focus order (but focusable via JavaScript)

Never use tabindex > 0.

Focus Visibility

Focus must ALWAYS be visible:

/* [NO] Removing focus indicator */
*:focus {
  outline: none;
}

/* [YES] Custom focus indicator */
*:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

/* Works in both light and dark modes */
*:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px var(--color-surface);
}

Focus Trapping

For modals and dialogs, trap focus inside:

function trapFocus(element) {
  const focusableElements = element.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const firstFocusable = focusableElements[0];
  const lastFocusable = focusableElements[focusableElements.length - 1];

  element.addEventListener('keydown', (e) => {
    if (e.key !== 'Tab') return;

    if (e.shiftKey) {
      if (document.activeElement === firstFocusable) {
        lastFocusable.focus();
        e.preventDefault();
      }
    } else {
      if (document.activeElement === lastFocusable) {
        firstFocusable.focus();
        e.preventDefault();
      }
    }
  });
}

Allow keyboard users to skip repetitive navigation:

<body>
  <a href="#main-content" class="skip-link">Skip to main content</a>

  <header><!-- Navigation --></header>

  <main id="main-content" tabindex="-1">
    <!-- Main content -->
  </main>
</body>
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  padding: 8px;
  background: var(--color-primary);
  color: var(--color-on-primary);
  z-index: 100;
}

.skip-link:focus {
  top: 0;
}

Screen Reader Support

Labels and Descriptions

Every interactive element needs a label:

<!-- Visible label (preferred) -->
<label for="search">Search</label>
<input type="search" id="search" />

<!-- Hidden label (when visual label exists elsewhere) -->
<input type="search" aria-label="Search products" />

<!-- Icon-only button -->
<button type="button" aria-label="Close">
  <svg aria-hidden="true"><!-- X icon --></svg>
</button>

<!-- Additional description -->
<input
  type="password"
  aria-label="Password"
  aria-describedby="password-requirements"
/>
<p id="password-requirements">Must be at least 8 characters.</p>

Live Regions

Announce dynamic content changes:

<!-- Polite: Announced after current speech -->
<div aria-live="polite" aria-atomic="true">
  3 items in cart
</div>

<!-- Assertive: Interrupts current speech (use sparingly) -->
<div role="alert">
  Error: Payment failed. Please try again.
</div>

<!-- Status: For status messages -->
<div role="status">
  Saving...
</div>

Hiding Content

Hide from everyone:

<div hidden>Not rendered at all</div>
<div style="display: none;">Not rendered at all</div>

Hide visually but keep accessible:

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
<button>
  <svg aria-hidden="true"><!-- icon --></svg>
  <span class="visually-hidden">Close menu</span>
</button>

Hide from screen readers only:

<span aria-hidden="true">★★★☆☆</span>
<span class="visually-hidden">3 out of 5 stars</span>

Standards

WCAG 2.1 AA Baseline

This playbook targets WCAG 2.1 Level AA as the baseline. All guidance assumes AA compliance unless noted otherwise.

Why 2.1 AA:

  • Industry standard for most organizations
  • Legal requirement in many jurisdictions (ADA, Section 508, EN 301 549)
  • Achievable without significant design constraints
  • Covers vast majority of accessibility needs

WCAG 2.2 Enhancements (Recommended):

CriterionWhatWhen to Implement
2.4.11 Focus Not ObscuredFocused element not hiddenNew projects
2.5.7 Dragging MovementsAlternative to drag operationsTouch interfaces
2.5.8 Target Size (Minimum)24x24px targetsAll projects
3.2.6 Consistent HelpHelp in consistent locationComplex apps
3.3.7 Redundant EntryDon’t re-request same infoMulti-step forms
3.3.8 Accessible AuthenticationNo cognitive tests for authAll auth flows

Implement 2.2 criteria in new projects. Retrofit existing projects during major updates.


Color and Contrast

WCAG Contrast Requirements

ElementRatio RequiredLevel
Normal text4.5:1AA
Large text (18px+ bold, 24px+)3:1AA
UI components, graphics3:1AA
Normal text7:1AAA

Tools:

  • WebAIM Contrast Checker
  • Chrome DevTools (inspect > color picker shows ratio)
  • Figma plugins (Stark, A11y)

Color Not Sole Indicator

Don’t rely on color alone:

<!-- [NO] Only color indicates error -->
<input type="email" class="error" />  <!-- Red border -->

<!-- [YES] Color + icon + text -->
<input type="email" class="error" aria-invalid="true" aria-describedby="email-error" />
<p id="email-error">
  <svg aria-hidden="true"><!-- Error icon --></svg>
  Please enter a valid email address.
</p>

Motion and Animation

Reduced Motion

Respect user preference for reduced motion:

/* Default: Animations enabled */
.card {
  transition: transform 0.3s ease;
}

.card:hover {
  transform: scale(1.05);
}

/* Reduced motion: Disable or minimize */
@media (prefers-reduced-motion: reduce) {
  .card {
    transition: none;
  }

  .card:hover {
    transform: none;
  }
}

In JavaScript:

const prefersReducedMotion = window.matchMedia(
  '(prefers-reduced-motion: reduce)'
).matches;

if (!prefersReducedMotion) {
  // Run animation
}

Safe Animation Guidelines

  • No flashing more than 3 times per second
  • Provide pause/stop controls for auto-playing content
  • Keep animations under 5 seconds or provide controls
  • Avoid animations that fill the entire screen

Touch and Mobile

Touch Target Size

Minimum 44x44 CSS pixels for touch targets:

.button {
  min-width: 44px;
  min-height: 44px;
  padding: 12px 16px;
}

/* Icon buttons need explicit sizing */
.icon-button {
  width: 44px;
  height: 44px;
  padding: 10px;
}

Spacing Between Targets

Leave at least 8px between touch targets:

.button-group {
  display: flex;
  gap: 8px;  /* Minimum spacing */
}

Testing

Manual Testing Checklist

Keyboard:

  • Can Tab through all interactive elements
  • Tab order is logical (follows visual flow)
  • Focus is always visible
  • Can activate all buttons/links with Enter/Space
  • Can close modals with Escape
  • No keyboard traps (can always Tab out)

Screen Reader:

  • All images have alt text (or are decorative and hidden)
  • All form inputs have labels
  • Headings create logical outline
  • Links and buttons have descriptive text
  • Dynamic changes are announced

Visual:

  • Contrast ratios meet WCAG AA (4.5:1 text, 3:1 UI)
  • Color is not sole indicator
  • Focus indicators visible in all themes
  • Text resizable to 200% without loss

Mobile:

  • Touch targets at least 44x44px
  • Works in portrait and landscape
  • No horizontal scrolling at 320px width

Tiered Automated Testing

Layer accessibility checks at different stages of development:

TierToolWhenCatches
Developmentaxe-core (React/browser)During codingImmediate feedback
Commitaxe-core (Playwright/Cypress)Pre-commit/CIRegressions
Quality GateLighthouse CIPR/mergePerformance + a11y score
ManualWAVE, axe DevToolsCode reviewContext-sensitive issues
Auditpa11y-ciPeriodicSite-wide compliance

Tier 1: Development (Immediate Feedback)

// React axe (dev only)
if (process.env.NODE_ENV === 'development') {
  import('@axe-core/react').then((axe) => {
    axe.default(React, ReactDOM, 1000);
  });
}

Tier 2: Commit (CI Integration)

# axe-core via playwright
npm install @axe-core/playwright
// In test:
import AxeBuilder from '@axe-core/playwright';

test('page should be accessible', async ({ page }) => {
  await page.goto('/');
  const results = await new AxeBuilder({ page }).analyze();
  expect(results.violations).toEqual([]);
});

Tier 3: Quality Gate (Lighthouse CI)

# lighthouserc.js
module.exports = {
  ci: {
    assert: {
      assertions: {
        'categories:accessibility': ['error', { minScore: 0.9 }],
      },
    },
  },
};
# In CI pipeline
npx lhci autorun

Tier 4: Manual Review

Browser extensions for code review:

  • axe DevTools - Comprehensive issue detection
  • WAVE - Visual overlay of issues
  • Accessibility Insights - Step-by-step assessment

Tier 5: Periodic Audit (pa11y-ci)

# .pa11yci.json
{
  "urls": ["/", "/products", "/checkout"],
  "standard": "WCAG2AA"
}

# Run audit
npx pa11y-ci

Use pa11y-ci for periodic site-wide audits, especially before major releases.

Screen Reader Testing

Test with real screen readers:

PlatformScreen ReaderBrowser
macOSVoiceOverSafari
WindowsNVDAFirefox
WindowsJAWSChrome
iOSVoiceOverSafari
AndroidTalkBackChrome

At minimum: Test with VoiceOver (macOS) or NVDA (Windows).


Quick Reference by Component

Button

<button type="button" aria-pressed="false">
  Toggle Feature
</button>
  • Use <button>, not <div> or <a>
  • type="button" prevents form submission
  • aria-pressed for toggle buttons
  • Descriptive text (not “Click here”)
<a href="/products">View all products</a>
  • Use <a> with href, not <span onclick>
  • Descriptive text (not “Learn more”)
  • Opens new tab? Add target="_blank" rel="noopener" and indicate visually

Image

<!-- Informative image -->
<img src="chart.png" alt="Sales increased 20% in Q4" />

<!-- Decorative image -->
<img src="decoration.svg" alt="" role="presentation" />

<!-- Complex image with long description -->
<figure>
  <img src="complex-chart.png" alt="Annual revenue chart" aria-describedby="chart-desc" />
  <figcaption id="chart-desc">
    Revenue grew from $1M in 2020 to $5M in 2024, with the largest growth in 2023.
  </figcaption>
</figure>

Input

<div>
  <label for="username">Username</label>
  <input
    type="text"
    id="username"
    name="username"
    required
    aria-invalid="false"
    aria-describedby="username-hint"
  />
  <p id="username-hint">3-20 characters, letters and numbers only.</p>
</div>
<div
  role="dialog"
  aria-modal="true"
  aria-labelledby="modal-title"
>
  <h2 id="modal-title">Dialog Title</h2>
  <!-- Content -->
  <button type="button" onclick="closeModal()">Close</button>
</div>
  • Focus trapped inside
  • Escape closes
  • Focus returns to trigger on close

  • /pb-patterns-frontend - Accessible component patterns
  • /pb-design-language - Accessibility constraints in design tokens
  • /pb-review-hygiene - Include accessibility in code review
  • /pb-testing - Accessibility testing integration
  • /pb-security - CSP and CORS (overlap with a11y testing tools)

Design Rules Applied

RuleApplication
ClaritySemantic HTML makes intent obvious to all users
RobustnessWorks with assistive technology, degrades gracefully
RepairError states are announced, not just visual
SimplicityNative HTML before ARIA complexity

Resources


Last Updated: 2026-01-19 Version: 1.0