wcag-keyboard-navigation

Use when building or reviewing keyboard-only support or a modal, drawer or popover's focus handling: WCAG 2.2 keyboard access, no keyboard traps, shortcuts, focus order, visible and unobscured focus, with test scripts and the focus-trap pattern (focus on open, Escape, restore).

/plugin install qa-accessibility@testland-qa

First time in Claude Code? Add the marketplace with /plugin marketplace add testland/qa

View source (opens in a new tab)

Reference catalog for how to verify keyboard-navigation conformance. Pairs with the scanner umbrella axe-a11y (axe-core, pa11y, Lighthouse a11y).

Overview

WCAG 2.2 organizes accessibility into four principles - Perceivable / Operable / Understandable / Robust - at three conformance levels - A / AA / AAA (wcag22 (opens in new window)).

Keyboard navigation lives under Operable (Principle 2) and spans six key Success Criteria covering keyboard operability, focus management, and focus visibility.

When to use

  • Reviewing a component's keyboard interaction surface during PR review.
  • Authoring a keyboard-navigation test plan for a new component.
  • Triaging an accessibility audit finding tagged with one of the SCs below.
  • Configuring per-rule severities for axe-a11y.

Success Criteria

SC 2.1.1 - Keyboard (Level A)

Per wcag22 (opens in new window): "All functionality of the content is operable through a keyboard interface" without requiring specific timings. Path-dependent input (handwriting) is exempt.

PatternWhat to test
<button>, <a href>, <input>Native - already keyboard-accessible.
<div onclick="...">Anti-pattern. Non-focusable; not keyboard-operable. Convert to <button>.
Custom widget (role="button", tabindex="0")Verify Enter / Space activates the same handler.
Drag-and-dropProvide a keyboard alternative (arrow keys + Enter, or button-based reorder).

Test script (Playwright):

test('SC 2.1.1 - interactive elements are keyboard-operable', async ({ page }) => {
  // Tab to the button, activate with Enter
  await page.keyboard.press('Tab');
  await page.keyboard.press('Enter');
  await expect(page.locator('[data-testid="action-result"]')).toBeVisible();

  // Repeat with Space for elements that should accept Space
  await page.keyboard.press('Tab');
  await page.keyboard.press('Space');
  await expect(...).toBeVisible();
});

SC 2.1.2 - No Keyboard Trap (Level A)

Per wcag22 (opens in new window): "Focus must be movable away using standard methods; users should be informed of exit procedures if non- standard keys are required."

Common failureFix
Modal traps Tab without an explicit Escape handlerAdd Escape handler that closes the modal and restores focus.
Embedded <iframe> (e.g. third-party widget) traps TabSet tabindex="-1" on the iframe OR document the exit (Esc + Tab) in surrounding label.
Custom date picker locks focus inside on first focusProvide Tab to exit the calendar and continue to the next field.

See references/focus-trap.md for the intentional-trap convention (modal focus management) which is distinct from a violation.

SC 2.1.4 - Character Key Shortcuts (Level A, added in 2.1)

Single-character shortcuts (/ to focus search, j/k to navigate items) must offer at least one of:

  • Off mechanism - user can disable the shortcut globally.
  • Remap mechanism - user can rebind to non-character or modifier-prefixed combinations.
  • Active only on focus - shortcut active only when the associated component has focus.

This SC exists because users with speech-input software produce spurious key presses; unguarded single-character shortcuts trigger unintended actions.

SC 2.4.3 - Focus Order (Level A)

Per wcag22 (opens in new window): components must "receive focus in an order that preserves meaning and operability."

PatternWhat to test
Reading-order vs DOM-order vs visual-orderAll three should agree. CSS order / flex-direction: row-reverse can desync visual from DOM.
tabindex="3" (positive)Anti-pattern. Positive tabindex creates explicit focus order separate from DOM; rarely intended. Use 0 or -1.
Modal that doesn't trap focusTab moves focus to the page behind; user loses context.
Skip linksAlways at the start of the document; tabindex="0" not needed (anchor links are focusable).

SC 2.4.7 - Focus Visible (Level AA)

Per wcag22 (opens in new window): "Any keyboard operable user interface has a mode of operation where the keyboard focus indicator is visible."

FailureFix
*:focus { outline: none; } without alternativeReplace with :focus-visible style: :focus-visible { outline: 2px solid var(--focus-ring); }.
Subtle 1px gray ring on a gray background3:1 contrast ratio against background per WCAG SC 1.4.11; high-contrast theme should bump to 4.5:1.
Disabled focus indicator on hoverHover and focus styles are independent; never tie them.

SC 2.4.11 / 2.4.12 - Focus Not Obscured (Level AA / AAA, NEW in 2.2)

Per wcag22 (opens in new window): components receiving focus cannot be "entirely hidden due to author-created content."

Failure patternFix
Sticky header covers the focused input on long-form pagesAdjust scroll-margin-top so focused element scrolls into view below the sticky header.
Persistent cookie banner covers focused elementModal / overlay should NOT obscure focused content; use compact banner or temporarily hide.
Auto-popup chat widget overlays focused fieldsRender below other content OR move to a corner that doesn't intersect with form fields.

2.4.11 (Minimum, Level AA): focus must NOT be entirely obscured.

2.4.12 (Enhanced, Level AAA): focus must NOT be partially obscured. Stricter; relevant for high-stakes forms.

Per-component test patterns

Form

  1. Tab through every field; verify focus order matches reading order.
  2. Verify each interactive element has a visible focus ring.
  3. Verify label-input association via <label for="..."> or aria-labelledby.
  4. Activate submit via Enter from any field.
  5. After submit error: verify focus moves to the first invalid field (per SC 3.3.1 - Error Identification).

Modal / dialog

  1. On open: focus moves to the first interactive element (or to the dialog's close button if no other focusable element).
  2. Tab cycles through dialog content only - does NOT escape to page behind.
  3. Shift+Tab from first → last interactive element (cycle).
  4. Escape closes the dialog and returns focus to the trigger.

(See references/focus-trap.md for the full modal pattern.)

Menu / dropdown

  1. Tab focuses the trigger.
  2. Enter / Space / Down-arrow opens the menu; first item focused.
  3. Down/Up arrows navigate items; Home/End jump to first/last.
  4. Enter / Space activates the focused item.
  5. Escape closes the menu and returns focus to the trigger.

This matches the ARIA Menu pattern (opens in new window) (referenced from aria-authoring-patterns).

Tabs

  1. Tab focuses the active tab (one tabstop per tab group).
  2. Left/Right arrows navigate between tabs (no Tab key).
  3. Activation: automatic on focus OR explicit on Enter/Space (manual activation; ARIA Authoring Practices defaults).
  4. After tab change: Tab moves to the panel content.

CI integration

The patterns above translate to per-test assertions in axe-a11y configurations. The a11y-violation-gate skill gates the build on new violations of these SCs.

Anti-patterns

Anti-patternWhy it failsFix
<div onclick> for buttonsNot focusable, not Enter-activatable.Always <button type="button">.
outline: none for clean designRemoves focus indicator entirely (SC 2.4.7).:focus-visible { outline: 2px solid <color>; }.
tabindex="3" to control focus orderCreates a non-DOM order; breaks on dynamic content.DOM-order tabbing; tabindex="0" for custom focusables, -1 to skip.
Single-character shortcuts always-activeTriggered by speech-input users (SC 2.1.4).Off / remap / focus-only.
Sticky header obscures focused inputViolates SC 2.4.11.scroll-margin-top per element.

References

  • wcag22 (opens in new window) - WCAG 2.2 specification.
  • references/focus-trap.md - focus management for modals (the intentional-trap pattern, 6 steps, native <dialog>, test scripts).
  • wcag-color-contrast - for SC 1.4.11 focus-indicator contrast.
  • aria-authoring-patterns - for canonical interactive-widget patterns.
  • axe-a11y - scanners that detect SC violations programmatically.
Reference

Focus management for modals - the intentional focus trap

View source (opens in a new tab) (opens in new window)

Companion reference for wcag-keyboard-navigation. Consult when authoring or reviewing any component that displays content over the page (modal, dialog, drawer, popover, command palette), or when diagnosing a focus-management bug ("Tab escapes the modal" / "Escape doesn't close" / "focus goes to body after close").

A modal that doesn't manage focus is broken: keyboard users tab past it into the dimmed page underneath; screen-reader users hear the page content as if the modal isn't open. The fix is intentional focus management - sometimes called a "focus trap" - but the term is misleading: it's not a trap, it's a scope. WCAG SC 2.1.2 forbids unintentional traps; this is the intentional pattern that satisfies SC 2.4.3 (Focus Order) without violating 2.1.2 (wcag22 (opens in new window)).

The 6-step canonical pattern

Step 1 - On open, move focus into the container

The first focusable element in the modal receives focus automatically. If the modal has no focusable content, focus the close button.

function openModal(modalEl) {
  modalEl.hidden = false;
  const firstFocusable = modalEl.querySelector(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  firstFocusable?.focus();
}

Step 2 - Set aria-modal="true" and role="dialog"

<div role="dialog" aria-modal="true" aria-labelledby="modal-title" tabindex="-1">
  <h2 id="modal-title">Confirm deletion</h2>
  ...
</div>

role="dialog" is the canonical role per the ARIA Dialog pattern (opens in new window). aria-modal="true" tells assistive technologies to treat content outside the dialog as inert.

Step 3 - Inert the rest of the page

Content outside the modal must be untabbable AND untraversable by screen readers:

<!-- Modern -->
<main inert>...</main>             <!-- inert attribute (Baseline 2024) -->
<aside role="dialog" ...>...</aside>
// Pre-inert fallback: cycle focusable elements outside the dialog,
// store their tabindex, set them to -1, restore on close.

inert removes the subtree from sequential focus AND from screen-reader navigation. Browsers that don't support inert need a polyfill.

Step 4 - Cycle focus within the container on Tab / Shift+Tab

Tab from the last focusable element wraps to the first; Shift+Tab from the first wraps to the last:

function trapFocus(event, modalEl) {
  if (event.key !== 'Tab') return;

  const focusables = Array.from(modalEl.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  )).filter(el => !el.disabled && el.offsetParent !== null);

  if (focusables.length === 0) {
    event.preventDefault();
    return;
  }

  const first = focusables[0];
  const last = focusables[focusables.length - 1];

  if (event.shiftKey && document.activeElement === first) {
    last.focus();
    event.preventDefault();
  } else if (!event.shiftKey && document.activeElement === last) {
    first.focus();
    event.preventDefault();
  }
}

Step 5 - Escape closes the modal

Per the ARIA Dialog pattern (opens in new window), Escape SHOULD close non-destructive dialogs. Destructive ("Are you sure?") dialogs may omit Escape close to avoid accidental dismissal.

function onKeyDown(event) {
  if (event.key === 'Escape') {
    closeModal();
  }
}

Step 6 - On close, restore focus to the trigger

The element that opened the modal receives focus back. Without this, focus lands on <body> and the user is disoriented:

let triggerElement = null;

function openModal(modalEl, trigger) {
  triggerElement = trigger;
  // ...steps 1-3 above
}

function closeModal(modalEl) {
  modalEl.hidden = true;
  // remove inert from rest of page, etc.
  triggerElement?.focus();
  triggerElement = null;
}

Native <dialog> element

Modern browsers ship <dialog> with most of this pattern built in:

<dialog id="confirmDelete" aria-labelledby="dialog-title">
  <h2 id="dialog-title">Confirm deletion</h2>
  <button id="cancel">Cancel</button>
  <button id="confirm">Delete</button>
</dialog>
const dialog = document.getElementById('confirmDelete');
dialog.showModal();   // automatic: inert outside, focus inside, Escape closes

showModal() (vs. show()) automatically:

  • Sets aria-modal="true" and role="dialog".
  • Inerts the page outside the dialog.
  • Captures Escape (default close).

What it does NOT do automatically:

  • Restore focus to the trigger on close.
  • Move focus to a specific element on open (focuses the dialog by default, which announces the dialog title via screen reader).
  • Cycle focus on Tab (browsers do this implicitly by inerting the outside).

Library-provided dialogs

Most UI libraries ship the pattern. Verify they do all 6 steps:

LibrarySteps 1-6 supported?
Radix UI DialogYes (uses <dialog> internally; aria-modal; restore focus).
Headless UI DialogYes.
<dialog> (native)Steps 1-5; Step 6 (restore focus) requires manual code.
Bootstrap ModalOlder versions: incomplete (no inert; pre-aria-modal).
Custom hand-rolledOften missing Step 3 (inert) and Step 6 (restore focus).

Test scripts

Test 1 - Focus moves on open

test('SC 2.4.3 - focus enters dialog on open', async ({ page }) => {
  await page.locator('[data-testid="open-dialog"]').click();
  await expect(page.locator('[role="dialog"] button').first()).toBeFocused();
});

Test 2 - Tab cycles within dialog

test('SC 2.1.2 - Tab cycles within dialog', async ({ page }) => {
  await page.locator('[data-testid="open-dialog"]').click();
  // Tab to last
  for (let i = 0; i < 5; i++) await page.keyboard.press('Tab');
  // Next Tab cycles to first
  await page.keyboard.press('Tab');
  await expect(page.locator('[role="dialog"] button').first()).toBeFocused();
});

Test 3 - Escape closes; focus returns to trigger

test('SC 2.1.2 + 2.4.3 - Escape closes; focus restored', async ({ page }) => {
  const trigger = page.locator('[data-testid="open-dialog"]');
  await trigger.click();
  await page.keyboard.press('Escape');
  await expect(page.locator('[role="dialog"]')).toBeHidden();
  await expect(trigger).toBeFocused();
});

Test 4 - Outside content is inert

test('outside content is unreachable by Tab', async ({ page }) => {
  await page.locator('[data-testid="open-dialog"]').click();
  // Tab N times; should never land outside the dialog
  for (let i = 0; i < 20; i++) {
    await page.keyboard.press('Tab');
    const inDialog = await page.evaluate(() => {
      const dialog = document.querySelector('[role="dialog"]');
      return dialog?.contains(document.activeElement);
    });
    expect(inDialog).toBe(true);
  }
});

Common bugs

BugSymptomFix
No focus on openScreen reader doesn't announce the dialog; user must Tab to find it.Step 1 - focus first element.
Tab escapes to pageKeyboard users lose context.Step 3 (inert) or Step 4 (cycle).
Escape doesn't closeUsers assume modal is broken; close-button-only.Step 5 - bind Escape.
Focus jumps to body on closeUser is disoriented; must Tab back to where they were.Step 6 - restore to trigger.
Background page scrolls while modal is openNot a focus issue but related; user thinks they're on the page.body { overflow: hidden; } while modal open.

Anti-patterns

Anti-patternWhy it failsFix
Hand-rolled Tab-key listener that never matches Tab+Shift casesShift+Tab on first element escapes the dialog.Implement both directions per Step 4.
Setting aria-hidden="true" on the modal triggerThe trigger element gets a focus indicator that ARIA hides; confusing.Don't aria-hide the trigger; only inert the page when modal is open.
<div role="dialog"> without aria-modal="true"Screen readers don't treat content outside as inert.Always include aria-modal="true".
Tab-cycle that skips disabled buttons inconsistentlySome focusables disabled; Tab still lands on them.Filter by :not([disabled]) AND offsetParent !== null (not display:none).
Closing modal on outside-click without EscapeKeyboard users can't close.Always bind Escape (Step 5).

References

  • wcag22 (opens in new window) - WCAG 2.2; SC 2.1.2, 2.4.3, 2.4.11.
  • aria-dialog (opens in new window) - ARIA Authoring Practices Guide: Dialog (Modal) Pattern.
  • HTML Living Standard <dialog> element - https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
  • inert attribute - https://html.spec.whatwg.org/multipage/interaction.html#the-inert-attribute
  • aria-authoring-patterns - pattern reference for other widgets.
  • Signing the Warrenby AA statement on a checkout with two bottom bars
  • The board audit item is still open after we "added the ARIA"
  • Mail threads are archiving themselves for one of our Dragon customers
  • Remediating audit finding 17 on the board dialogs before the 30th
  • Analytics tab strip: the panel changes, the screen reader stays put