| applyTo | ** |
|---|---|
| description | Keyboard accessibility instructions for GitHub Copilot — WCAG 2.2 AA patterns for focus management, dialog traps, roving tabindex, skip links, and focus visibility. Complements the broad a11y.instructions.md with deep, code-level keyboard guidance. |
All interactive functionality must be fully usable with a keyboard alone — no mouse or touch required. These instructions complement the broad a11y.instructions.md with deep, code-level patterns for the most common keyboard accessibility failures.
- CRITICAL — Blocks keyboard users entirely. Fix before merge.
- SERIOUS — Significantly impairs keyboard access; workaround unreasonable. Fix in same sprint.
- MODERATE — Creates friction; workaround exists. Schedule for near-term fix.
Users must never become stranded in a component. The only permitted trap is an intentional modal dialog where Escape closes the dialog and returns focus to the trigger.
<!-- BAD: no way to Tab out of this widget -->
<div class="date-picker" tabindex="0" onkeydown="absorbAllKeys(event)">…</div>
<!-- GOOD: Escape always exits; Tab moves through and out -->
<div role="dialog" aria-modal="true" aria-labelledby="dlg-title">…</div>WCAG: 2.1.2 No Keyboard Trap (A)
Every mouse-clickable element must be reachable and activatable by keyboard. Always prefer native elements — they include keyboard support, ARIA semantics, and focus management at zero extra cost.
<!-- GOOD: native keyboard support built in -->
<button type="button">Save</button>
<a href="/about">About</a>
<!-- BAD: requires manual role + tabindex + two key handlers to match native button -->
<div role="button" tabindex="0" onclick="…" onkeydown="…">Save</div>If a non-interactive element must receive a click handler, always add:
role(e.g.role="button")tabindex="0"keydownhandlers for bothEnterandSpace
WCAG: 2.1.1 Keyboard (A), 4.1.2 Name, Role, Value (A)
Follow WAI-ARIA APG key patterns. Deviating breaks the mental model AT users depend on.
| Control | Required keys |
|---|---|
| Button | Enter, Space |
| Link | Enter |
| Checkbox | Space to toggle |
| Radio group | Arrow keys to move; Space to select |
| Select / listbox | Arrow keys to navigate; Enter to confirm |
| Menu / menubar | Arrow keys; Enter to activate; Escape to close |
| Tab widget | Arrow keys between tabs; Enter/Space to activate |
| 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 |
| Slider | Arrow keys to change value; Home/End for min/max |
WCAG: 2.1.1 Keyboard (A)
Incorrect focus management means keyboard and screen reader users lose their place or cannot reach dialog controls.
function openDialog(dialog, trigger) {
// Prevent interaction with everything outside the dialog
document.querySelectorAll('body > *:not(#dialog-container)')
.forEach(el => el.setAttribute('inert', ''));
dialog.removeAttribute('hidden');
// Move focus to first focusable element
const first = dialog.querySelector(
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
first?.focus();
}
function closeDialog(dialog, trigger) {
document.querySelectorAll('[inert]')
.forEach(el => el.removeAttribute('inert'));
dialog.setAttribute('hidden', '');
trigger.focus(); // Return focus to the opener
}
dialog.addEventListener('keydown', e => {
if (e.key === 'Escape') closeDialog(dialog, trigger);
});For environments without inert support, use the focus-trap library rather than hand-rolling a trap:
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();
}WCAG: 2.1.2 No Keyboard Trap (A), 2.4.3 Focus Order (A)
Every focusable element must have a clear, persistent visible focus indicator. Never remove outlines without an equally visible replacement.
/* GOOD */
:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
/* BAD — removes the only focus indicator */
:focus { outline: none; }WCAG 2.4.11 Focus Appearance (Minimum) requirements (WCAG 2.2):
- At least 2 px thick
- Minimum 3:1 contrast ratio against adjacent colours
- Visible in both light and dark modes
WCAG: 2.4.7 Focus Visible (AA), 2.4.11 Focus Appearance (Minimum) (AA, WCAG 2.2)
Sticky headers, cookie banners, and floating toolbars can cover the focused element. Use scroll-margin to ensure focused elements scroll clear of fixed overlays.
:focus {
scroll-margin-top: var(--sticky-header-height, 4rem);
scroll-margin-bottom: var(--sticky-footer-height, 0);
}WCAG: 2.4.12 Focus Not Obscured (Minimum) (AA, WCAG 2.2)
Tab order must follow a logical reading and interaction sequence.
- Use semantic DOM order as the primary mechanism.
- Never use positive
tabindexvalues (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).- If visual order differs from DOM order (e.g. CSS grid/flex reordering), fix the DOM — do not use
tabindexto compensate.
WCAG: 2.4.3 Focus Order (A)
Toolbars, radio groups, tree views, tab lists, and menubars must keep only one tab stop in the group at a time. Arrow keys navigate within the group.
<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 index = items.indexOf(document.activeElement);
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();
}
});WCAG: 2.1.1 Keyboard (A)
Provide a visible-on-focus skip link as the first element in <body>. Use landmark elements for page regions.
<!-- First element in <body> — must be visible on focus -->
<a class="skip-link" href="#main">Skip to main content</a>
<header>…</header>
<nav aria-label="Main">…</nav>
<main id="main" tabindex="-1">…</main>
<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; }A skip link hidden permanently (e.g. display: none) is a SERIOUS issue — it breaks WCAG 2.4.1.
WCAG: 2.4.1 Bypass Blocks (A)
MODERATE: Hidden and offscreen content
- Elements with
display:noneorvisibility:hiddenare excluded from tab order automatically — no extra work needed. - Use
aria-hidden="true"on offscreen content that must stay in the DOM but is not currently available. - When an overlay (modal, drawer) opens, apply
inert(oraria-hidden) to background content; remove it when the overlay closes.
Before marking any interactive UI change as complete, verify:
- Tab through entire page: logical order, no unexpected skips
- Visible focus indicator on every focusable element (light and dark modes)
- All interactive elements activatable with correct keys per widget type table
- No keyboard trap (except intentional modal trap with working Escape)
- Dialog open: background made
inert; first focusable element receives focus - Dialog close:
inertremoved; focus returns to trigger - Skip link present, first in DOM, visible on focus, target has
tabindex="-1" - Sticky headers/footers:
scroll-marginprevents focused elements being hidden - Hidden/offscreen content not in tab order
- Composite widgets use roving tabindex; arrow keys navigate within group
| Criterion | Level | Notes |
|---|---|---|
| 2.1.1 Keyboard | A | All functionality keyboard-operable |
| 2.1.2 No Keyboard Trap | A | Users can always navigate away |
| 2.4.1 Bypass Blocks | A | Skip link bypasses repeated navigation |
| 2.4.3 Focus Order | A | Logical tab sequence |
| 2.4.7 Focus Visible | AA | Focus indicator always visible |
| 2.4.11 Focus Appearance (Minimum) | AA | New in WCAG 2.2 — 2 px thick, 3:1 contrast |
| 2.4.12 Focus Not Obscured (Minimum) | AA | New in WCAG 2.2 — not hidden by sticky UI |
| 2.5.8 Target Size (Minimum) | AA | New in WCAG 2.2 — 24×24 CSS px minimum |