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-navigationwcag-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
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.
| Pattern | What 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-drop | Provide 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 failure | Fix |
|---|---|
| Modal traps Tab without an explicit Escape handler | Add Escape handler that closes the modal and restores focus. |
Embedded <iframe> (e.g. third-party widget) traps Tab | Set tabindex="-1" on the iframe OR document the exit (Esc + Tab) in surrounding label. |
| Custom date picker locks focus inside on first focus | Provide 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:
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."
| Pattern | What to test |
|---|---|
| Reading-order vs DOM-order vs visual-order | All 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 focus | Tab moves focus to the page behind; user loses context. |
| Skip links | Always 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."
| Failure | Fix |
|---|---|
*:focus { outline: none; } without alternative | Replace with :focus-visible style: :focus-visible { outline: 2px solid var(--focus-ring); }. |
| Subtle 1px gray ring on a gray background | 3:1 contrast ratio against background per WCAG SC 1.4.11; high-contrast theme should bump to 4.5:1. |
| Disabled focus indicator on hover | Hover 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 pattern | Fix |
|---|---|
| Sticky header covers the focused input on long-form pages | Adjust scroll-margin-top so focused element scrolls into view below the sticky header. |
| Persistent cookie banner covers focused element | Modal / overlay should NOT obscure focused content; use compact banner or temporarily hide. |
| Auto-popup chat widget overlays focused fields | Render 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
Modal / dialog
(See references/focus-trap.md for the full modal pattern.)
Menu / dropdown
This matches the ARIA Menu pattern (opens in new window) (referenced from aria-authoring-patterns).
Tabs
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-pattern | Why it fails | Fix |
|---|---|---|
<div onclick> for buttons | Not focusable, not Enter-activatable. | Always <button type="button">. |
outline: none for clean design | Removes focus indicator entirely (SC 2.4.7). | :focus-visible { outline: 2px solid <color>; }. |
tabindex="3" to control focus order | Creates a non-DOM order; breaks on dynamic content. | DOM-order tabbing; tabindex="0" for custom focusables, -1 to skip. |
| Single-character shortcuts always-active | Triggered by speech-input users (SC 2.1.4). | Off / remap / focus-only. |
| Sticky header obscures focused input | Violates SC 2.4.11. | scroll-margin-top per element. |
References
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 closesshowModal() (vs. show()) automatically:
What it does NOT do automatically:
Library-provided dialogs
Most UI libraries ship the pattern. Verify they do all 6 steps:
| Library | Steps 1-6 supported? |
|---|---|
| Radix UI Dialog | Yes (uses <dialog> internally; aria-modal; restore focus). |
| Headless UI Dialog | Yes. |
<dialog> (native) | Steps 1-5; Step 6 (restore focus) requires manual code. |
| Bootstrap Modal | Older versions: incomplete (no inert; pre-aria-modal). |
| Custom hand-rolled | Often 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
| Bug | Symptom | Fix |
|---|---|---|
| No focus on open | Screen reader doesn't announce the dialog; user must Tab to find it. | Step 1 - focus first element. |
| Tab escapes to page | Keyboard users lose context. | Step 3 (inert) or Step 4 (cycle). |
| Escape doesn't close | Users assume modal is broken; close-button-only. | Step 5 - bind Escape. |
| Focus jumps to body on close | User is disoriented; must Tab back to where they were. | Step 6 - restore to trigger. |
| Background page scrolls while modal is open | Not a focus issue but related; user thinks they're on the page. | body { overflow: hidden; } while modal open. |
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Hand-rolled Tab-key listener that never matches Tab+Shift cases | Shift+Tab on first element escapes the dialog. | Implement both directions per Step 4. |
Setting aria-hidden="true" on the modal trigger | The 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 inconsistently | Some focusables disabled; Tab still lands on them. | Filter by :not([disabled]) AND offsetParent !== null (not display:none). |
| Closing modal on outside-click without Escape | Keyboard users can't close. | Always bind Escape (Step 5). |
References
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.