| name | keyboard |
| description | Load this skill for every project containing interactive UI elements — buttons, links, modals, dropdowns, sliders, tabs, carousels, or any custom widget. Under no circumstances create an interactive component that cannot be fully operated by keyboard alone. Absolutely always ensure visible focus indicators, logical tab order, and no keyboard traps. Apply these rules to every interactive element without exception.
|
Keyboard Accessibility Skill
Canonical source: examples/KEYBOARD_ACCESSIBILITY_BEST_PRACTICES.md in mgifford/ACCESSIBILITY.md
This skill is derived from that file. When in doubt, the example is authoritative.
Apply these rules to every interactive UI element and feature.
Core Mandate
All interactive functionality must be fully usable with a keyboard alone —
no mouse or touch required.
Severity Scale (this skill)
| Level | Meaning |
|---|
| Critical | Blocks task completion entirely for keyboard and AT users |
| Serious | Significantly impairs keyboard access; workaround unreasonable |
| Moderate | Creates friction for keyboard users; workaround exists |
| Minor | Best-practice gap; marginal keyboard impact |
Critical: No Keyboard Trap
Users must never become unable to move focus away from a component using
standard keys (Tab, Shift+Tab, Escape, arrow keys).
The only permitted exception is an intentional modal dialog trap where Escape
closes the dialog and returns focus to the trigger.
A keyboard trap with no exit is Critical — it locks keyboard users and
switch-access users into a dead end with no recovery path.
Critical: All Interactive Elements Must Be Keyboard Reachable
Every element that can be activated by mouse must be reachable and activatable
by keyboard. Use native elements — they have keyboard support, focus
management, and ARIA semantics built in at zero extra cost:
<button type="button">Save</button>
<a href="/about">About</a>
<div role="button" tabindex="0">Save</div>
Never attach click handlers to non-interactive elements (<div>, <span>,
<p>) without also adding the correct role, tabindex="0", and keyboard
event handlers for both Enter and Space.
Critical: Expected Key Behaviours
Deviating from expected widget key behaviour is Critical — it breaks the
mental model that AT users depend on. Follow the
WAI-ARIA APG for each widget type.
The APG is versioned independently of WCAG — always confirm you are reading
the current version before implementing a pattern.
| Control | Required keys |
|---|
| Button | Enter, Space |
| Link | Enter |
| Checkbox | Space to toggle |
| Radio group | Arrow keys to move between options; Space to select |
| Select / listbox | Arrow keys to navigate; Enter to confirm |
| Menu / menubar | Arrow keys; Enter to activate item; Escape to close |
| Tab widget | Arrow keys between tabs; Enter/Space to activate tab |
| Dialog | Escape to close; focus trapped inside while open |
| Combobox | Arrow keys in list; Enter to select; Escape to collapse |
| Tree view | Arrow keys to expand/collapse/navigate nodes |
| Slider | Arrow keys to change value; Home/End for min/max |
Critical: Dialog Focus Management
Incorrect dialog focus management is Critical — keyboard and screen reader
users lose their place, or cannot reach dialog controls at all.
Preferred approach: inert attribute
The inert attribute prevents all interaction (focus, click, AT) with elements
outside the open dialog. It is simpler and more reliable than manual focusable-
element cycling and has good browser support (baseline 2023).
const focusableSelectors = [
'a[href]', 'area[href]', 'input:not([disabled])', 'select:not([disabled])',
'textarea:not([disabled])', 'button:not([disabled])',
'[tabindex]:not([tabindex="-1"])', 'details > summary'
].join(', ');
function openDialog(dialog, trigger) {
document.querySelectorAll('body > *:not(#dialog-container)')
.forEach(el => el.setAttribute('inert', ''));
dialog.removeAttribute('hidden');
const first = dialog.querySelector(focusableSelectors);
first?.focus();
}
function closeDialog(dialog, trigger) {
document.querySelectorAll('[inert]')
.forEach(el => el.removeAttribute('inert'));
dialog.setAttribute('hidden', '');
trigger.focus();
}
dialog.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeDialog(dialog, trigger);
});
Manual focus trap (fallback when inert is unavailable)
For environments that do not support inert, implement a manual focus trap.
The focus-trap library provides a well-tested, production-ready implementation —
prefer it over hand-rolling this logic:
import { createFocusTrap } from 'focus-trap';
let trap;
function openDialog(dialog, trigger) {
dialog.removeAttribute('hidden');
trap = createFocusTrap(dialog, {
escapeDeactivates: true,
onDeactivate: () => closeDialog(dialog, trigger)
});
trap.activate();
}
function closeDialog(dialog, trigger) {
trap?.deactivate();
dialog.setAttribute('hidden', '');
trigger.focus();
}
If writing a manual trap without a library, the complete cycle logic is:
function trapFocus(dialog) {
const focusable = Array.from(dialog.querySelectorAll(focusableSelectors));
const first = focusable[0];
const last = focusable[focusable.length - 1];
dialog.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
});
}
Serious: Focus Visibility
Every focusable element must have a clear, persistent visible focus indicator.
Removing focus outlines without an equally visible replacement is Serious.
:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
WCAG 2.4.11 minimum requirements:
- At least 2px thick
- Minimum 3:1 contrast ratio against adjacent colours
- Must be visible in both light and dark modes
Serious: Focus Not Obscured (WCAG 2.4.12)
Sticky headers, cookie banners, floating toolbars, and chat widgets can cover
the focused element, making it invisible even though focus is technically visible.
A focused element fully hidden behind a sticky overlay is Serious.
:focus {
scroll-margin-top: var(--sticky-header-height, 4rem);
scroll-margin-bottom: var(--sticky-footer-height, 0);
}
Test by tabbing through the page with a sticky header present. If the focused
element scrolls under the header, the scroll margin is too small.
WCAG 2.4.12 requires the focused element is not fully hidden. Partial
obscuring (only a portion covered) is a Moderate issue; complete hiding is Serious.
Serious: Focus Order
Tab order must follow logical reading and interaction sequence.
Illogical focus order is Serious — screen reader users build a spatial model
of the page from the focus sequence, and out-of-order focus breaks that model.
- Use semantic DOM order as the primary mechanism
- Never use positive
tabindex values (tabindex="2" etc.) — they override DOM
order globally and create unpredictable sequences
tabindex="0" — use only to make custom widgets focusable
tabindex="-1" — use only for programmatic focus targets (skip link anchors,
modal focus management, scroll-into-view targets)
- If visual order differs from DOM order (e.g., CSS grid/flex reordering),
fix the DOM order — do not use
tabindex to compensate
Serious: Roving Tabindex for Composite Widgets
Composite widgets (toolbars, radio groups, tree views, tab lists, menubars) must
use the roving tabindex pattern so only one item in the group is in the tab
stop at a time, and arrow keys move within the group.
This prevents the group's items from cluttering the page tab sequence and matches
the keyboard behaviour AT users expect from desktop applications.
<div role="toolbar" aria-label="Text formatting">
<button tabindex="0" aria-pressed="false">Bold</button>
<button tabindex="-1" aria-pressed="false">Italic</button>
<button tabindex="-1" aria-pressed="false">Underline</button>
</div>
const toolbar = document.querySelector('[role="toolbar"]');
const items = Array.from(toolbar.querySelectorAll('button'));
toolbar.addEventListener('keydown', (e) => {
const current = document.activeElement;
const index = items.indexOf(current);
let next = -1;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
next = (index + 1) % items.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
next = (index - 1 + items.length) % items.length;
} else if (e.key === 'Home') {
next = 0;
} else if (e.key === 'End') {
next = items.length - 1;
}
if (next !== -1) {
e.preventDefault();
items.forEach(btn => btn.setAttribute('tabindex', '-1'));
items[next].setAttribute('tabindex', '0');
items[next].focus();
}
});
Apply the same pattern to radio groups, tab lists, and tree nodes. Consult the
APG pattern
for the specific arrow key directions required for each widget type.
Moderate: Skip Link & Landmarks
Missing skip link is Moderate — sighted keyboard users must Tab through all
navigation on every page load. Screen reader users have landmark navigation as an
alternative, but a skip link is the most reliable, lowest-friction bypass.
<a class="skip-link" href="#main">Skip to main content</a>
<header role="banner">…</header>
<nav aria-label="Main">…</nav>
<main id="main" tabindex="-1">…</main>
<aside>…</aside>
<footer>…</footer>
.skip-link {
position: absolute;
top: -100%;
left: 1rem;
padding: 0.5rem 1rem;
background: #000;
color: #fff;
font-weight: bold;
text-decoration: none;
z-index: 9999;
}
.skip-link:focus { top: 1rem; }
The skip link must be visible on focus — a skip link hidden permanently
(e.g., display: none) is a Serious issue as it breaks WCAG 2.4.1.
Moderate: Hidden & Offscreen Content
- Elements with
display:none or visibility:hidden are correctly excluded from tab order
- Use
aria-hidden="true" on offscreen content that must remain in the DOM
but is not currently available to users
- Modals and drawers: apply
inert (or aria-hidden) to background content
when the overlay is open; remove it when closed
Keyboard-reachable content that is visually hidden is Serious — users reach
invisible controls with no indication of what they have focused.
Moderate: Touch & Pointer Equivalents (WCAG 2.5.x)
Pointer and touch requirements apply alongside keyboard requirements.
| Requirement | Description | Severity |
|---|
| 2.5.1 Pointer Gestures | Multi-point or path-based gestures (pinch, swipe) must have a single-pointer or keyboard alternative | Serious |
| 2.5.3 Label in Name | Visible label text must be contained in the accessible name | Serious |
| 2.5.8 Target Size Minimum | Interactive targets at least 24×24 CSS pixels; recommended 44×44 | Moderate |
| user-scalable=no | Never prevent pinch-to-zoom — blocks low-vision users | Serious |
| Drag-to-reorder | Must have keyboard alternative (e.g., cut/paste, up/down button) | Serious |
<meta name="viewport" content="width=device-width, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1">
For minimum target size:
button, a, [role="button"] {
min-width: 24px;
min-height: 24px;
}
Minor: inert Browser Support Note
inert is baseline 2023 (Chrome 102+, Firefox 112+, Safari 15.5+). For projects
requiring support of older browsers, use the
wicg-inert polyfill or the
focus-trap library as the primary focus management mechanism.
Definition of Done Checklist
Key WCAG Criteria
- 2.1.1 Keyboard (A) — Critical if violated
- 2.1.2 No Keyboard Trap (A) — Critical if violated
- 2.4.1 Bypass Blocks (A)
- 2.4.3 Focus Order (A)
- 2.4.7 Focus Visible (AA)
- 2.4.11 Focus Appearance (AA, WCAG 2.2)
- 2.4.12 Focus Not Obscured (AA, WCAG 2.2)
- 2.5.1 Pointer Gestures (A)
- 2.5.3 Label in Name (A)
- 2.5.8 Target Size Minimum (AA, WCAG 2.2)
References
Standards horizon: These rules target WCAG 2.2 AA. WCAG 3.0 is in
development; keyboard and focus requirements are expected to be broadly
compatible with 2.2.
Monitor: https://www.w3.org/TR/wcag-3.0/