Home/Learn/HTML, CSS & Responsive/Keyboard Access and Focus Management

Keyboard Access and Focus Management

Advanced
Accessibility

Everything 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.

:focus-visible, and never a bare outline: none
/* 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.

tabindex 0 and -1 only; DOM order is tab order
/* 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.

Dialogs, removals, and SPA navigation
/* 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 Aria
1

Why is :focus-visible preferable to :focus for styling focus rings?

Medium
2

What happens to focus when you delete the element that has it?

Hard
3

Why is a positive tabindex value considered harmful?

Medium

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.

Loading discussion…