keyboard — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited keyboard (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Canonical source:examples/KEYBOARD_ACCESSIBILITY_BEST_PRACTICES.mdinmgifford/ACCESSIBILITY.mdThis skill is derived from that file. When in doubt, the example is authoritative.
Apply these rules to every interactive UI element and feature.
All interactive functionality must be fully usable with a keyboard alone — no mouse or touch required, except where the underlying function genuinely depends on path-based movement. Keyboard access is not equivalent to pressing Tab through every control — native and composite widgets use different key conventions, and users must understand how to operate custom widgets.
| 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 |
Do not assign severity from the rule alone — determine the actual effect on the task. Consider whether the defect prevents reaching, operating, or leaving a component; whether it blocks an essential task or only an optional feature; whether focus is lost or placed misleadingly; and whether a reasonable keyboard method remains available.
If focus can enter a component, users must be able to move focus away using Tab, Shift+Tab, arrow keys, or Escape (depending on the component). If leaving requires an unusual command, provide instructions before or within the component.
A keyboard trap with no exit is Critical. Restricting focus temporarily is NOT a trap when it is a modal dialog with a standard/documented exit: focus moves in on open, Tab/Shift+Tab stay within it, a keyboard-operable control closes/completes it, and closing returns focus to the invoking control or another logical location.
Test embedded editors, third-party widgets, iframes, media players, remote desktops, and canvas applications carefully — they commonly consume Tab or other keys and need an available, documented exit.
Every element that can be activated by mouse must be reachable and activatable by keyboard. Use native elements — they provide keyboard behaviour, focusability, semantics, states, and browser integration built in at zero extra cost:
<!-- Good: built-in keyboard support -->
<button type="button">Save</button>
<a href="/about">About</a>
<!-- Avoid: requires full ARIA + JS to match native behaviour -->
<div role="button" tabindex="0">Save</div>Adding role="button" and tabindex="0" does not add button behaviour by itself — a custom button must also implement Enter and Space activation, disabled state, focus styling, and the expected accessible name/state. Test the actual result, not merely whether a keydown handler exists. Mouse and touch handlers must not be the only way to operate a control.
Deviating from expected widget key behaviour is Critical — it breaks the mental model AT users depend on. Do not override standard browser behaviour on native controls without a documented need. Follow the current WAI-ARIA APG for custom widgets (versioned independently of WCAG — APG patterns are design guidance, not WCAG requirements by themselves; always confirm the current version).
| Control | Required keys |
|---|---|
| Button | Enter, Space |
| Link | Enter |
| Checkbox | Space to toggle |
| Radio group | Arrow keys to move between options; Space to select |
<select> | Browser/platform conventions for navigation and selection |
| Menu / menubar | Arrow keys; Enter to activate item; Escape to close |
| Tab widget | Arrow keys between tabs; Tab moves into the active panel |
| Dialog | Escape to close; focus trapped inside while open |
| Combobox | Depends on editable/select-only behaviour; Escape commonly closes |
| Tree view | Up/Down navigate visible nodes; Right expands/enters; Left collapses/parent |
| Slider | Arrow keys to change value; Home/End for min/max; Page Up/Down for larger steps |
| Grid | Arrow keys move among cells; Tab usually enters/leaves the composite |
<details> summary | Enter/Space toggles disclosure |
Do not implement a widget from a summary table alone — use the complete current APG pattern, document any variation, and test with representative browsers and assistive technologies.
Incorrect dialog focus management is Critical — keyboard and screen reader users lose their place, or cannot reach dialog controls at all.
<dialog> with showModal()A modal dialog opened with showModal() places the rest of the document in an inert state automatically — this is now the preferred pattern over manually applying inert to every sibling.
<button type="button" id="delete-trigger">Delete project</button>
<dialog id="delete-dialog" aria-labelledby="delete-title">
<h2 id="delete-title">Delete project?</h2>
<p>This action cannot be undone.</p>
<form method="dialog">
<button value="cancel" autofocus>Cancel</button>
<button value="confirm">Delete project</button>
</form>
</dialog>const trigger = document.getElementById('delete-trigger');
const dialog = document.getElementById('delete-dialog');
trigger.addEventListener('click', () => dialog.showModal());
dialog.addEventListener('close', () => {
if (trigger.isConnected) trigger.focus();
});Initial focus depends on the dialog's content — a short confirmation might focus Cancel; a long informational dialog might focus a heading with tabindex="-1" so users can read from the beginning. Do not always focus the first interactive element without considering the content. If the trigger is removed while the dialog is open, return focus to the nearest logical workflow location rather than the document body.
Do not add aria-modal="true" to a container and assume it creates modality — ARIA does not make content inert, contain focus, add keyboard handling, or restore focus.
Native modal behaviour does not remove the need to test: initial focus location, Tab/Shift+Tab containment, Escape and visible close controls, form submission/cancellation, focus restoration, nested/stacked overlays, screen reader announcement, and browser support in the project's support matrix.
<dialog> is unavailable)The inert attribute prevents all interaction (focus, click, AT) with elements outside the open dialog. It has good browser support (baseline 2023) and is simpler and more reliable than manual focusable-element cycling:
function openDialog(dialog, trigger) {
document.querySelectorAll('body > *:not(#dialog-container)')
.forEach(el => el.setAttribute('inert', ''));
dialog.removeAttribute('hidden');
dialog.querySelector(focusableSelectors)?.focus();
}
function closeDialog(dialog, trigger) {
document.querySelectorAll('[inert]').forEach(el => el.removeAttribute('inert'));
dialog.setAttribute('hidden', '');
trigger.focus();
}For environments without inert, use the focus-trap library (production-tested) rather than hand-rolling focus-cycling logic, or use the wicg-inert polyfill.
Do not trap focus in ordinary disclosures, tooltips, menus, or non-modal popovers unless the interaction pattern explicitly requires restricted focus. For simple show/hide content, a button with aria-expanded is sufficient:
<button type="button" aria-expanded="false" aria-controls="filter-panel">
Filters
</button>
<div id="filter-panel" hidden><!-- Filter controls --></div>When open content overlays the page: ensure keyboard users can enter and leave it; close non-persistent content when interaction moves away; support Escape where users reasonably expect it; return focus when closing would otherwise cause focus loss; prevent the overlay from obscuring the new focus location; do not open complex content merely because its trigger receives focus.
The HTML Popover API can be used where it meets the requirement — still test focus behaviour and dismissal for the selected popover type.
Every focusable element must have a clear, persistent visible focus indicator. Removing focus outlines without an equally visible replacement is Serious.
:focus-visible {
outline: 3px solid var(--focus-color, #005fcc);
outline-offset: 3px;
}
@media (forced-colors: active) {
:focus-visible { outline-color: Highlight; }
}Distinguish the applicable requirements:
A 3px solid outline with adequate contrast is a useful project default, not the complete Focus Appearance formula. Test focus in light, dark, increased-contrast, and forced-colours modes, and on every background a control appears against.
Sticky headers, sticky footers, cookie notices, chat panels, toolbars, virtual keyboards, and persistent disclosures can obscure the focused element even though focus is technically visible. A focused element fully hidden behind a sticky overlay is Serious.
html {
scroll-padding-block-start: var(--sticky-header-height, 0);
scroll-padding-block-end: var(--sticky-footer-height, 0);
}
[id] {
scroll-margin-block-start: var(--sticky-header-height, 0);
}CSS scroll spacing helps but does not prove conformance — test every breakpoint, zoom level, text-spacing configuration, and persistent overlay. WCAG 2.4.11 (AA) requires the focused element is not entirely hidden; partial obscuring is Moderate, complete hiding is Serious. Prefer designs where the complete focused component and its indicator remain visible — that exceeds the AA minimum and aligns with 2.4.12 Focus Not Obscured Enhanced (AAA).
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.
order — do not use CSS order, grid placement, or absolute positioning to create a misleading visual sequence
tabindex values — they create a separate, hard-to-maintainfocus sequence
tabindex="0" — only when an element legitimately belongs in sequentialfocus order and no native element provides the semantics
tabindex="-1" — only for programmatic focus targets and inactive items inroving-tabindex patterns
without a specific interaction requirement
autofocus unless placing initial focus there is expected, tested,and won't surprise users
move it to a logical location — never leave focus on a deleted element
Composite widgets (toolbars, radio groups, tree views, tab lists, menubars) usually expose one Tab stop; arrow keys or other documented keys move among internal items. Two recognized strategies:
tabindex="0", others -1; movementupdates values and DOM focus
attribute identifies the active descendant
Choose the strategy the relevant widget pattern specifies. Do not add custom roving tabindex to a native radio group — browsers already provide its keyboard behaviour.
<div role="toolbar" aria-label="Text formatting">
<button type="button" tabindex="0" aria-pressed="false">Bold</button>
<button type="button" tabindex="-1" aria-pressed="false">Italic</button>
<button type="button" tabindex="-1" aria-pressed="false">Underline</button>
</div>toolbar.addEventListener('keydown', (event) => {
const items = Array.from(toolbar.querySelectorAll('button:not([disabled])'));
const current = items.indexOf(document.activeElement);
let next = current;
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') next = (current + 1) % items.length;
else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') next = (current - 1 + items.length) % items.length;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = items.length - 1;
else return;
event.preventDefault();
items.forEach((item, index) => { item.tabIndex = index === next ? 0 : -1; });
items[next].focus();
});A production toolbar must also address orientation, directionality, disabled items, and dynamic additions. Consult the APG roving tabindex practice for the specific arrow-key directions per widget type.
If a shortcut uses only a printable character, punctuation mark, number, or symbol, provide at least one of: a way to turn it off, a way to remap it to include a non-printing modifier key, or activation only while the relevant component has focus. Single-character shortcuts can be triggered accidentally by speech input. Do not intercept browser, OS, or AT shortcuts, and avoid preventing default behaviour unless the component genuinely replaces it with an accessible equivalent. Display shortcut instructions in a form AT can perceive.
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 lowest-friction bypass.
<a class="skip-link" href="#main-content">Skip to main content</a>
<header>…</header>
<nav aria-label="Primary">…</nav>
<main id="main-content" tabindex="-1">…</main>
<footer>…</footer>.skip-link {
position: fixed;
z-index: 1000;
inset-block-start: 0.5rem;
inset-inline-start: 0.5rem;
padding: 0.75rem 1rem;
color: #ffffff;
background: #000000;
transform: translateY(-200%);
}
.skip-link:focus { transform: translateY(0); }The skip link must be visible on focus — permanently hidden (display: none) is a Serious issue breaking WCAG 2.4.1. Use native landmarks without redundant role attributes (<header> not <header role="banner">) unless the element's native landmark role isn't exposed in context. Give multiple navigation landmarks distinct accessible names.
Choose the mechanism that matches the intended state:
| Mechanism | Visual rendering | Sequential focus | Accessibility tree |
|---|---|---|---|
hidden / display: none | Hidden | Removed | Normally removed |
visibility: hidden | Hidden | Removed | Normally removed |
inert | Usually visible unless separately styled | Descendants removed | Descendants excluded |
aria-hidden="true" | Unchanged | Unchanged | Content hidden from AT |
Never put a focusable element inside an `aria-hidden="true"` subtree — aria-hidden does NOT prevent keyboard focus, and can create a control that receives focus without being exposed to a screen reader. Use hidden for closed disclosures that should be unavailable; use inert when visible content must temporarily become non-interactive. Native <dialog> with showModal() provides document-level modal behaviour without manually applying inert to every sibling. When removing hidden/inert, verify focus order remains logical.
Do not move focus merely because content updated — move it when users need a new interaction context or would otherwise lose their place. Appropriate cases: opening/closing a modal, focusing an error summary after failed submission, moving to the main heading after a client-side route change, recovering when the focused element is deleted. On route change: update the document title, move focus to an appropriate heading/main container, announce the new context without duplicate announcements, and preserve expected Back/Forward behaviour. Do not use positive tabindex or repeated synthetic Tab events to manage application focus.
| Requirement | Description | Severity |
|---|---|---|
| 2.5.1 Pointer Gestures | Multi-point/path-based gestures need 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 a keyboard alternative (cut/paste, up/down button) | Serious |
<!-- Never do this -->
<meta name="viewport" content="width=device-width, user-scalable=no">
<!-- Good -->
<meta name="viewport" content="width=device-width, initial-scale=1">the complete page in both directions; confirm every interactive function is reachable and operable; confirm no inactive/hidden control receives focus; confirm focus indicator is always visible; confirm sticky/overlaid content never fully obscures focus; activate the skip link
test every documented key for each custom widget; test first/last/disabled/ dynamically added/removed items; test Tab/Shift+Tab entry and exit; test writing direction and orientation where relevant; confirm keyboard activation matches pointer activation
focus doesn't leave the dialog; confirm visible controls and Escape close it; confirm focus returns logically; confirm non-modal content doesn't trap focus; test nested overlays and repeated open/close
200%/400% zoom; 320px viewport; light/dark/increased-contrast/forced-colours modes; speech input for controls whose visible labels are used as commands
Automated checks can detect positive tabindex, click handlers on non-interactive elements, focusable descendants of aria-hidden/inert content, missing accessible names, and some focus-indicator problems — but cannot determine whether the complete focus order is meaningful or whether custom interactions follow their expected keyboard model. Retain manual keyboard walkthroughs for every new or changed interaction pattern.
<dialog>/showModal() (or inert/focus-trap fallback);first appropriate element receives focus; focus returns to trigger on close
tabindex="-1" for programmatic focusaria-hiddenaria-activedescendantuser-scalable=no not useddialog elementinert attributefocus-trap librarywicg-inert polyfillStandards 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/>
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.