Testland
Browse all skills & agents

wcag-keyboard-navigation

Reference catalog for WCAG 2.2 keyboard-navigation conformance - covers SC 2.1.1 (Keyboard), 2.1.2 (No Keyboard Trap), 2.1.4 (Character Key Shortcuts), 2.4.3 (Focus Order), 2.4.7 (Focus Visible), 2.4.11/2.4.12 (Focus Not Obscured) - with conformance levels (A/AA), test scripts, and per-criterion failure patterns, plus the modal focus-trap / focus-management pattern (focus-on-open, Tab-cycle, inert, Escape-closes, restore-to-trigger, native `<dialog>`) in references/. Use when authoring or reviewing keyboard-only interaction support, or a modal / drawer / popover's focus management.

Install with skills.sh (any agent)

npx skills add testland/qa --skill wcag-keyboard-navigation
View source

wcag-keyboard-navigation

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.

Focus management for modals - the intentional focus trap

View source (opens in new window)

Focus management for modals - the intentional focus trap

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.

Related skills

a11y-violation-gate

Builds a CI gate that fails the build on **new** WCAG / a11y violations introduced by a PR while grandfathering pre-existing violations on a per-rule / per-page baseline. Aggregates verdicts from axe-core / pa11y / Lighthouse a11y / WAVE / IBM Equal Access scans. Use when a project has accumulated a11y debt and a strict "zero violations" gate would block every PR - the ratchet pattern lets the team ship while preventing regressions.

aria-authoring-patterns

Reference for the W3C ARIA Authoring Practices Guide (APG) - covers the 31 canonical interactive-widget patterns (Combobox, Dialog, Menu, Tabs, Tree, etc.), their required ARIA roles and states, the keyboard-interaction model per pattern, and the canonical-violations to watch for. Use when authoring a custom interactive widget that doesn't have a native HTML equivalent, or when reviewing one for ARIA correctness.

axe-a11y

Automated accessibility scanning across the five engines - axe-core (primary), pa11y, Lighthouse a11y, WAVE, and IBM Equal Access. Authors and runs axe-core scans via the `axe.run()` JavaScript API or the @axe-core/playwright / @axe-core/cli wrappers, parses `violations[]` into per-rule severity, configures rule disable / disable-by-tag patterns, and emits CI-gateable output; references/ carry the pa11y CLI (htmlcs + axe runners), Lighthouse CI `categories:accessibility` assertions, the WAVE API / overlay, and IBM Equal Access (Section 508) with their verified CLI / API / config. Use for any automated a11y scanner setup - axe-core for JS/TS UI test suites on every PR, and the references for CLI-only, Lighthouse-pipeline, WebAIM-branded, or Section 508 scanning.

screen-reader-test-author

Builds the full manual-accessibility artifact surface: step-by-step screen-reader test scripts for NVDA (Windows), JAWS (Windows), VoiceOver (macOS / iOS), or TalkBack (Android) with per-step keystroke + expected announcement; per-archetype WCAG 2.2 checklists (references/wcag-checklist.md); per-widget keystroke matrices pairing expected NVDA and VoiceOver announcements with the WCAG SC each row verifies (references/widget-matrix.md); and a guided NVDA / VoiceOver session protocol that merges script + checklist into a signed pass/fail session report. Use when authoring an accessibility-acceptance test, checklist, or widget matrix the team will run before sign-off, when scripting a manual a11y audit, OR when walking a tester through a guided screen-reader session.

wcag-color-contrast

Reference for WCAG 2.2 color-contrast conformance - covers SC 1.4.3 Contrast (Minimum, AA), 1.4.6 Contrast (Enhanced, AAA), 1.4.11 Non-text Contrast (AA), and 1.4.13 Content on Hover or Focus (AA) - with the canonical contrast ratios (4.5:1 normal text, 3:1 large text and UI components), measurement formula references, and bulk design-token checking patterns. Use when designing a color palette, reviewing a component for accessibility, or auditing existing CSS for contrast violations.

wcag-compliance-reporter

Builds a per-page WCAG 2.2 compliance score report by aggregating output from one or more accessibility scanners (axe-core / pa11y / lighthouse / WAVE / IBM Equal Access), pivoting violations by Success Criterion (1.4.3 contrast, 2.4.7 focus visible, etc.), grouping by conformance level (A / AA / AAA), reporting per-page coverage gaps explicitly (the "this page wasn't scanned" failure mode), and emitting both an executive summary and a per-page drill-down. Use after a multi-page accessibility scan - pa11y-ci, axe across a sitemap, lighthouse-batch - when the team needs a shareable conformance report rather than a per-page tool dump.