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).
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: "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: "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:
- 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: 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: "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: 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
- Tab through every field; verify focus order matches reading
order.
- Verify each interactive element has a visible focus ring.
- Verify label-input association via
<label for="..."> or
aria-labelledby.
- Activate submit via Enter from any field.
- After submit error: verify focus moves to the first invalid
field (per SC 3.3.1 - Error Identification).
Modal / dialog
- On open: focus moves to the first interactive element
(or to the dialog's close button if no other focusable element).
- Tab cycles through dialog content only - does NOT escape to
page behind.
- Shift+Tab from first → last interactive element (cycle).
- Escape closes the dialog and returns focus to the trigger.
(See references/focus-trap.md for the full
modal pattern.)
Menu / dropdown
- Tab focuses the trigger.
- Enter / Space / Down-arrow opens the menu; first item focused.
- Down/Up arrows navigate items; Home/End jump to first/last.
- Enter / Space activates the focused item.
- Escape closes the menu and returns focus to the trigger.
This matches the ARIA Menu pattern (referenced from
aria-authoring-patterns).
Tabs
- Tab focuses the active tab (one tabstop per tab group).
- Left/Right arrows navigate between tabs (no Tab key).
- Activation: automatic on focus OR explicit on Enter/Space
(manual activation; ARIA Authoring Practices defaults).
- 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-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
- wcag22 - 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.