---
title: "Accessibility"
description: "Accessibility patterns for WCAG 2.2 compliance, keyboard focus management, React Aria component patterns, cognitive inclusion, native HTML-first philosophy, and user preference honoring. Use when implementing screen reader support, keyboard navigation, ARIA patterns, focus traps, accessible component libraries, reduced motion, or cognitive accessibility."
canonical: "https://orchestkit.yonyon.ai/docs/reference/skills/accessibility"
---

# Accessibility

Accessibility patterns for WCAG 2.2 compliance, keyboard focus management, React Aria component patterns, cognitive inclusion, native HTML-first philosophy, and user preference honoring. Use when implementing screen reader support, keyboard navigation, ARIA patterns, focus traps, accessible component libraries, reduced motion, or cognitive accessibility.

<span className="badge badge-gray">Reference</span> <span className="badge badge-yellow">medium</span>

> **Not directly invocable** — no slash command and no model auto-selection. An agent loads it explicitly via `Read()`.

<ContextualSkillSidebar slug="accessibility" />

> **Accessibility** Accessibility patterns for WCAG 2.2 compliance, keyboard focus management, React Aria component patterns, cognitive inclusion, native HTML-first philosophy, and user preference honoring. Use when implementing screen reader support, keyboard navigation, ARIA patterns, focus traps, accessible component libraries, reduced motion, or cognitive accessibility.


# Accessibility

Comprehensive patterns for building accessible web applications: WCAG 2.2 AA compliance, keyboard focus management, React Aria component patterns, native HTML-first philosophy, cognitive inclusion, and user preference honoring. Each category has individual rule files in `rules/` loaded on-demand.

## Quick Reference

| Category | Rules | Impact | When to Use |
|----------|-------|--------|-------------|
| [WCAG Compliance](#wcag-compliance) | 3 | CRITICAL | Color contrast, semantic HTML, automated testing |
| [POUR Exit Criteria](#pour-exit-criteria) | 1 | CRITICAL | Falsifiable pass/fail thresholds for each WCAG 2.2 AA criterion |
| [Static Anti-Patterns](#static-anti-patterns) | 1 | HIGH | Grep-able patterns detectable without a browser |
| [Focus Management](#focus-management) | 1 | HIGH | Keyboard navigation; trap/restoration mechanics live upstream |
| [React Aria](#react-aria) | 2 | HIGH | Accessible components and form hooks; overlay APIs live upstream |
| [Modern Web Accessibility](#modern-web-accessibility) | 2 | CRITICAL/HIGH | Native HTML first, user preferences; cognitive ceilings in `references/ork-delta.md` |

**Total: 10 rules across 6 categories**

## Quick Start

```tsx
// Semantic HTML with ARIA
<main>
  <article>
    <header><h1>Page Title</h1></header>
    <section aria-labelledby="features-heading">
      <h2 id="features-heading">Features</h2>
    </section>
  </article>
</main>
```

```tsx
// Focus trap with React Aria
import { FocusScope } from 'react-aria';

<FocusScope contain restoreFocus autoFocus>
  <div role="dialog" aria-modal="true">
    {children}
  </div>
</FocusScope>
```

## WCAG Compliance

WCAG 2.2 AA implementation for inclusive, legally compliant web applications.

| Rule | File | Key Pattern |
|------|------|-------------|
| Color Contrast | `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/rules/wcag-color-contrast.md` | 4.5:1 text, 3:1 UI components, focus indicators |
| Semantic HTML | `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/rules/wcag-semantic-html.md` | Landmarks, headings, ARIA labels, form structure |
| Testing | `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/rules/wcag-testing.md` | axe-core, Playwright a11y, screen reader testing |

## POUR Exit Criteria

Concrete pass/fail thresholds for each WCAG 2.2 AA criterion — replaces vague "meets requirements" checks.

| Rule | File | Key Pattern |
|------|------|-------------|
| POUR Exit Criteria | `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/rules/pour-exit-criteria.md` | Falsifiable checklist: image alt, contrast ratios, focus indicators, touch targets, ARIA states |

## Static Anti-Patterns

Grep-able anti-patterns detectable via static analysis or code review — no browser needed.

| Rule | File | Key Pattern |
|------|------|-------------|
| A11y Anti-Patterns (Static) | `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/rules/a11y-antipatterns-static.md` | Focus removal, missing labels, autoplay, icon-only buttons, div-click handlers |

## Focus Management

Keyboard focus management patterns for accessible interactive widgets.

| Rule | File | Key Pattern |
|------|------|-------------|
| Keyboard Navigation | `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/rules/focus-keyboard-nav.md` | Roving tabindex, skip links, arrow keys |

Focus trap and restoration mechanics are upstream's job: use React Aria `&lt;FocusScope contain restoreFocus autoFocus&gt;` (see [Upstream coverage](#upstream-coverage-do-not-restate)). When React Aria is unavailable, copy `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/scripts/focus-trap-template.tsx`; the house rules for both live in `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/references/ork-delta.md`.

## React Aria

Adobe React Aria hooks for building WCAG-compliant interactive UI.

| Rule | File | Key Pattern |
|------|------|-------------|
| Components | `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/rules/aria-components.md` | useButton, useDialog, useMenu, FocusScope |
| Forms | `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/rules/aria-forms.md` | useComboBox, useTextField, useListBox |

Overlay hook APIs (useModalOverlay, useTooltip, usePopover) are documented upstream (see [Upstream coverage](#upstream-coverage-do-not-restate)); the house overlay recipe (FocusScope plus shared motion presets) is in `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/references/ork-delta.md`.

## Modern Web Accessibility

2026 best practices: native HTML first, cognitive inclusion, and honoring user preferences.

| Rule | File | Key Pattern |
|------|------|-------------|
| Native HTML First | `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/rules/wcag-native-html-first.md` | `&lt;dialog&gt;`, `<details>`, native over custom ARIA |
| User Preferences | `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/rules/wcag-user-preferences.md` | prefers-reduced-motion, forced-colors, prefers-contrast, zoom |

Cognitive inclusion (ADHD/autism/dyslexia support) is covered upstream by W3C COGA (see [Upstream coverage](#upstream-coverage-do-not-restate)); the house cognitive-load ceilings (notification cap, nav-item cap, reading-level targets) are in `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/references/ork-delta.md`.

## Key Decisions

| Decision | Recommendation |
|----------|----------------|
| Conformance level | WCAG 2.2 AA (legal standard: ADA, Section 508) |
| Contrast ratio | 4.5:1 normal text, 3:1 large text and UI components |
| Target size | 24px min (WCAG 2.5.8), 44px for touch |
| Focus indicator | 3px solid outline, 3:1 contrast |
| Component library | React Aria hooks for control, react-aria-components for speed |
| State management | react-stately hooks (designed for a11y) |
| Focus management | FocusScope for modals, roving tabindex for widgets |
| Testing | jest-axe (unit) + Playwright axe-core (E2E) |

## Anti-Patterns (FORBIDDEN)

- **Div soup**: Using `<div>` instead of semantic elements (`&lt;nav&gt;`, `&lt;main&gt;`, `&lt;article&gt;`)
- **Color-only information**: Status indicated only by color without icon/text
- **Missing labels**: Form inputs without associated `&lt;label&gt;` or `aria-label`
- **Keyboard traps**: Focus that cannot escape without Escape key
- **Removing focus outline**: `outline: none` without replacement indicator
- **Positive tabindex**: Using `tabindex > 0` (disrupts natural order)
- **Div with onClick**: Using `<div onClick>` instead of `&lt;button&gt;` or `useButton`
- **Manual focus in modals**: Using `useEffect` + `ref.focus()` instead of `FocusScope`
- **Auto-playing media**: Audio/video that plays without user action
- **ARIA overuse**: Using ARIA when semantic HTML suffices

## Upstream coverage (do not restate)

Vendor tutorials, API references, and criterion walkthroughs removed in the 2026-07-31 wrap-plus-delta thinning. Consult the first-party source; only the ork delta (floors, scars, house decisions) lives in `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/references/ork-delta.md`.

| Topic | First-party source |
|-------|--------------------|
| WCAG 2.2 success-criterion walkthroughs and audit checklists | [WCAG 2.2 quick reference](https://www.w3.org/WAI/WCAG22/quickref/) and [WCAG 2.2 spec](https://www.w3.org/TR/WCAG22/) |
| ARIA widget patterns and keyboard interaction models | [WAI-ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/) |
| React Aria hook APIs (useButton, useMenu, useComboBox, useModalOverlay, useTooltip, usePopover), component checklists, worked examples | [React Aria docs](https://react-spectrum.adobe.com/react-aria/) |
| Focus trap, focus restoration, roving tabindex, and skip-link algorithms | [React Aria FocusScope](https://react-spectrum.adobe.com/react-aria/FocusScope.html) and [APG patterns](https://www.w3.org/WAI/ARIA/apg/patterns/) |
| Cognitive accessibility guidance (COGA) | [W3C Making Content Usable](https://www.w3.org/TR/coga-usable/) |
| Screen reader testing walkthroughs (NVDA, JAWS, VoiceOver, TalkBack) | [WebAIM screen reader testing](https://webaim.org/articles/screenreader_testing/) |

## Detailed Documentation

| Resource | Description |
|----------|-------------|
| `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/scripts` | Templates: accessible form, focus trap, React Aria components |
| `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/references/ork-delta.md` | Ork-specific floors, scars, and house decisions (cognitive ceilings, overlay recipe, canonical focus-trap selector) |
| `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/references/ux-thresholds-quick.md` | UI/UX thresholds quick reference: contrast, touch targets, cognitive load, typography, forms |
| `$\{CLAUDE_PLUGIN_ROOT\}/skills/accessibility/examples/wcag-examples.md` | Complete accessible form, modal, and navigation examples |

## Related Skills

- `ork:testing-e2e` - E2E testing patterns including accessibility testing
- `design-system-starter` - Accessible component library patterns
- `ork:i18n-date-patterns` - RTL layout and locale-aware formatting
- `motion-animation-patterns` - Reduced motion and animation accessibility


---

## Rules (10)

### Grep-able anti-patterns detectable via static code analysis without a browser — HIGH


# Accessibility Anti-Patterns: Static Detection

These patterns are detectable by grep, ESLint, or code review — no browser required.

## Anti-Pattern Table

| Anti-Pattern | Detection Regex | WCAG | Fix |
|---|---|---|---|
| Focus removal | `outline:\s*(none\|0)` without `:focus-visible` companion | 2.4.11 | Add `:focus-visible` ring with 3:1 contrast |
| Non-descriptive links | Link text `/^(click here\|read more\|here\|more\|learn more)$/i` | 2.4.4 | Use descriptive text meaningful out of context |
| Autoplay media | `&lt;(video\|audio)[^&gt;]*autoplay` without `muted` | 1.4.2 | Add `muted` or remove `autoplay` |
| Missing language | `&lt;html(?![^&gt;]*lang)` | 3.1.1 | Add `&lt;html lang="en"&gt;` (or correct BCP 47 tag) |
| Disabled zoom | `(user-scalable=no\|maximum-scale=1)` in viewport meta | 1.4.4 | Remove these restrictions entirely |
| SR content hidden wrong | `display:\s*none\|visibility:\s*hidden` on ARIA-role elements | 1.3.1 | Use `.sr-only` (visually hidden, SR accessible) |
| Placeholder as label | `&lt;input[^&gt;]*placeholder` without nearby `&lt;label` | 3.3.2 | Add `&lt;label for="id"&gt;` linked via matching `id` |
| Heading skip | `&lt;h[1-6]` followed later by level +2 or more | 1.3.1 | Maintain sequential hierarchy (h1 &gt; h2 > h3) |
| Image without alt | `<img(?![^>]*\balt\b)` | 1.1.1 | Add `alt="description"` or `alt=""` for decorative |
| Button without text | `&lt;button[^&gt;]*>(\s*&lt;[^/])` with no `aria-label` | 4.1.2 | Add `aria-label="Action name"` |
| Positive tabindex | `tabindex="[1-9]` | 2.4.3 | Use `tabindex="0"` or `-1` only |
| Div/span click handler | `&lt;(div\|span)[^&gt;]*onClick` | 4.1.2 | Replace with `&lt;button&gt;` or add `role="button"` + keyboard handler |

## Detailed Fixes

### Focus Removal

**Incorrect:**
```css
/* Removes focus for all users including keyboard-only users */
* { outline: none; }
button:focus { outline: 0; }
```

**Correct:**
```css
/* Only hide outline for mouse users; preserve for keyboard users */
button:focus:not(:focus-visible) { outline: none; }
button:focus-visible {
  outline: 3px solid #0052cc;
  outline-offset: 2px;
}
```

### Placeholder as Label

**Incorrect:**
```html
<input type="email" placeholder="Email address" />
```

**Correct:**
```html
<label for="email">Email address</label>
<input id="email" type="email" placeholder="you@example.com" aria-required="true" />
```

### Autoplay Media

**Incorrect:**
```html
<video autoplay src="intro.mp4"></video>
```

**Correct:**
```html
<!-- Muted autoplay is allowed (no audio disruption) -->
<video autoplay muted loop src="intro.mp4"></video>
<!-- Or: remove autoplay entirely and provide play control -->
<video controls src="intro.mp4"></video>
```

### Icon-Only Button

**Incorrect:**
```html
<button><svg><!-- search icon --></svg></button>
```

**Correct:**
```html
<button aria-label="Search">
  <svg aria-hidden="true" focusable="false"><!-- search icon --></svg>
</button>
```

### Visually Hidden Content (SR-only)

**Incorrect:**
```css
/* Hides from screen readers too */
.hidden-label { display: none; }
```

**Correct:**
```css
/* Visible to screen readers, hidden visually */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
```

## ESLint Rule Mapping

| Anti-Pattern | ESLint Rule (`eslint-plugin-jsx-a11y`) |
|---|---|
| Image without alt | `jsx-a11y/alt-text` |
| Missing label | `jsx-a11y/label-has-associated-control` |
| Non-descriptive links | `jsx-a11y/anchor-ambiguous-text` |
| Div with click handler | `jsx-a11y/click-events-have-key-events` + `jsx-a11y/no-static-element-interactions` |
| Button without text | `jsx-a11y/accessible-emoji` + custom rule |
| Autoplay | `jsx-a11y/media-has-caption` |


### Build accessible buttons, dialogs, and menus with React Aria keyboard support — HIGH


# React Aria Components (useButton, useDialog, useMenu)

## useButton - Accessible Button

```tsx
import { useRef } from 'react';
import { useButton, useFocusRing, mergeProps } from 'react-aria';
import type { AriaButtonProps } from 'react-aria';

function Button(props: AriaButtonProps & { className?: string }) {
  const ref = useRef<HTMLButtonElement>(null);
  const { buttonProps, isPressed } = useButton(props, ref);
  const { focusProps, isFocusVisible } = useFocusRing();

  return (
    <button
      {...mergeProps(buttonProps, focusProps)}
      ref={ref}
      className={`
        px-4 py-2 rounded font-medium transition-all
        ${isPressed ? 'scale-95' : ''}
        ${isFocusVisible ? 'ring-2 ring-offset-2 ring-blue-500' : ''}
        disabled:opacity-50 disabled:cursor-not-allowed
      `}
    >
      {props.children}
    </button>
  );
}
```

**Key Props:**
- `onPress` - Triggered on click, tap, Enter, or Space
- `isDisabled` - Disables all interaction
- `elementType` - Custom element type (default: button)

## useDialog - Modal Dialog

```tsx
import { useRef } from 'react';
import { useDialog, useModalOverlay, FocusScope, mergeProps } from 'react-aria';
import { useOverlayTriggerState } from 'react-stately';

function Modal({ state, title, children }) {
  const ref = useRef<HTMLDivElement>(null);
  const { modalProps, underlayProps } = useModalOverlay({}, state, ref);
  const { dialogProps, titleProps } = useDialog({ 'aria-label': title }, ref);

  return (
    <div {...underlayProps} className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center">
      <FocusScope contain restoreFocus autoFocus>
        <div {...mergeProps(modalProps, dialogProps)} ref={ref} className="bg-white rounded-lg p-6">
          <h2 {...titleProps} className="text-xl font-semibold mb-4">{title}</h2>
          {children}
        </div>
      </FocusScope>
    </div>
  );
}
```

## useMenu - Dropdown Menu

```tsx
import { useRef } from 'react';
import { useButton, useMenuTrigger, useMenu, useMenuItem, mergeProps } from 'react-aria';
import { useMenuTriggerState, useTreeState } from 'react-stately';
import { Item } from 'react-stately';

export function MenuButton(props: { label: string; onAction: (key: string) => void }) {
  const state = useMenuTriggerState({});
  const ref = useRef<HTMLButtonElement>(null);
  const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref);
  const { buttonProps } = useButton(menuTriggerProps, ref);

  return (
    <div className="relative inline-block">
      <button
        {...buttonProps}
        ref={ref}
        className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 flex items-center gap-2"
      >
        {props.label}
        <span aria-hidden="true">&#9660;</span>
      </button>
      {state.isOpen && (
        <MenuPopup
          {...menuProps}
          autoFocus={state.focusStrategy}
          onClose={state.close}
          onAction={(key) => {
            props.onAction(key as string);
            state.close();
          }}
        />
      )}
    </div>
  );
}

function MenuPopup(props: any) {
  const ref = useRef<HTMLUListElement>(null);
  const state = useTreeState({ ...props, selectionMode: 'none' });
  const { menuProps } = useMenu(props, state, ref);

  return (
    <ul {...menuProps} ref={ref} className="absolute top-full left-0 mt-1 min-w-[200px] bg-white border rounded shadow-lg py-1 z-50">
      {[...state.collection].map((item) => (
        <MenuItem key={item.key} item={item} state={state} onAction={props.onAction} onClose={props.onClose} />
      ))}
    </ul>
  );
}

function MenuItem({ item, state, onAction, onClose }: any) {
  const ref = useRef<HTMLLIElement>(null);
  const { menuItemProps, isFocused, isPressed } = useMenuItem(
    { key: item.key, onAction, onClose }, state, ref
  );

  return (
    <li {...menuItemProps} ref={ref} className={`px-4 py-2 cursor-pointer ${isFocused ? 'bg-blue-50' : ''} ${isPressed ? 'bg-blue-100' : ''}`}>
      {item.rendered}
    </li>
  );
}
```

## mergeProps Utility

Safely merge multiple prop objects (combines event handlers):

```tsx
import { mergeProps } from 'react-aria';

const combinedProps = mergeProps(
  { onClick: handler1, className: 'base' },
  { onClick: handler2, className: 'extra' }
);
// Result: onClick calls both handlers
```

## Hooks vs Components Decision

| Approach | Use When |
|----------|----------|
| `useButton` hooks | Maximum control over rendering and styling |
| `Button` from react-aria-components | Fast prototyping, less boilerplate |

## Anti-Patterns

```tsx
// NEVER use div with onClick for interactive elements
<div onClick={handleClick}>Click me</div>  // Missing keyboard support!

// ALWAYS use useButton or native button
const { buttonProps } = useButton({ onPress: handleClick }, ref);
<div {...buttonProps} ref={ref}>Click me</div>

// NEVER forget aria-live for dynamic announcements
<div>{errorMessage}</div>  // Screen readers won't announce!

// ALWAYS use aria-live for status updates
<div aria-live="polite" className="sr-only">{errorMessage}</div>
```

**Incorrect — div with onClick, no keyboard support:**
```tsx
<div onClick={handleClick} className="button">
  Click me
</div>
// No keyboard access, no screen reader announcement
```

**Correct — useButton hook provides full accessibility:**
```tsx
const ref = useRef<HTMLButtonElement>(null);
const { buttonProps } = useButton({ onPress: handleClick }, ref);
return <button {...buttonProps} ref={ref}>Click me</button>;
```


### Create accessible form controls with React Aria labels and keyboard navigation — HIGH


# React Aria Forms (useComboBox, useTextField, useListBox)

## useComboBox - Accessible Autocomplete

```tsx
import { useRef } from 'react';
import { useComboBox, useFilter } from 'react-aria';
import { useComboBoxState } from 'react-stately';

function ComboBox(props) {
  const { contains } = useFilter({ sensitivity: 'base' });
  const state = useComboBoxState({ ...props, defaultFilter: contains });
  const inputRef = useRef(null), buttonRef = useRef(null), listBoxRef = useRef(null);

  const { buttonProps, inputProps, listBoxProps, labelProps } = useComboBox(
    { ...props, inputRef, buttonRef, listBoxRef }, state
  );

  return (
    <div className="relative inline-flex flex-col">
      <label {...labelProps}>{props.label}</label>
      <div className="flex">
        <input {...inputProps} ref={inputRef} className="border rounded-l px-3 py-2" />
        <button {...buttonProps} ref={buttonRef} className="border rounded-r px-2">&#9660;</button>
      </div>
      {state.isOpen && (
        <ul {...listBoxProps} ref={listBoxRef} className="absolute top-full w-full border bg-white">
          {[...state.collection].map((item) => (
            <li key={item.key} className="px-3 py-2 hover:bg-gray-100">{item.rendered}</li>
          ))}
        </ul>
      )}
    </div>
  );
}
```

**Features:**
- Type-ahead filtering with `useFilter`
- Arrow keys navigate options, Enter selects, Escape closes
- Label associated with input via `labelProps`
- `aria-expanded` indicates dropdown state

## useTextField - Accessible Text Input

```tsx
import { useRef } from 'react';
import { useTextField } from 'react-aria';

function TextField(props) {
  const ref = useRef(null);
  const { labelProps, inputProps, descriptionProps, errorMessageProps } = useTextField(props, ref);

  return (
    <div className="flex flex-col gap-1">
      <label {...labelProps} className="font-medium">
        {props.label}
      </label>
      <input
        {...inputProps}
        ref={ref}
        className="border rounded px-3 py-2"
      />
      {props.description && (
        <div {...descriptionProps} className="text-sm text-gray-600">
          {props.description}
        </div>
      )}
      {props.errorMessage && (
        <div {...errorMessageProps} className="text-sm text-red-600">
          {props.errorMessage}
        </div>
      )}
    </div>
  );
}
```

**Key Props:**
- `label` - Accessible label text
- `description` - Helper text (linked via `aria-describedby`)
- `errorMessage` - Error text (linked via `aria-describedby`)
- `isRequired` - Adds `aria-required="true"`
- `isInvalid` - Adds `aria-invalid="true"`

## useListBox - Accessible List with Selection

```tsx
import { useRef } from 'react';
import { useListBox, useOption } from 'react-aria';
import { useListState } from 'react-stately';
import { Item } from 'react-stately';

function ListBox(props) {
  const state = useListState(props);
  const ref = useRef(null);
  const { listBoxProps } = useListBox(props, state, ref);

  return (
    <ul {...listBoxProps} ref={ref} className="border rounded">
      {[...state.collection].map((item) => (
        <Option key={item.key} item={item} state={state} />
      ))}
    </ul>
  );
}

function Option({ item, state }) {
  const ref = useRef(null);
  const { optionProps, isSelected, isFocused } = useOption(
    { key: item.key }, state, ref
  );

  return (
    <li
      {...optionProps}
      ref={ref}
      className={`
        px-3 py-2 cursor-pointer
        ${isSelected ? 'bg-blue-500 text-white' : ''}
        ${isFocused ? 'bg-gray-100' : ''}
      `}
    >
      {item.rendered}
    </li>
  );
}

// Usage
<ListBox selectionMode="multiple">
  <Item key="red">Red</Item>
  <Item key="green">Green</Item>
  <Item key="blue">Blue</Item>
</ListBox>
```

**Selection Modes:**
- `"single"` - Select one item
- `"multiple"` - Select multiple items
- `"none"` - No selection (display only)

## useSelect - Dropdown Select

```tsx
import { useRef } from 'react';
import { HiddenSelect, useSelect } from 'react-aria';
import { useSelectState } from 'react-stately';

function Select(props) {
  const state = useSelectState(props);
  const ref = useRef(null);
  const { triggerProps, valueProps, menuProps } = useSelect(props, state, ref);

  return (
    <div className="relative inline-flex flex-col">
      <HiddenSelect state={state} triggerRef={ref} label={props.label} />
      <button
        {...triggerProps}
        ref={ref}
        className="px-4 py-2 border rounded flex justify-between items-center"
      >
        <span {...valueProps}>
          {state.selectedItem?.rendered || 'Select...'}
        </span>
        <span aria-hidden="true">&#9660;</span>
      </button>
      {state.isOpen && (
        <ListBoxPopup {...menuProps} state={state} />
      )}
    </div>
  );
}
```

## react-stately Integration

| React Aria Hook | State Hook |
|----------------|------------|
| useComboBox | useComboBoxState |
| useListBox | useListState |
| useSelect | useSelectState |
| useMenu | useTreeState |
| useCheckbox | useToggleState |

## Anti-Patterns

```tsx
// NEVER omit label associations
<input type="text" placeholder="Email" />  // No accessible name!

// ALWAYS associate labels properly
<label {...labelProps}>Email</label>
<input {...inputProps} />

// NEVER use placeholder as label
<input placeholder="Enter email" />  // Disappears on focus!

// ALWAYS provide visible label + optional placeholder
<label htmlFor="email">Email</label>
<input id="email" placeholder="user@example.com" />
```

**Incorrect — Placeholder as label, no explicit association:**
```tsx
<input type="text" placeholder="Enter your email" />
// Screen readers can't identify field purpose reliably
```

**Correct — useTextField with proper label association:**
```tsx
const { labelProps, inputProps } = useTextField({ label: 'Email' }, ref);
return (
  <>
    <label {...labelProps}>Email</label>
    <input {...inputProps} ref={ref} />
  </>
);
```


### Ensure all interactive elements support keyboard navigation with roving tabindex — HIGH


# Keyboard Navigation (WCAG 2.1.1, 2.4.3, 2.4.7)

## Roving Tabindex

Only one item in a group has `tabIndex=\{0\}`; the rest have `tabIndex=\{-1\}`. Arrow keys move focus.

```tsx
function TabList({ tabs, onSelect }) {
  const [activeIndex, setActiveIndex] = useState(0);
  const tabRefs = useRef<HTMLButtonElement[]>([]);

  const handleKeyDown = (e: KeyboardEvent, index: number) => {
    const keyMap: Record<string, number> = {
      ArrowRight: (index + 1) % tabs.length,
      ArrowLeft: (index - 1 + tabs.length) % tabs.length,
      Home: 0, End: tabs.length - 1,
    };
    if (e.key in keyMap) {
      e.preventDefault();
      setActiveIndex(keyMap[e.key]);
      tabRefs.current[keyMap[e.key]]?.focus();
    }
  };

  return (
    <div role="tablist">
      {tabs.map((tab, i) => (
        <button key={tab.id} ref={(el) => (tabRefs.current[i] = el!)}
          role="tab" tabIndex={i === activeIndex ? 0 : -1}
          aria-selected={i === activeIndex}
          onKeyDown={(e) => handleKeyDown(e, i)}
          onClick={() => { setActiveIndex(i); onSelect(tab); }}>
          {tab.label}
        </button>
      ))}
    </div>
  );
}
```

## useRovingTabindex Hook

Reusable hook for toolbars, menus, and lists:

```tsx
type Orientation = 'horizontal' | 'vertical';

export function useRovingTabindex<T extends HTMLElement>(
  itemCount: number,
  orientation: Orientation = 'vertical'
) {
  const [activeIndex, setActiveIndex] = useState(0);
  const itemsRef = useRef<Map<number, T>>(new Map());

  const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
    const keys = orientation === 'horizontal'
      ? { next: 'ArrowRight', prev: 'ArrowLeft' }
      : { next: 'ArrowDown', prev: 'ArrowUp' };

    let nextIndex: number | null = null;

    if (event.key === keys.next) {
      nextIndex = (activeIndex + 1) % itemCount;
    } else if (event.key === keys.prev) {
      nextIndex = (activeIndex - 1 + itemCount) % itemCount;
    } else if (event.key === 'Home') {
      nextIndex = 0;
    } else if (event.key === 'End') {
      nextIndex = itemCount - 1;
    }

    if (nextIndex !== null) {
      event.preventDefault();
      setActiveIndex(nextIndex);
      itemsRef.current.get(nextIndex)?.focus();
    }
  }, [activeIndex, itemCount, orientation]);

  const getItemProps = useCallback((index: number) => ({
    ref: (element: T | null) => {
      if (element) {
        itemsRef.current.set(index, element);
      } else {
        itemsRef.current.delete(index);
      }
    },
    tabIndex: index === activeIndex ? 0 : -1,
    onFocus: () => setActiveIndex(index),
  }), [activeIndex]);

  return { activeIndex, setActiveIndex, handleKeyDown, getItemProps };
}

// Usage: Toolbar
function Toolbar() {
  const { getItemProps, handleKeyDown } = useRovingTabindex<HTMLButtonElement>(
    3, 'horizontal'
  );

  return (
    <div role="toolbar" aria-label="Text formatting" onKeyDown={handleKeyDown}>
      <button {...getItemProps(0)} aria-label="Bold"><BoldIcon /></button>
      <button {...getItemProps(1)} aria-label="Italic"><ItalicIcon /></button>
      <button {...getItemProps(2)} aria-label="Underline"><UnderlineIcon /></button>
    </div>
  );
}
```

## Skip Links

Allow keyboard users to bypass repeated navigation:

```tsx
export function SkipLinks() {
  return (
    <nav aria-label="Skip links">
      <a href="#main-content" className="skip-link">
        Skip to main content
      </a>
      <a href="#navigation" className="skip-link">
        Skip to navigation
      </a>
    </nav>
  );
}

// Layout usage
export function Layout({ children }) {
  return (
    <>
      <SkipLinks />
      <nav id="navigation" aria-label="Main navigation">
        {/* navigation */}
      </nav>
      <main id="main-content" tabIndex={-1}>
        {children}
      </main>
    </>
  );
}
```

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

## Focus Within Detection

```tsx
export function useFocusWithin<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [isFocusWithin, setIsFocusWithin] = useState(false);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const handleFocusIn = () => setIsFocusWithin(true);
    const handleFocusOut = (e: FocusEvent) => {
      if (!element.contains(e.relatedTarget as Node)) {
        setIsFocusWithin(false);
      }
    };

    element.addEventListener('focusin', handleFocusIn);
    element.addEventListener('focusout', handleFocusOut);
    return () => {
      element.removeEventListener('focusin', handleFocusIn);
      element.removeEventListener('focusout', handleFocusOut);
    };
  }, []);

  return { ref, isFocusWithin };
}
```

## Focus Indicator Styles

```css
/* Use :focus-visible (not :focus) for keyboard-only indicators */
:focus-visible {
  outline: 3px solid #0052cc;
  outline-offset: 2px;
}

/* Ensure scroll margin for sticky headers */
:focus {
  scroll-margin-top: var(--header-height, 64px);
}
```

## Anti-Patterns

```tsx
// NEVER use positive tabindex - breaks natural tab order
<button tabIndex={5}>Bad</button>

// NEVER remove focus outline without replacement (WCAG 2.4.7)
button:focus { outline: none; }

// NEVER auto-focus without user expectation
useEffect(() => inputRef.current?.focus(), []);

// NEVER hide skip links permanently - must be visible on focus
.skip-link { display: none; }
```

**Incorrect — Removing focus outline globally:**
```css
*:focus {
  outline: none;
}
/* Violates WCAG 2.4.7, keyboard users can't see focus */
```

**Correct — Using focus-visible for keyboard-only indicators:**
```css
:focus-visible {
  outline: 3px solid #0052cc;
  outline-offset: 2px;
}
```


### Concrete pass/fail exit criteria for each POUR principle mapped to WCAG 2.2 criteria — CRITICAL


# POUR Exit Criteria (WCAG 2.2 AA)

Pass/fail thresholds for each principle. Every item must pass before marking a feature accessible.

## Perceivable

- [ ] Every `<img>` has `alt`. Decorative images use `alt=""`. Complex images use `aria-describedby` pointing to adjacent descriptive text. (1.1.1, Level A)
- [ ] Normal text contrast >= 4.5:1 against its background. Large text (>= 18pt or >= 14pt bold) contrast >= 3:1. (1.4.3, AA)
- [ ] UI component boundaries (input borders, icon strokes, button outlines) contrast >= 3:1 against adjacent color. (1.4.11, AA)
- [ ] No information is conveyed by color alone — every color-coded element also has an icon, pattern, or visible text label. (1.4.1, A)
- [ ] Page content reflows without horizontal scrolling at 320px viewport width (equivalent to 400% zoom on 1280px display). No fixed-width containers wider than 320px. (1.4.10, AA)
- [ ] Text spacing overrides do not break layout: `line-height: 1.5`, `letter-spacing: 0.12em`, `word-spacing: 0.16em`, `paragraph spacing: 2em` all applied simultaneously produce no clipped or overlapping content. (1.4.12, AA)

## Operable

- [ ] Every interactive element (links, buttons, inputs, custom widgets) is reachable and activatable via keyboard alone. Tab order follows visual reading order (left-to-right, top-to-bottom for LTR). (2.1.1, A)
- [ ] No keyboard trap exists — pressing Escape or a documented key sequence always exits any component that receives focus. (2.1.2, A)
- [ ] A skip link to `&lt;main id="main-content"&gt;` is the first focusable element on every page. It becomes visible on focus. (2.4.1, A)
- [ ] All focus indicators: minimum 2px perimeter outline, >= 3:1 contrast between focused and unfocused states. Default browser outlines are acceptable only if they pass the contrast check. (2.4.11, AA — WCAG 2.2)
- [ ] Touch targets are >= 24x24 CSS pixels. No adjacent interactive target falls within a 24px radius of another target's boundary. Primary CTAs should be >= 44x44px. (2.5.8, AA — WCAG 2.2)
- [ ] No content moves, blinks, scrolls, or auto-updates for more than 3 seconds without a mechanism to pause, stop, or hide it. (2.2.2, A)
- [ ] Page titles describe topic or purpose uniquely within the site (e.g., "Login — AppName", not just "Login"). (2.4.2, A)

## Understandable

- [ ] `&lt;html lang="xx"&gt;` is set and matches the primary language of the page. Language changes within content use `lang` on the containing element. (3.1.1, A)
- [ ] Every form input has an associated `&lt;label for="id"&gt;`, or `aria-label`, or `aria-labelledby`. Placeholder text alone does not count as a label. (3.3.2, A)
- [ ] Error messages: identify the affected field by name, describe the cause of the error, and suggest a specific fix. Errors are announced to assistive technology via `aria-live="polite"` or `role="alert"`. (3.3.1 + 3.3.3, A/AA)
- [ ] No link text is "click here", "read more", "here", "more", or "link" when read out of context. Each link's accessible name uniquely identifies its destination or action. (2.4.4, A)
- [ ] Links that open in a new tab include a visible icon (e.g., external-link icon) with `aria-label` supplement (e.g., `aria-label="Opens in new tab"`). (2.4.4 advisory)
- [ ] Navigation menus appear in the same relative order on every page where they repeat. (3.2.3, AA)
- [ ] Components with the same function have the same accessible name across all pages. (3.2.4, AA)

## Example: Focus Indicator (2.4.11)

**Incorrect:**
```css
/* Removes all focus indicators — keyboard users are blind */
*:focus { outline: none; }
```

**Correct:**
```css
/* 2px outline with sufficient contrast for focus visibility */
*:focus-visible {
  outline: 2px solid var(--focus-ring, #005fcc);
  outline-offset: 2px;
}
```

## Robust

- [ ] No duplicate `id` attributes on interactive elements in the same document. No unclosed or improperly nested landmark elements (`&lt;main&gt;`, `&lt;nav&gt;`, `&lt;header&gt;`, `&lt;footer&gt;`). (4.1.1, A)
- [ ] Custom interactive widgets expose correct ARIA state attributes:
  - Toggle buttons: `aria-pressed="true|false"`
  - Disclosure widgets: `aria-expanded="true|false"`
  - Tabs: `aria-selected="true|false"` on tab elements, `role="tablist"` on container
  - Custom checkboxes: `aria-checked="true|false|mixed"`
  - Comboboxes: `aria-autocomplete`, `aria-activedescendant` (4.1.2, A)
- [ ] Status messages (toasts, loading indicators, success confirmations, live regions) use `role="status"` or `aria-live="polite"`. Urgent alerts use `role="alert"` or `aria-live="assertive"`. (4.1.3, AA)
- [ ] All `aria-*` attributes reference existing IDs. No orphaned `aria-labelledby` or `aria-describedby` values. (4.1.2, A)


### Meet WCAG 4.5:1 minimum contrast ratio for text and UI component readability — CRITICAL


# Color Contrast (WCAG 1.4.3, 1.4.11)

## Contrast Requirements

| Element Type | Minimum Ratio | WCAG Criterion |
|-------------|---------------|----------------|
| Normal text (&lt; 18pt / &lt; 14pt bold) | 4.5:1 | 1.4.3 |
| Large text (>= 18pt / >= 14pt bold) | 3:1 | 1.4.3 |
| UI components (borders, icons, focus) | 3:1 | 1.4.11 |
| Focus indicators | 3:1 | 2.4.7 |

## CSS Custom Properties

```css
:root {
  --text-primary: #1a1a1a;   /* 16.1:1 on white - normal text */
  --text-secondary: #595959; /* 7.0:1 on white - secondary */
  --focus-ring: #0052cc;     /* 7.3:1 - focus indicator */
}

/* High visibility focus indicator */
:focus-visible {
  outline: 3px solid var(--focus-ring);
  outline-offset: 2px;
}

/* Button border 3:1 contrast */
.button {
  background: #ffffff;
  border: 2px solid #757575; /* 4.5:1 on white */
}

/* Minimum target size (WCAG 2.5.8) */
button, a[role="button"], input[type="checkbox"] {
  min-width: 24px;
  min-height: 24px;
}

/* Touch-friendly target size */
@media (hover: none) {
  button {
    min-width: 44px;
    min-height: 44px;
  }
}
```

## Non-Color Status Indicators

Never convey information through color alone:

```tsx
// FORBIDDEN: Color-only status
<span className="text-red-500">Error</span>

// CORRECT: Color + icon + text
<span className="text-red-500 flex items-center gap-1">
  <AlertIcon aria-hidden="true" />
  Error: Invalid email address
</span>
```

## Text Spacing (WCAG 1.4.12)

Content must remain usable when text spacing is adjusted:

```css
body {
  line-height: 1.5;        /* at least 1.5x font size */
}
p {
  margin-bottom: 2em;      /* at least 2x font size */
}
```

## Reflow (WCAG 1.4.10)

Content must reflow without horizontal scrolling at 320px width:

```css
/* Responsive design */
.card {
  width: 100%;
  max-width: 600px;
}

/* FORBIDDEN: Fixed width that forces horizontal scroll */
.card {
  width: 800px;
}
```

## Testing Tools

- **WebAIM Contrast Checker**: [webaim.org/resources/contrastchecker](https://webaim.org/resources/contrastchecker/)
- **Chrome DevTools**: Inspect > Color picker > Contrast ratio
- **Lighthouse**: Accessibility audit built into Chrome DevTools

## Common Mistakes

| Mistake | Fix |
|---------|-----|
| Insufficient text contrast (#b3b3b3 = 2.1:1) | Use #595959 or darker (7.0:1+) |
| Removing focus outline globally | Use `:focus-visible` with custom outline |
| Color-only error indication | Add icon + text alongside color |
| Fixed-width layouts | Use responsive `max-width` + `width: 100%` |
| Tiny touch targets | Minimum 24px, 44px for touch devices |

**Incorrect — Insufficient text contrast:**
```css
.secondary-text {
  color: #b3b3b3;  /* 2.1:1 ratio on white - fails WCAG AA */
}
```

**Correct — Meeting 4.5:1 contrast minimum:**
```css
.secondary-text {
  color: #595959;  /* 7.0:1 ratio on white - passes WCAG AA */
}
```


### Prefer native HTML elements over custom ARIA widgets for built-in accessibility — CRITICAL


# Native HTML First (2026 Best Practice)

## Principle

Use the platform. Native HTML elements (`&lt;dialog&gt;`, `<details>`, `&lt;select&gt;`, `&lt;button&gt;`) ship with keyboard handling, focus management, and screen reader announcements built in. Custom ARIA widgets should only be used when **no native equivalent exists**.

## Native Element Replacements

| Instead of... | Use native | Why |
|--------------|-----------|-----|
| Custom modal + focus trap JS | `&lt;dialog&gt;` + `showModal()` | Built-in focus trap, Escape close, inert backdrop |
| Custom accordion + ARIA | `<details>` / `<summary>` | Built-in expand/collapse, keyboard, screen reader |
| Custom dropdown + listbox ARIA | `&lt;select&gt;` | Built-in keyboard nav, mobile-optimized |
| Custom toggle + aria-checked | `&lt;input type="checkbox"&gt;` | Built-in state, label association, form submission |
| `<div onClick>` | `&lt;button&gt;` | Built-in focus, Enter/Space, role announcement |

## Dialog — Use `&lt;dialog&gt;` + `showModal()`

**Incorrect — Custom modal with manual focus trap:**
```tsx
function Modal({ isOpen, onClose, children }) {
  const ref = useRef(null);
  useEffect(() => {
    if (isOpen) ref.current?.focus();
    // manual focus trap, Escape handler, inert siblings...
  }, [isOpen]);

  return isOpen ? (
    <div role="dialog" aria-modal="true" ref={ref} tabIndex={-1}>
      <div className="backdrop" onClick={onClose} />
      {children}
    </div>
  ) : null;
}
```

**Correct — Native `&lt;dialog&gt;` with built-in focus management:**
```tsx
function Modal({ children }) {
  const dialogRef = useRef<HTMLDialogElement>(null);

  return (
    <dialog ref={dialogRef} onClose={() => dialogRef.current?.close()}>
      {children}
      <button onClick={() => dialogRef.current?.close()}>Close</button>
    </dialog>
  );
}

// Open with showModal() for built-in focus trap + backdrop + Escape
dialogRef.current?.showModal();
```

## Accordion — Use `<details>` / `<summary>`

**Incorrect — Custom accordion with ARIA:**
```tsx
<div role="region">
  <button aria-expanded={open} aria-controls="panel-1"
    onClick={() => setOpen(!open)}>
    Section Title
  </button>
  <div id="panel-1" role="region" hidden={!open}>{content}</div>
</div>
```

**Correct — Native `<details>` with CSS styling:**
```tsx
<details>
  <summary>Section Title</summary>
  <div className="panel">{content}</div>
</details>
```

```css
details summary { cursor: pointer; padding: 0.75rem; font-weight: 600; }
details[open] summary { border-bottom: 1px solid var(--border); }
details summary::marker { content: ''; } /* Note: ::marker on <summary> is supported in Chrome 89+, Firefox 68+, Safari 15.4+ */
details summary::after { content: '\25B6'; transition: transform 0.2s; }
details[open] summary::after { transform: rotate(90deg); }
```

## When Custom ARIA Is Justified

Use custom ARIA only when native elements cannot meet the requirement:

| Use case | Why native fails | ARIA approach |
|----------|-----------------|---------------|
| Combobox with async search | `&lt;datalist&gt;` lacks async, filtering control | `role="combobox"` + `useComboBox` |
| Tab panel widget | No native tab element | `role="tablist"` + `role="tab"` |
| Tree view | No native tree element | `role="tree"` + `role="treeitem"` |
| Menu with submenus | `&lt;menu&gt;` has limited support | `role="menu"` + `role="menuitem"` |

## Audit Checklist

- [ ] Every `role="dialog"` — can it be `&lt;dialog&gt;`?
- [ ] Every custom accordion — can it be `<details>`?
- [ ] Every `<div onClick>` — should it be `&lt;button&gt;` or `<a>`?
- [ ] Every custom select — does `&lt;select&gt;` + CSS suffice?
- [ ] ARIA attributes are only used where no native equivalent exists


### Use semantic HTML and ARIA attributes for proper screen reader document structure — CRITICAL


# Semantic HTML & ARIA (WCAG 1.3.1, 4.1.2)

## Document Structure

```tsx
<main>
  <article>
    <header><h1>Page Title</h1></header>
    <section aria-labelledby="features-heading">
      <h2 id="features-heading">Features</h2>
      <ul><li>Feature 1</li></ul>
    </section>
    <aside aria-label="Related content">...</aside>
  </article>
</main>
```

## Heading Hierarchy

Always follow h1-h6 order without skipping levels:

```tsx
// CORRECT
<h1>Page Title</h1>
  <h2>Section</h2>
    <h3>Subsection</h3>

// FORBIDDEN: Skipping levels
<h1>Page Title</h1>
  <h3>Subsection</h3>  // Skipped h2!
```

## ARIA Labels and States

```tsx
// Icon-only button
<button aria-label="Save document">
  <svg aria-hidden="true">...</svg>
</button>

// Form field with error
<input
  id="email"
  aria-required="true"
  aria-invalid={!!error}
  aria-describedby={error ? "email-error" : "email-hint"}
/>
{error && <p id="email-error" role="alert">{error}</p>}

// Custom widget with explicit role
<div
  role="switch"
  aria-checked={isEnabled}
  aria-label="Enable notifications"
  tabIndex={0}
  onClick={handleToggle}
  onKeyDown={(e) => {
    if (e.key === ' ' || e.key === 'Enter') handleToggle();
  }}
/>
```

## Form Structure

```tsx
<form>
  <fieldset>
    <legend>Shipping Address</legend>
    <label htmlFor="street">Street</label>
    <input id="street" type="text" autoComplete="street-address" />
  </fieldset>
</form>
```

## Live Regions

```tsx
// Polite: status updates (default, avoids interruption)
<div role="status" aria-live="polite" aria-atomic="true">
  {items.length} items in cart
</div>

// Assertive: errors that need immediate announcement
<div role="alert" aria-live="assertive">
  {error}
</div>
```

## Page Language

```html
<html lang="en">
  <body>
    <p>The French phrase <span lang="fr">Je ne sais quoi</span> means...</p>
  </body>
</html>
```

## Skip Links

```tsx
<a href="#main-content" className="skip-link">
  Skip to main content
</a>
<nav>...</nav>
<main id="main-content" tabIndex={-1}>
  {children}
</main>
```

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

## WCAG 2.2 AA Checklist

| Criterion | Requirement | Test |
|-----------|-------------|------|
| 1.1.1 Non-text | Alt text for images | axe-core scan |
| 1.3.1 Info | Semantic HTML, headings | Manual + automated |
| 1.4.3 Contrast | 4.5:1 text, 3:1 large | WebAIM checker |
| 2.1.1 Keyboard | All functionality via keyboard | Tab through |
| 2.4.3 Focus Order | Logical tab sequence | Manual test |
| 2.4.7 Focus Visible | Clear focus indicator | Visual check |
| 2.4.11 Focus Not Obscured | Focus not hidden by sticky elements | scroll-margin-top |
| 2.5.8 Target Size | Min 24x24px interactive | CSS audit |
| 4.1.2 Name/Role/Value | Proper ARIA, labels | Screen reader test |

## Anti-Patterns

- **Div soup**: Using `<div>` where `&lt;nav&gt;`, `&lt;main&gt;`, `&lt;article&gt;` should be used
- **Empty links/buttons**: Interactive elements without accessible names
- **ARIA overuse**: Using ARIA when semantic HTML suffices (prefer `&lt;button&gt;` over `<div role="button">`)
- **Positive tabindex**: Using `tabIndex > 0` disrupts natural tab order
- **Decorative images without alt=""**: Must use `alt=""` or `role="presentation"`

**Incorrect — Skipping heading levels:**
```tsx
<h1>Page Title</h1>
<h3>Subsection</h3>  {/* Skipped h2 */}
// Screen readers rely on heading hierarchy
```

**Correct — Following h1-h6 order without skipping:**
```tsx
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
```


### Test accessibility compliance with axe-core automation and manual screen reader verification — CRITICAL


# Accessibility Testing

## Automated Testing with axe-core

### Component-Level (jest-axe)

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

expect.extend(toHaveNoViolations);

test('form has no accessibility violations', async () => {
  const { container } = render(<AccessibleForm />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});
```

### Page-Level (Playwright + axe-core)

```typescript
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('should not have any automatically detectable accessibility issues', async ({ page }) => {
  await page.goto('/');
  const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
  expect(accessibilityScanResults.violations).toEqual([]);
});
```

### ESLint Plugin

```bash
npm install --save-dev eslint-plugin-jsx-a11y
```

Catches issues during development: missing alt text, missing labels, invalid ARIA attributes.

## Screen Reader Testing

Test with at least one screen reader:

| Platform | Screen Reader | How to Enable |
|----------|--------------|---------------|
| Windows | NVDA (free) | [nvaccess.org](https://www.nvaccess.org/) |
| Windows | JAWS | [freedomscientific.com](https://www.freedomscientific.com/) |
| macOS/iOS | VoiceOver | Cmd+F5 to enable |
| Android | TalkBack | Built-in |

### Verification Steps

- Navigate with Tab key, verify focus indicators
- Navigate with arrow keys (for custom widgets)
- Verify all images/icons are announced correctly
- Verify form labels are announced
- Verify error messages are announced via `role="alert"`
- Verify dynamic content updates are announced via `aria-live`
- Verify headings provide proper page structure
- Verify links are descriptive when read out of context

## Manual Keyboard Testing

1. Navigate entire UI with keyboard only (no mouse)
2. Verify all interactive elements are reachable via Tab
3. Test Tab, Shift+Tab, Arrow keys, Enter, Escape, Space
4. Verify focus order follows visual/logical reading order
5. Verify focus indicators are visible on all interactive elements
6. Verify focus does not get trapped (except in modals, which need Escape)
7. Check that focus returns after closing modals/menus

## Automated Testing Tools

| Tool | Purpose | Coverage |
|------|---------|----------|
| **axe DevTools** | Browser extension | ~30-50% of WCAG issues |
| **Lighthouse** | Accessibility audit | Built into Chrome DevTools |
| **WAVE** | Visual feedback | Page-level audit |
| **ESLint jsx-a11y** | Catches issues during development | Code-level |
| **Playwright + axe** | CI/CD automated regression | Page-level |

## CI/CD Integration

```yaml
# GitHub Actions example
- name: Run accessibility tests
  run: npx playwright test --grep @a11y
```

## Common Mistakes

| Mistake | Fix |
|---------|-----|
| Only relying on automated tests | Automated tests catch 30-50%; manual + screen reader testing required |
| Testing only happy path | Test error states, loading states, empty states |
| Not testing keyboard navigation | Tab through entire flow manually |
| Ignoring screen reader announcements | Test with NVDA/VoiceOver for dynamic content |

**Incorrect — Only running automated tests:**
```typescript
test('accessibility', async ({ page }) => {
  await page.goto('/');
  const results = await new AxeBuilder({ page }).analyze();
  expect(results.violations).toEqual([]);
  // Only catches ~30-50% of issues
});
```

**Correct — Combining automated + manual testing:**
```typescript
test('accessibility - automated', async ({ page }) => {
  await page.goto('/');
  const results = await new AxeBuilder({ page }).analyze();
  expect(results.violations).toEqual([]);
});

// Plus manual checklist:
// - Tab through all interactive elements
// - Test with screen reader (NVDA/VoiceOver)
// - Verify focus indicators visible
// - Test error state announcements
```


### Honor all user preferences including motion, color scheme, contrast, and zoom — HIGH


# User Preferences (2026 Best Practices)

## Principle

Users configure their OS for a reason. Honor every preference: reduced motion, color scheme, high contrast, contrast level, and text zoom. These are not optional enhancements — they are accessibility requirements.

## `prefers-reduced-motion`

Disable or shorten animations for users with vestibular disorders.

**Incorrect — Ignoring motion preference:**
```css
.card {
  transition: transform 0.5s ease;
}
.card:hover {
  transform: scale(1.1) rotate(2deg);
}
```

**Correct — Respecting reduced motion:**
```css
.card {
  transition: transform 0.3s ease;
}
.card:hover {
  transform: scale(1.05);
}

@media (prefers-reduced-motion: reduce) {
  .card {
    transition: none;
  }
  .card:hover {
    transform: none;
  }
}
```

For JS: check `window.matchMedia('(prefers-reduced-motion: reduce)').matches` before animating.

## `prefers-color-scheme`

**Incorrect:** `body \{ background: #ffffff; color: #1a1a1a; \}` — ignores user preference.

**Correct — Adaptive color scheme with CSS custom properties:**
```css
:root { color-scheme: light dark; --bg: #ffffff; --text: #1a1a1a; }
@media (prefers-color-scheme: dark) {
  :root { --bg: #111827; --text: #f3f4f6; }
}
body { background: var(--bg); color: var(--text); }
```

## `forced-colors` (Windows High Contrast)

**Incorrect — Ignoring forced-colors mode:**
```css
.button {
  background: var(--brand-blue);
  border: none;
}
/* In High Contrast mode: button becomes invisible */
```

**Correct — Supporting forced-colors mode:**
```css
.button {
  background: var(--brand-blue);
  border: 2px solid transparent; /* becomes visible in forced-colors */
}

@media (forced-colors: active) {
  .button {
    border-color: ButtonText;
    forced-color-adjust: none; /* Opt out only when custom treatment needed */
  }

  .icon {
    forced-color-adjust: auto; /* Let system colors apply */
  }
}
```

Key `forced-colors` rules:
- Always use `border` (not just `background`) for interactive elements
- Test with Windows High Contrast Mode enabled
- Use system color keywords: `ButtonText`, `Canvas`, `LinkText`, `Highlight`

## `prefers-contrast`

Increase or decrease contrast beyond WCAG minimums on user request.

```css
@media (prefers-contrast: more) {
  :root {
    --text: #000000;
    --bg: #ffffff;
    --border: #000000;
  }
  button {
    border-width: 3px;
  }
}

@media (prefers-contrast: less) {
  :root {
    --text: #333333;
    --bg: #fafafa;
    --border: #cccccc;
  }
}
```

## Text Size and Zoom

Content must remain usable at 200% zoom (WCAG 1.4.4) and with user font-size overrides.

**Incorrect:** `width: 960px; font-size: 14px;` — fixed sizes break zoom.

**Correct — Relative units that respect zoom:**
```css
.container { max-width: 60rem; width: 100%; }
.text { font-size: 0.875rem; line-height: 1.5; }
.content { overflow-wrap: break-word; overflow: visible; }
```

## Audit Checklist

- [ ] All animations wrapped in `prefers-reduced-motion` check
- [ ] Dark mode supported via `prefers-color-scheme`
- [ ] Tested in Windows High Contrast Mode (`forced-colors: active`)
- [ ] `prefers-contrast: more` increases border widths and text contrast
- [ ] Page usable at 200% browser zoom without horizontal scroll
- [ ] All text uses `rem`/`em` units, never `px` for font-size



---

## References (2)

### Ork Delta

# Accessibility Skill: OrchestKit Delta

Ork-specific floors, scars, and house decisions for `src/skills/accessibility`.
Vendor mechanics (WCAG 2.2 criterion walkthroughs, ARIA Authoring Practices
widget patterns, React Aria hook APIs, generic focus-trap algorithms) are
deliberately not restated here. See the section "Upstream coverage (do not
restate)" in SKILL.md for the first-party source that owns each removed topic.

## Enforce the house cognitive-load ceilings, not just WCAG normative text
Why: House decision carried in this skill since v2.1 and asserted by the test-cases.json case `wcag-cognitive-inclusion` until the 2026-07-31 wrap-plus-delta thinning removed the rule file it traced to (the case was retired with the rule file because `bin/validate-test-case-rules.sh` requires the pair to exist together): at most 1 visible notification at a time (queue the rest, expose the overflow count in an sr-only element), 7 or fewer primary navigation items, paragraphs of 2 to 4 sentences, sentences under 25 words, grade 8 reading level for general content, multi-step wizards with progress indicators instead of long single-page forms, and session-timeout warnings with an extend option. WCAG 2.2 AA has no normative thresholds for any of these; without the numbers, reviews regress to vague "reduce cognitive load" advice. The companion cognitive-science numbers (Miller 4 plus or minus 1, Hick, 400ms Doherty) live in `references/ux-thresholds-quick.md`.
Upstream: W3C "Making Content Usable for People with Cognitive and Learning Disabilities", https://www.w3.org/TR/coga-usable/

## Build overlays as React Aria hooks inside FocusScope, animated with the shared motion presets
Why: House recipe, recorded 2026-07-31 when the aria-overlays rule and the examples restating it were thinned: pair `useModalOverlay` + `useDialog` + `useOverlayTriggerState` with `&lt;FocusScope contain restoreFocus autoFocus&gt;`, and wrap the overlay in `AnimatePresence` using the shared `modalBackdrop` / `modalContent` / `fadeIn` presets from `@/lib/animations` (the animation-motion-design skill's convention), so exit animations and focus restoration do not fight each other. React Aria's docs do not cover the motion/react integration, and hand-rolled `useEffect` + `ref.focus()` modal focus is on this SKILL.md's forbidden list. Working code survives in `scripts/focus-trap-template.tsx` under src/skills/accessibility.
Upstream: https://react-spectrum.adobe.com/react-aria/useModalOverlay.html

## Reach for scripts/focus-trap-template.tsx before re-deriving trap or restoration logic
Why: House decision from the 2026-07-31 thinning of src/skills/accessibility: keep exactly one hand-rolled implementation of the focusable-element selector and the Tab/Shift+Tab wrap plus trigger-restore logic, in `scripts/focus-trap-template.tsx`, for the rare case React Aria is unavailable. Before the thinning the same selector was quadruplicated (the template plus three now-deleted files: focus-examples, focus-patterns, and the focus-trap rule), and copies drift: the modal example in the surviving `examples/wcag-examples.md` still uses a shorter selector (`a[href], button, textarea, input, select`) that misses `[contenteditable]` and `[tabindex]` elements, so its trap skips focusables the canonical selector catches. Use FocusScope first, the template second, and never a fresh derivation.
Upstream: https://react-spectrum.adobe.com/react-aria/FocusScope.html


### Ux Thresholds Quick

# UI/UX Thresholds — Cognitive Science Quick Reference

## Contrast & Color
- Text on background: >= 4.5:1 (normal), >= 3:1 (large text >= 18pt)
- UI components: >= 3:1 against adjacent colors
- Focus indicators: >= 3:1 contrast, minimum 2px perimeter
- Never use color as sole information carrier

## Touch & Targets
- Touch devices: minimum 44x44px interactive targets
- Desktop: minimum 24x24px, no adjacent target within 24px
- Primary CTA in thumb zone (bottom 2/3 of mobile screen)
- Destructive actions: smaller targets or require confirmation (Fitts's Law)

## Cognitive Load
- Max 5-7 items in any list/menu before grouping (Miller's Law 4±1)
- Decision time doubles per doubling of options (Hick's Law) — use progressive disclosure
- Acknowledge interactions within 400ms (Doherty Threshold)
- Recognition over recall: show options, don't ask users to remember

## Typography & Readability
- Line length: 50-75 characters (use `max-width: 65ch`)
- Line-height: 1.4-1.6x for body text
- No true black (#000) on pure white (#fff) — temper contrast

## Forms & Errors
- Top-aligned labels (optimal for all contexts)
- Error messages: name the field + describe cause + suggest fix
- Blame the system, not the user ("We couldn't process..." not "Invalid input")
- Inline validation on blur, not on keystroke
- Mark optional fields, not required (invert the assumption)

## Dark Pattern Red Flags
Reject these 13 patterns: confirmshaming, roach motel, misdirection, hidden costs,
trick questions, disguised ads, forced continuity, friend spam, privacy zuckering,
bait-and-switch, false urgency, nagging, visual interference.



---

## Examples (1)

### Wcag Examples

# WCAG Compliance Code Examples

Complete, production-ready examples of accessible patterns.

---

## 1. Accessible Form with Validation

Full form with labels, error handling, and live region announcements.

```tsx
import { useState } from 'react';
import { z } from 'zod';

const FormSchema = z.object({
  email: z.email('Please enter a valid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  agreeToTerms: z.boolean().refine((val) => val === true, {
    message: 'You must agree to the terms',
  }),
});

type FormData = z.infer<typeof FormSchema>;

export function AccessibleForm() {
  const [formData, setFormData] = useState<FormData>({
    email: '',
    password: '',
    agreeToTerms: false,
  });
  const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
  const [submitStatus, setSubmitStatus] = useState<string>('');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    const result = FormSchema.safeParse(formData);

    if (!result.success) {
      const fieldErrors: Partial<Record<keyof FormData, string>> = {};
      result.error.issues.forEach((issue) => {
        const field = issue.path[0] as keyof FormData;
        fieldErrors[field] = issue.message;
      });
      setErrors(fieldErrors);
      setSubmitStatus('Please correct the errors below');
      return;
    }

    setErrors({});
    setSubmitStatus('Form submitted successfully!');
    // Submit form...
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      <h1>Create Account</h1>

      {/* Status message - announced by screen readers */}
      {submitStatus && (
        <div
          role="status"
          aria-live="polite"
          aria-atomic="true"
          className="mb-4 p-3 rounded bg-blue-50 text-blue-900"
        >
          {submitStatus}
        </div>
      )}

      {/* Email field */}
      <div className="mb-4">
        <label htmlFor="email" className="block mb-1 font-medium">
          Email <span aria-label="required">*</span>
        </label>
        <input
          type="email"
          id="email"
          name="email"
          autoComplete="email"
          value={formData.email}
          onChange={(e) => setFormData({ ...formData, email: e.target.value })}
          aria-required="true"
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? 'email-error' : 'email-hint'}
          className={`w-full px-3 py-2 border rounded ${
            errors.email ? 'border-red-600' : 'border-gray-300'
          }`}
        />
        <p id="email-hint" className="text-sm text-gray-600 mt-1">
          We'll never share your email
        </p>
        {errors.email && (
          <p id="email-error" role="alert" className="text-red-600 text-sm mt-1">
            {errors.email}
          </p>
        )}
      </div>

      {/* Password field */}
      <div className="mb-4">
        <label htmlFor="password" className="block mb-1 font-medium">
          Password <span aria-label="required">*</span>
        </label>
        <input
          type="password"
          id="password"
          name="password"
          autoComplete="new-password"
          value={formData.password}
          onChange={(e) => setFormData({ ...formData, password: e.target.value })}
          aria-required="true"
          aria-invalid={!!errors.password}
          aria-describedby={errors.password ? 'password-error' : 'password-hint'}
          className={`w-full px-3 py-2 border rounded ${
            errors.password ? 'border-red-600' : 'border-gray-300'
          }`}
        />
        <p id="password-hint" className="text-sm text-gray-600 mt-1">
          Must be at least 8 characters
        </p>
        {errors.password && (
          <p id="password-error" role="alert" className="text-red-600 text-sm mt-1">
            {errors.password}
          </p>
        )}
      </div>

      {/* Checkbox */}
      <div className="mb-4">
        <label className="flex items-start gap-2">
          <input
            type="checkbox"
            checked={formData.agreeToTerms}
            onChange={(e) => setFormData({ ...formData, agreeToTerms: e.target.checked })}
            aria-required="true"
            aria-invalid={!!errors.agreeToTerms}
            aria-describedby={errors.agreeToTerms ? 'terms-error' : undefined}
            className="mt-1 w-5 h-5"
          />
          <span>
            I agree to the <a href="/terms" className="text-blue-600 underline">terms and conditions</a>
            <span aria-label="required"> *</span>
          </span>
        </label>
        {errors.agreeToTerms && (
          <p id="terms-error" role="alert" className="text-red-600 text-sm mt-1">
            {errors.agreeToTerms}
          </p>
        )}
      </div>

      {/* Submit button */}
      <button
        type="submit"
        className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
      >
        Create Account
      </button>
    </form>
  );
}
```

**Key accessibility features:**
- All inputs have associated labels with `htmlFor`
- Required fields marked with `aria-required="true"`
- Invalid fields marked with `aria-invalid="true"`
- Error messages use `role="alert"` for immediate announcement
- Error messages linked with `aria-describedby`
- Hint text linked with `aria-describedby`
- Status message uses `role="status"` with `aria-live="polite"`
- Visible focus indicators
- AutoComplete attributes for password managers

---

## 2. Accessible Modal Dialog

Modal with focus trap, Esc to close, and backdrop click handling.

```tsx
import { useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { modalBackdrop, modalContent } from '@/lib/animations';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

export function AccessibleModal({ isOpen, onClose, title, children }: ModalProps) {
  const modalRef = useRef<HTMLDivElement>(null);
  const triggerElementRef = useRef<HTMLElement | null>(null);

  // Store the element that opened the modal
  useEffect(() => {
    if (isOpen) {
      triggerElementRef.current = document.activeElement as HTMLElement;
    }
  }, [isOpen]);

  // Focus trap and Esc key handler
  useEffect(() => {
    if (!isOpen) return;

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        onClose();
        return;
      }

      if (e.key === 'Tab') {
        const modal = modalRef.current;
        if (!modal) return;

        const focusableElements = modal.querySelectorAll<HTMLElement>(
          'a[href], button:not([disabled]), textarea, input, select'
        );
        const firstElement = focusableElements[0];
        const lastElement = focusableElements[focusableElements.length - 1];

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

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

  // Focus first element when modal opens
  useEffect(() => {
    if (isOpen && modalRef.current) {
      const firstFocusable = modalRef.current.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      firstFocusable?.focus();
    }
  }, [isOpen]);

  // Return focus to trigger element when modal closes
  useEffect(() => {
    if (!isOpen && triggerElementRef.current) {
      triggerElementRef.current.focus();
      triggerElementRef.current = null;
    }
  }, [isOpen]);

  return (
    <AnimatePresence>
      {isOpen && (
        <>
          {/* Backdrop */}
          <motion.div
            {...modalBackdrop}
            className="fixed inset-0 z-50 bg-black/50"
            onClick={onClose}
            aria-hidden="true"
          />

          {/* Modal */}
          <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
            <motion.div
              {...modalContent}
              ref={modalRef}
              role="dialog"
              aria-modal="true"
              aria-labelledby="modal-title"
              className="relative bg-white rounded-lg shadow-xl max-w-md w-full p-6"
            >
              {/* Title */}
              <h2 id="modal-title" className="text-xl font-semibold mb-4">
                {title}
              </h2>

              {/* Content */}
              <div className="mb-6">{children}</div>

              {/* Close button */}
              <div className="flex justify-end gap-2">
                <button
                  onClick={onClose}
                  className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
                >
                  Close
                </button>
              </div>

              {/* Close icon button */}
              <button
                onClick={onClose}
                aria-label="Close dialog"
                className="absolute top-4 right-4 p-2 rounded hover:bg-gray-100 focus-visible:outline focus-visible:outline-2"
              >
                <svg
                  aria-hidden="true"
                  width="20"
                  height="20"
                  viewBox="0 0 20 20"
                  fill="currentColor"
                >
                  <path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
                </svg>
              </button>
            </motion.div>
          </div>
        </>
      )}
    </AnimatePresence>
  );
}
```

**Key accessibility features:**
- `role="dialog"` and `aria-modal="true"`
- Title linked with `aria-labelledby`
- Focus trapped within modal
- Esc key closes modal
- Focus returns to trigger element on close
- Close button has `aria-label`
- Backdrop click closes modal
- First focusable element receives focus on open

---

## 3. Skip Navigation Link

Allow keyboard users to bypass repeated navigation.

```tsx
export function SkipLink() {
  return (
    <a
      href="#main-content"
      className="skip-link"
    >
      Skip to main content
    </a>
  );
}

// In your layout component:
export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <SkipLink />
      <header>
        <nav>
          {/* Navigation links */}
        </nav>
      </header>
      <main id="main-content" tabIndex={-1}>
        {children}
      </main>
    </>
  );
}
```

```css
/* styles/globals.css */
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: #000;
  color: #fff;
  padding: 8px;
  text-decoration: none;
  z-index: 100;
}

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

---

## 4. Accessible Tab Component

Tabs with keyboard navigation (arrow keys, Home, End).

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

interface TabProps {
  tabs: { id: string; label: string; content: React.ReactNode }[];
}

export function AccessibleTabs({ tabs }: TabProps) {
  const [activeTab, setActiveTab] = useState(0);
  const tabListRef = useRef<HTMLDivElement>(null);

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

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

    setActiveTab(newIndex);

    // Focus the new tab
    const newTab = tabListRef.current?.children[newIndex] as HTMLElement;
    newTab?.focus();
  };

  return (
    <div>
      {/* Tab list */}
      <div
        ref={tabListRef}
        role="tablist"
        aria-label="Content sections"
        className="flex border-b border-gray-300"
      >
        {tabs.map((tab, index) => (
          <button
            key={tab.id}
            role="tab"
            id={`tab-${tab.id}`}
            aria-selected={activeTab === index}
            aria-controls={`panel-${tab.id}`}
            tabIndex={activeTab === index ? 0 : -1}
            onClick={() => setActiveTab(index)}
            onKeyDown={(e) => handleKeyDown(e, index)}
            className={`px-4 py-2 font-medium focus-visible:outline focus-visible:outline-2 ${
              activeTab === index
                ? 'text-blue-600 border-b-2 border-blue-600'
                : 'text-gray-600 hover:text-gray-900'
            }`}
          >
            {tab.label}
          </button>
        ))}
      </div>

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

**Key accessibility features:**
- `role="tablist"`, `role="tab"`, `role="tabpanel"`
- `aria-selected` indicates active tab
- `aria-controls` links tab to panel
- Only active tab is focusable (`tabIndex=\{0\}`)
- Arrow keys navigate between tabs
- Home/End keys jump to first/last tab
- Panels hidden with `hidden` attribute (not CSS display:none)

---

## 5. Focus Management in Complex Widgets

Custom dropdown with roving tabindex.

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

interface DropdownProps {
  label: string;
  options: string[];
  value: string;
  onChange: (value: string) => void;
}

export function AccessibleDropdown({ label, options, value, onChange }: DropdownProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [focusedIndex, setFocusedIndex] = useState(0);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const listRef = useRef<HTMLUListElement>(null);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
        } else {
          setFocusedIndex((prev) => (prev + 1) % options.length);
        }
        break;
      case 'ArrowUp':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
        } else {
          setFocusedIndex((prev) => (prev - 1 + options.length) % options.length);
        }
        break;
      case 'Enter':
      case ' ':
        e.preventDefault();
        if (isOpen) {
          onChange(options[focusedIndex]);
          setIsOpen(false);
          buttonRef.current?.focus();
        } else {
          setIsOpen(true);
        }
        break;
      case 'Escape':
        e.preventDefault();
        setIsOpen(false);
        buttonRef.current?.focus();
        break;
    }
  };

  // Focus first option when opening
  useEffect(() => {
    if (isOpen) {
      setFocusedIndex(options.indexOf(value));
    }
  }, [isOpen, value, options]);

  return (
    <div className="relative">
      <button
        ref={buttonRef}
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={handleKeyDown}
        aria-haspopup="listbox"
        aria-expanded={isOpen}
        aria-labelledby="dropdown-label"
        className="w-full px-4 py-2 text-left bg-white border border-gray-300 rounded focus-visible:outline focus-visible:outline-2"
      >
        <span id="dropdown-label" className="sr-only">{label}</span>
        {value}
      </button>

      {isOpen && (
        <ul
          ref={listRef}
          role="listbox"
          aria-labelledby="dropdown-label"
          onKeyDown={handleKeyDown}
          className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded shadow-lg max-h-60 overflow-auto"
        >
          {options.map((option, index) => (
            <li
              key={option}
              role="option"
              aria-selected={option === value}
              onClick={() => {
                onChange(option);
                setIsOpen(false);
                buttonRef.current?.focus();
              }}
              className={`px-4 py-2 cursor-pointer ${
                index === focusedIndex ? 'bg-blue-100' : ''
              } ${option === value ? 'bg-blue-50 font-semibold' : ''}`}
            >
              {option}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
```

**Key accessibility features:**
- `role="listbox"` and `role="option"`
- `aria-haspopup="listbox"` on trigger
- `aria-expanded` indicates open/closed state
- `aria-selected` on current option
- Arrow keys navigate options
- Enter/Space selects option
- Esc closes dropdown and returns focus
- Focus returns to trigger on close

---

## 6. Live Region for Dynamic Updates

Announce changes to screen reader users without interrupting.

```tsx
import { useState, useEffect } from 'react';

export function ShoppingCart() {
  const [items, setItems] = useState<string[]>([]);
  const [statusMessage, setStatusMessage] = useState('');

  const addItem = (item: string) => {
    setItems([...items, item]);
    setStatusMessage(`${item} added to cart. ${items.length + 1} items total.`);
  };

  const removeItem = (index: number) => {
    const removedItem = items[index];
    setItems(items.filter((_, i) => i !== index));
    setStatusMessage(`${removedItem} removed from cart. ${items.length - 1} items total.`);
  };

  return (
    <div>
      <h2>Shopping Cart</h2>

      {/* Live region for status updates */}
      <div
        role="status"
        aria-live="polite"
        aria-atomic="true"
        className="sr-only"
      >
        {statusMessage}
      </div>

      {/* Visible cart count */}
      <p aria-hidden="true">
        {items.length} {items.length === 1 ? 'item' : 'items'} in cart
      </p>

      <ul>
        {items.map((item, index) => (
          <li key={index} className="flex justify-between items-center py-2">
            <span>{item}</span>
            <button
              onClick={() => removeItem(index)}
              aria-label={`Remove ${item} from cart`}
              className="px-3 py-1 bg-red-600 text-white rounded"
            >
              Remove
            </button>
          </li>
        ))}
      </ul>

      <button
        onClick={() => addItem('Product ' + (items.length + 1))}
        className="px-4 py-2 bg-blue-600 text-white rounded"
      >
        Add Item
      </button>
    </div>
  );
}
```

**Key accessibility features:**
- `role="status"` with `aria-live="polite"` announces changes
- `aria-atomic="true"` ensures entire message is read
- `.sr-only` class hides visual duplicate
- Remove buttons have descriptive `aria-label`

---

## 7. Accessible Error Summary

Error summary at top of form that links to fields with errors.

```tsx
interface ErrorSummaryProps {
  errors: Record<string, string>;
}

export function ErrorSummary({ errors }: ErrorSummaryProps) {
  const errorEntries = Object.entries(errors);

  if (errorEntries.length === 0) return null;

  return (
    <div
      role="alert"
      aria-labelledby="error-summary-title"
      className="mb-6 p-4 bg-red-50 border-l-4 border-red-600 rounded"
    >
      <h2 id="error-summary-title" className="text-lg font-semibold text-red-900 mb-2">
        There {errorEntries.length === 1 ? 'is' : 'are'} {errorEntries.length}{' '}
        {errorEntries.length === 1 ? 'error' : 'errors'} in this form
      </h2>
      <ul className="list-disc list-inside space-y-1">
        {errorEntries.map(([field, message]) => (
          <li key={field}>
            <a
              href={`#${field}`}
              className="text-red-900 underline hover:text-red-700"
              onClick={(e) => {
                e.preventDefault();
                document.getElementById(field)?.focus();
              }}
            >
              {message}
            </a>
          </li>
        ))}
      </ul>
    </div>
  );
}
```

**Key accessibility features:**
- `role="alert"` announces errors immediately
- Links to fields with errors
- Clicking link focuses the field
- Descriptive error count

---

## Resources

- [WCAG 2.2 Spec](https://www.w3.org/TR/WCAG22/)
- [WAI-ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/)
- [Radix UI Primitives](https://www.radix-ui.com/) - Accessible components
- [Inclusive Components](https://inclusive-components.design/)
- [A11y Project Checklist](https://www.a11yproject.com/checklist/)

---

**Version**: 1.0.0
**Last Updated**: 2026-01-16
