Keyboard and Focus
When to Use
Keyboard accessibility is required for all interactive elements — every action a mouse user can take must be reachable and operable by keyboard alone, with a visible focus indicator.
Decision
| Need | Pattern | Notes |
|---|---|---|
| Single interactive element | Native <button> / <a> |
Browser handles Tab, Enter, Space |
| Tab order matches visual layout | DOM order = visual order | Avoid CSS order diverging from source order |
| Visible focus ring | :focus-visible with sufficient contrast |
Never just outline: none |
| Element reachable by Tab | tabindex="0" (or implicit from native element) |
Assign ARIA role if not a native element |
| Element focusable only by script | tabindex="-1" then .focus() |
Skip-link targets, dialog on open |
| Custom widget arrow key navigation | Roving tabindex in JS | See Keyboard Navigation Craft |
| Focus trap for modal | <dialog>.showModal() → browser handles it |
See Native Dialog |
| Toggle button state | aria-expanded / aria-pressed |
Set programmatically when state changes |
Positive tabindex is always wrong: tabindex="1" creates a separate, earlier focus sequence that breaks the natural DOM order. Restructure the DOM if the visual order differs.
aria-hidden on focusable elements: Never set aria-hidden="true" on an element that can receive keyboard focus — it becomes an invisible, unreachable focus trap for AT users.
Pattern
/* Explicit :focus-visible — high contrast, never removed */
:where(a:any-link, button, [tabindex]):focus-visible {
outline: 3px solid #ff0055;
outline-offset: 3px;
}
// Custom widget: Enter on keydown, Space on keyup (matches native button)
customWidget.addEventListener('keydown', (e) => {
if (e.key === 'Enter') toggleState();
if (e.key === ' ') e.preventDefault(); // Block scroll
});
customWidget.addEventListener('keyup', (e) => {
if (e.key === ' ') toggleState();
});
function toggleState() {
const expanded = customWidget.getAttribute('aria-expanded') === 'true';
customWidget.setAttribute('aria-expanded', String(!expanded));
}
Common Mistakes
- Wrong:
outline: nonewith no replacement → Right: Focus position becomes invisible for keyboard users; always provide a visible replacement - Wrong: Positive
tabindexvalues → Right: Destroys predictable tab order; usetabindex="0"only - Wrong: Skip-link target missing
tabindex="-1"→ Right:<main>is not natively focusable;.focus()silently fails without it - Wrong: Toggling element visibility with CSS only → Right: Keyboard focus can land on visually hidden interactive elements; use
hidden,inert, ordisplay: none
See Also
- Native Dialog — browser-native focus management in modals
- Reference: Keyboard Navigation Craft — roving tabindex, focus trap, focus restoration
- Reference: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/