Keyboard Access and Focus Management
AdvancedEverything doable with a mouse must be doable with a keyboard. Test it by unplugging the mouse — most sites fail within a minute.
Overview
Keyboard accessibility serves far more people than it first appears: screen-reader users, people with motor impairments, anyone using voice control, and power users. The test is simple and brutal — put the mouse away and try to complete a task. Sites fail on three things: invisible focus, controls that cannot be reached, and focus that is lost when the UI changes. All three are fixable, and the first is usually a deliberate choice someone made because the default outline was "ugly".
Focus Must Be Visible
The single most damaging line of CSS in common use.
/* This appears in countless codebases and breaks keyboard use */
*:focus { outline: none }
/* If you remove it, you must replace it */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 2px;
}
:focus:not(:focus-visible) { outline: none } /* mouse clicks only */
/* :focus-visible applies for keyboard focus and not for mouse
clicks, which is exactly the behaviour people wanted when they
removed outlines. */
/* WCAG 2.2 requires the focus indicator to be at least 2px thick
and have 3:1 contrast against the adjacent colour. On a dark
background a dark outline is as good as none. */
/* Make it work on both light and dark: */
:focus-visible { outline: 2px solid; outline-color: currentColor }
/* or a two-tone ring that shows on any background: */
:focus-visible { box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent) }
/* Also ensure the focused element is not hidden under a sticky
header when it scrolls into view: */
html { scroll-padding-block-start: 5rem }Tab Order and Reachability
What is focusable by default, and how to fix what is not.
/* Naturally focusable: a[href], button, input, select, textarea,
summary, and anything with tabindex="0".
NOT focusable: div, span, li, p — regardless of onClick. */
tabindex="0" /* add to the natural tab order, in DOM position */
tabindex="-1" /* focusable by script only — for headings and
containers you move focus to */
tabindex="3" /* NEVER. A positive value jumps ahead of everything
and corrupts the order of the whole page. */
/* Tab order follows DOM order, not visual order. CSS that reorders
visually — order in flexbox, grid placement, row-reverse — leaves
the tab order where the DOM says, so a user tabs in a sequence
that does not match what they see. Reorder the DOM instead. */
/* A custom control needs everything a button has: */
<div role="button" tabindex="0"
onclick={act}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && act(e)}>
/* Four attributes and a key handler — or one <button>. */
/* Expected keys for common widgets:
button Enter, Space
link Enter
checkbox Space
radios arrow keys move within the group, Tab leaves it
tabs arrows switch, Home/End jump
menu arrows, Escape closes, typing jumps to an item */Moving Focus
When the UI changes, focus has to go somewhere sensible.
/* A dialog opens */
1. remember document.activeElement
2. move focus into the dialog (its heading, or the first control)
3. trap Tab inside it while it is open
4. Escape closes it
5. restore focus to the trigger on close
<div inert> /* on everything behind, so it is unreachable */
/* Content appears after an action — move focus to it, or a screen
reader user never learns it exists */
const results = await search(q)
resultsHeadingRef.current.focus() // heading has tabindex="-1"
/* Content is REMOVED — focus falls back to the body, which strands
the user at the top of the page. Move it deliberately: */
function deleteRow(id) {
const next = rowRefs.current[id + 1] ?? listRef.current
remove(id)
next.focus()
}
/* Client-side navigation: the browser resets focus on a real page
load, but a SPA route change does not. Focus the new page's h1. */
useEffect(() => { headingRef.current?.focus() }, [pathname])
/* Roving tabindex — a toolbar or tab list should be ONE tab stop,
with arrows moving inside it, not ten separate stops. */Key Points to Remember
- 1outline: none without a replacement is the most damaging common CSS line; :focus-visible is the correct tool
- 2A focus indicator needs about 2px and 3:1 contrast to satisfy WCAG 2.2
- 3Only tabindex 0 and -1 are acceptable — positive values corrupt the page's tab order
- 4Tab order follows the DOM, so visual reordering with CSS desynchronises it from what users see
- 5When a dialog opens, content appears or a row is deleted, focus must be moved deliberately — including on SPA route changes
Interview Questions
Sign in to ask AriaWhy is :focus-visible preferable to :focus for styling focus rings?
What happens to focus when you delete the element that has it?
Why is a positive tabindex value considered harmful?
Ask Aria about Keyboard Access and Focus Management
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.