03 · Keyboard Access & Focus Management¶
Everything a mouse user can do, a keyboard user must be able to do. That single rule (WCAG 2.1.1 Keyboard) covers a wide range of people: screen-reader users (who drive the page from the keyboard), people with tremors or limited mobility, switch and voice-control users (whose tools emulate keyboard input), and power users who simply prefer it. It's also the easiest accessibility requirement to test yourself — put the mouse away and try.
The keys¶
| Key | Does |
|---|---|
| Tab / Shift+Tab | Move focus to the next / previous focusable element |
| Enter | Follow a link; activate a button; submit a form from a text field |
| Space | Activate a button; toggle a checkbox; scroll the page when nothing is focused |
| Arrow keys | Move within a group: radio buttons, select options, tabs, menus; scroll |
| Escape | Close dialogs, popovers, menus |
| Home / End | Start/end of a text field or list |
Links activate with Enter only; buttons with Enter and Space. That's one of the ways users (and assistive tech) tell them apart, and a reason not to fake one with the other.
What's focusable¶
Natively focusable: <a href>, <button>, <input>, <select>, <textarea>,
<summary>, <iframe>, elements with contenteditable, <audio>/<video> with
controls, and anything with a tabindex. Not focusable: <a> without href,
disabled controls, and every div and span.
We built a page with a mix of elements and pressed Tab seven times in Chromium, recording where focus went:
<a id="a1" href="#">A</a>
<div id="div-click" onclick="">div</div>
<span id="span-t0" tabindex="0">span</span>
<button id="b1">B</button>
<input id="i-pos" tabindex="3">
<a id="no-href">no href</a>
<button id="b-dis" disabled>dis</button>
<div id="neg" tabindex="-1">neg</div>
<button id="b2">C</button>
i-poscame first, despite being fifth in the source, because oftabindex="3".- The
divwith a click handler was skipped — addingonclickdoesn't make anything focusable. span-t0was reached because oftabindex="0".<a>withouthref, the disabled button and thetabindex="-1"div were skipped.- After the last element, focus left the page (
body— in a real browser it goes to the address bar and other browser UI) and then cycled back.
tabindex values¶
| Value | Meaning |
|---|---|
0 |
Focusable, in normal source order |
-1 |
Focusable only by script (element.focus()), never by Tab |
positive (1, 3…) |
Focusable, and jumps ahead of all 0 elements, in numeric order |
Never use positive tabindex. As the test shows, it rips one element out of the
natural order, and once one exists, every other element's order becomes hard to reason
about. If the tab order is wrong, fix the source order.
Use tabindex="0" sparingly too: it makes something focusable, but not operable — it
won't respond to Enter or Space and has no role. Usually the right fix is a real
<button>. Legitimate uses include scrollable regions (Level 1 · 05) and custom widgets
that also get a role and key handlers (lesson 04).
Visible focus¶
Focus must be visible (WCAG 2.4.7), and WCAG 2.2 adds that the focused element must not be entirely hidden behind sticky headers or overlays (2.4.11).
:focus-visible matches when the browser judges a visible indicator useful. We tested
Chromium's heuristics:
button focused by mouse click: :focus-visible = false
button focused by keyboard: :focus-visible = true
text input focused by mouse: :focus-visible = true
Clicking a button doesn't show a ring (the user knows where they clicked), keyboard focus
always does, and text inputs always do because you need to see where typing will go.
That's why the old advice to write :focus { outline: none } to "remove ugly rings for
mouse users" is obsolete and harmful — :focus-visible already solves it.
Guidelines for focus styles:
- Outline, not just colour.
outlinedoesn't affect layout, followsborder-radius, and survives Windows High Contrast mode. A background-colour-only change often doesn't. - 3:1 contrast against the adjacent colours.
outline-offsetkeeps the ring clear of the element's own border.- Keep it inside the viewport under sticky headers:
scroll-padding-topon the scrolling element prevents focused elements from being scrolled underneath:
Removing whole regions: inert¶
inert makes an element and everything inside it non-focusable, non-clickable and
hidden from assistive technology — while leaving it visible:
<button id="before">Before</button>
<div inert>
<button>Inert 1</button>
<a href="#">Inert link</a>
</div>
<button id="after">After</button>
Tabbing from the top visited ["before", "after", "body"] — both elements inside the
inert region were skipped. Use it for content behind an overlay, an off-canvas menu when
closed, or a form section that doesn't apply yet (with a visual cue that it's disabled).
Focus management¶
When your page changes without a page load, you are responsible for where focus goes. The rules of thumb:
| Situation | Move focus to |
|---|---|
| Open a dialog | The first focusable element inside it (or the dialog heading) |
| Close a dialog | The control that opened it |
| Delete the focused item in a list | The next item, or the list's heading |
| Show inline form errors on submit | An error summary, or the first invalid field |
| Client-side route change | The new page's <h1> (with tabindex="-1") |
| Load more results | The first new result |
If focus is on an element that gets removed from the DOM, it falls back to <body>, and
a keyboard user has to start tabbing from the top of the page.
The native dialog does it for you¶
We built a modal with the native <dialog> element:
<button id="opener">Open</button>
<dialog id="d">
<h2>Title</h2>
<button id="close" onclick="this.closest('dialog').close()">Close</button>
<input id="field">
</dialog>
<button id="behind">Behind</button>
<script>
document.getElementById('opener').onclick = () => document.getElementById('d').showModal();
</script>
Tabbing to "Open", pressing Enter and observing in Chromium:
focus after showModal(): close
Tab ×4 inside the modal: field, body, close, field
what's on top at the "Behind" button: DIALOG (the backdrop)
after pressing Escape: open = false, focus = opener
showModal() moved focus to the first focusable element, kept Tab cycling within the
dialog (plus the browser's own UI), made everything behind it inert, closed on Escape,
and returned focus to the opener — all without extra script. Level 3 · 09 covers the
dialog in full.
One thing that went wrong while we wrote that test: the first version gave the button
id="open" and wrote open.onclick = .... Nothing happened, because open resolved to
the built-in window.open function rather than the element (browsers expose elements by
id as globals only when the name isn't already taken). Our second attempt used
id="opener" — which collides with window.opener. Use getElementById rather than the
id-as-global shortcut.
Worked example: a roving tabindex toolbar¶
Composite widgets — toolbars, tab lists, menus, grids — should be one Tab stop, with
arrow keys moving within them. Otherwise a 12-button toolbar costs 12 Tab presses to get
past. The standard technique is a roving tabindex: only the active item has
tabindex="0"; the rest have -1.
<div role="toolbar" aria-label="Text formatting" class="toolbar">
<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>
const toolbar = document.querySelector('.toolbar');
const items = [...toolbar.querySelectorAll('button')];
toolbar.addEventListener('keydown', (event) => {
const current = items.indexOf(document.activeElement);
let next = null;
if (event.key === 'ArrowRight') next = (current + 1) % items.length;
if (event.key === 'ArrowLeft') next = (current - 1 + items.length) % items.length;
if (event.key === 'Home') next = 0;
if (event.key === 'End') next = items.length - 1;
if (next === null) return;
event.preventDefault();
items[current].tabIndex = -1;
items[next].tabIndex = 0;
items[next].focus();
});
Tab enters the toolbar at whichever button was last active, arrows move within it, and Tab leaves it. This is the one place where keyboard behaviour needs JavaScript — and where you should follow the WAI-ARIA Authoring Practices patterns exactly, because users expect every toolbar to behave the same way.
How It Actually Works¶
The browser keeps a sequential focus navigation order: first all elements with a
positive tabindex in ascending order (ties in source order), then all elements with
tabindex="0" or natively focusable, in source (DOM) order — not visual order. CSS
that reorders things visually (order, flex-direction: row-reverse, grid placement,
absolute positioning) doesn't change it. The newer CSS reading-flow property is
designed to let the focus order follow a flex or grid layout's visual order; it was
supported in the Chromium build we tested but not in WebKit, so don't rely on it yet.
Focus is tracked per document in document.activeElement. Keyboard events are dispatched
to the focused element first and bubble up, which is how a toolbar's single keydown
listener handles all its buttons. Native elements have built-in activation behaviour:
a button's Enter/Space fires click, a link's Enter follows it. A div has none, so a
div with only a click handler ignores the keyboard entirely (we confirmed this in
lesson 04).
inert and a modal <dialog> work by marking nodes as inert in the browser's internal
tree: inert nodes are skipped by focus navigation, hit testing (clicks) and the
accessibility tree construction.
Common mistakes¶
- Clickable
divs andspans, unreachable and inoperable by keyboard. - Positive
tabindex. outline: nonewith no replacement.- Focus lost to
<body>after deleting, closing or re-rendering. - Custom modals that don't trap focus, so Tab wanders into the page behind.
- Keyboard traps — the opposite problem: a widget you can Tab into but not out of (embedded players and editors are common culprits).
- Tab order that jumps around because of visual reordering.
Exercise¶
- Unplug the mouse and complete a task on your product page: choose a size, change the quantity, add to basket, follow a related-product link. Note every problem.
- Style
:focus-visiblefor your whole site and check it against your dark theme. - Add
scroll-padding-topso focused elements aren't hidden under your sticky header. Test by tabbing down the page. - Build the roving-tabindex toolbar and make each button toggle
aria-pressed. - Convert a custom modal (or build one from
divs), and then replace it with<dialog>andshowModal(). Compare how much script you deleted.