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.
| 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 |
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.
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:
<!-- 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>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.
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 |
Incorrect dialog focus management is Critical — keyboard and screen reader users lose their place, or cannot reach dialog controls at all.
inert attributeThe 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) {
// Make everything outside the dialog inert
document.querySelectorAll('body > *:not(#dialog-container)')
.forEach(el => el.setAttribute('inert', ''));
dialog.removeAttribute('hidden');
// Move focus to first focusable element inside dialog
const first = dialog.querySelector(focusableSelectors);
first?.focus();
}
function closeDialog(dialog, trigger) {
// Remove inert from background content
document.querySelectorAll('[inert]')
.forEach(el => el.removeAttribute('inert'));
dialog.setAttribute('hidden', '');
trigger.focus(); // Return focus to the element that opened the dialog
}
dialog.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeDialog(dialog, trigger);
});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:
// Using the focus-trap library (https://github.com/focus-trap/focus-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();
}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) {
// Shift+Tab: if on first element, wrap to last
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
// Tab: if on last element, wrap to first
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
});
}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;
}
/* Never: :focus { outline: none; } without a visible replacement */WCAG 2.4.11 minimum requirements:
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.
/* Add scroll-margin to all focusable elements to clear sticky headers */
: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.
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.
tabindex values (tabindex="2" etc.) — they override DOMorder globally and create unpredictable sequences
tabindex="0" — use only to make custom widgets focusabletabindex="-1" — use only for programmatic focus targets (skip link anchors,modal focus management, scroll-into-view targets)
fix the DOM order — do not use tabindex to compensate
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();
// Move the roving tabindex
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.
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.
<!-- First element in <body> — must be visible on focus -->
<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.
display:none or visibility:hidden are correctly excluded from tab orderaria-hidden="true" on offscreen content that must remain in the DOMbut is not currently available to users
inert (or aria-hidden) to background contentwhen 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.
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 |
<!-- Never do this -->
<meta name="viewport" content="width=device-width, user-scalable=no">
<!-- Good -->
<meta name="viewport" content="width=device-width, initial-scale=1">For minimum target size:
button, a, [role="button"] {
min-width: 24px;
min-height: 24px;
/* Recommended: 44×44 for primary actions */
}inert Browser Support Noteinert 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.
inert; first focusable element receives focusinert removed; focus returns to triggertabindex="-1" for programmatic focusscroll-margin prevents focused elements being hiddenuser-scalable=no not usedinert attributefocus-trap library — production-ready manual focus trapwicg-inert polyfill — inert for older browsersStandards 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.