cf.completefrontend
My progress
FUNDAMENTALS · MODULE 12

Keyboard navigation

Making every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.

The problem: a page you cannot drive

Picture an email app. A user opens it, presses Tab once, and lands on the logo link. Tab again: the search box. Then 14 presses through folder links, 6 through a toolbar, and 50 through the rows of the inbox before reaching the reading pane.

Somewhere in the middle the focus ring disappears, because a designer removed the outline. The user no longer knows where they are.

Then they open the "More" menu with Enter. It opens, but Tab walks straight past it into the page behind, and Escape does nothing. To close it they have to reload. Every one of these problems is invisible to a developer who tests with a mouse.

Keyboard navigation is how a person reaches and uses every control on a page with keys alone. It is not a nice extra. For a large group of people it is the only way in, and every other input method that is not a mouse is built on top of it.

Who uses the keyboard

  • People with motor disabilities. Tremors, arthritis, limited reach, or paralysis can make a mouse slow or impossible. They use a keyboard, a large-key keyboard, or a switch device: one or two buttons that the system maps to keys like Tab and Enter.
  • Screen reader users. A blind user cannot aim a pointer at a target they cannot see. Screen readers such as NVDA, JAWS, and VoiceOver are driven by keys (on desktop) and pass many keys through to the page.
  • Voice control users. Tools like Dragon and Voice Control can say "press Tab" or "click Save", and they rely on the same focusable, named controls.
  • Power users. Developers, support agents, and anyone filling in forms all day move faster with keys. Products like Gmail, GitHub, Linear, and Figma build whole shortcut systems for them.
  • Everyone, sometimes. A broken trackpad, a tablet with a keyboard case, a TV remote, or a game controller: many of these send arrow keys and Enter.

What the rules say

WCAG, the Web Content Accessibility Guidelines that most accessibility laws point to, puts keyboard access at its most basic level, A. Success criterion 2.1.1 says all functionality must work from a keyboard. 2.1.2 says focus must never get stuck in a place you cannot leave with the keyboard.

2.4.3, also level A, says the focus order must keep the page's meaning and operation. 2.4.7, at level AA, says the focus indicator must be visible.

WCAG 2.2, published in October 2023, added 2.4.11 Focus Not Obscured: the focused element must not be fully hidden by something the author put on top of it, like a sticky header or a cookie banner. Laws and rules such as the European Accessibility Act, which applies from June 2025, and most ADA cases in the United States use WCAG level AA as the bar. Keyboard access is the part auditors check first because it is the easiest to test: put the mouse away and try.

What this lesson covers

The keyboard model of the web is small, and this lesson walks through all of it: how the Tab sequence is built, how to keep focus visible, how composite widgets like toolbars, listboxes, and grids use arrow keys, how skip links save hundreds of key presses, how to avoid traps, and how to add shortcuts without breaking assistive technology. You will build a roving tabindex hook and test its key handling.

This lesson assumes you know why a button beats a div with a click handler; the semantic HTML lesson covers that. Moving focus on purpose when content changes (opening a dialog, changing route) is the focus management lesson. Roles and states for each widget are in ARIA patterns. Here the subject is the keys themselves.

Check your understanding · Exercise 1

Which of these depend on the page being fully keyboard operable? Select all that apply.

The mental model: a hallway and rooms

Think of a hotel corridor. Tab walks you from door to door. You don't want to walk past every chair in every room just to reach the lift. So when you step into a room, like a toolbar or a list, you move around inside it with the arrow keys, and one more Tab takes you back out to the next door.

That picture holds the two levels of keyboard navigation. Between widgets, Tab and Shift+Tab move through tab stops, the elements that are in the page's Tab sequence. Inside a composite widget, a widget made of many items like a toolbar, a tab list, a listbox, a menu, a tree, or a grid, the arrow keys move between items, and the whole widget is a single tab stop.

The same page drawn twice: 3 header links, a toolbar of 6 buttons, and a list of 5 items. On the left every item is its own tab stop, numbered 1 to 14, so crossing the page takes 14 Tab presses. On the right the toolbar and the list are composite widgets: each is one tab stop, numbered 4 and 5, with arrow keys moving inside, so crossing the page takes 5 presses.every item a tab stop: 14 Tabsone stop per widget: 5 TabsEvery item1234567891011121314Composite widgets12345arrowsarrows
The header links stay as they are. Turning the toolbar and the list into composite widgets cuts the Tab sequence from 14 stops to 5, and the arrow keys do the fine movement inside each.

The handful of keys

Users learned these keys from their operating system long before they met your site. Native controls already follow them, so a custom widget must too. The table is the contract.

KeyWhat users expectWhere it applies
Tab / Shift+TabGo to the next or previous tab stopWhole page
EnterFollow a link, press a button, submit a formLinks, buttons, form fields
SpacePress a button, toggle a checkbox, scroll the page when nothing is focusedButtons, checkboxes, switches
Arrow keysMove between items in a group; change a slider or radio valueComposite widgets, radios, sliders, selects
Home / EndGo to the first or last itemLists, toolbars, menus, grids, sliders
EscapeClose what just opened and go back to where you wereMenus, dialogs, popovers, comboboxes
Printable charactersTypeahead: jump to the item starting with that letterListboxes, menus, trees

Notice what is missing: there is no key that means "click this div". A link reacts to Enter but not Space. A button reacts to both.

A native select opens on Space, Enter, or Alt+Down depending on the platform. Getting these details right for free is one more reason the semantic HTML lesson says to use native elements first.

Two numbers to keep low

Keyboard design is a counting game. For any page you can count two numbers: how many presses it takes to reach the main task, and how many tab stops there are in total. A news page with 120 links in its header and menus asks a keyboard user to press Tab 121 times to reach the first article. With a skip link it takes 3 presses: Tab to the skip link, Enter to follow it, and Tab to the article.

Composite widgets cut the second number. A toolbar with 12 buttons as 12 tab stops costs 12 presses to cross. As one tab stop with arrow keys inside, it costs 1. A data grid with 50 rows and 8 columns could be 400 tab stops; as a grid widget it is 1.

Check your understanding · Exercise 2

A page has a header with 5 links, a toolbar of 8 buttons built as one composite widget, a list of 20 links, and a footer with 6 links. How many presses of Tab does it take to cross the whole page once? Pick one.

How the Tab sequence is built

The browser builds the sequential focus navigation order, the Tab sequence, from the DOM. It walks the document in source order and collects every element that is focusable and not taken out of the sequence. Tab moves forward through that list, Shift+Tab moves back, and after the last stop focus goes to the browser's own interface (the address bar) before coming round again.

What is focusable by default

  • Links with an href (an a without href is not focusable).
  • button, input (except type="hidden"), select, textarea, unless disabled.
  • summary inside details, iframe, and media elements with controls.
  • Any element with contenteditable.
  • Scroll containers with no focusable children, in Chrome since version 130 and in Firefox, so keyboard users can scroll them with arrows.

Everything else, such as a div, span, li, or heading, is not focusable until you add tabindex. Hidden elements are skipped: display: none, visibility: hidden, the hidden attribute, and anything inside an inert subtree. Elements that are only visually hidden, for example moved off-screen or set to opacity: 0, are still in the sequence, which is a common source of invisible tab stops.

The three kinds of tabindex

ValueFocusable by scriptIn the Tab sequenceUse it for
tabindex="0"YesYes, at its place in the DOMThe focusable part of a custom widget; the active item of a roving group
tabindex="-1"Yes, with element.focus()NoHeadings and containers you move focus to; the inactive items of a roving group
tabindex="1" or moreYesYes, before every 0 element, in number orderNothing. Avoid it.
A decision flowchart for tabindex. First: is it a native control like a button, link, or input? If yes, it is focusable already and needs no tabindex. If no: must it ever take focus? If no, leave it alone, as with static text and images. If yes: should Tab stop on it? If yes, use tabindex 0, as for a custom widget or the active item of a group. If no, use tabindex minus 1, for headings you move focus to and the inactive items of a roving group. A separate box warns: tabindex 1 or more, never.Native control?Focusable alreadyno tabindex neededyesnoMust it take focus?No tabindexstatic text, imagesnoyesShould Tab stop here?tabindex=0custom widget, active itemyesnotabindex=-1headings, roving itemstabindex=1 or morenever: breaks the order
Three questions cover every case. Positive values never come out of the chart: if something must come first, move it first in the DOM.

Why positive tabindex hurts

A positive value moves an element to the front of the whole page's sequence. All elements with positive values come first, sorted by number, and only then does the browser visit the tabindex="0" and native elements in DOM order. One tabindex="1" on a search box in the middle of the page means the first Tab press jumps there, and the next press jumps back to the top.

It also does not compose. Two components that each use tabindex="1" to put themselves first now fight each other, and the order changes whenever someone adds a third. The fix is always the same: put the elements in the right DOM order and use 0. axe and Lighthouse both flag positive values for this reason.

A DOM tree. body has three children: header, main, and footer. header holds a Logo link and a Nav link. main holds a Search input with tabindex 1 and an Article link. footer holds a Contact link. Because Search has a positive tabindex, Tab visits it first, then jumps back to the top for Logo, Nav, Article, and Contact: the order is Search 1, Logo 2, Nav 3, Article 4, Contact 5.bodyheaderLogoTab 2NavTab 3mainSearchTab 1ArticleTab 4footerContactTab 5tabindex=1 on Searchorder: Search, Logo, Nav, Article, Contact
One tabindex="1" in the middle of the tree pulls Search ahead of everything, so the first Tab lands mid-page and the second jumps back to the logo. Every other element still follows DOM order.

When the screen and the DOM disagree

CSS can show elements in a different order from the DOM: flexbox order, flex-direction: row-reverse, grid placement with grid-area, and absolute positioning. Focus still follows the DOM, so the ring jumps around the screen. WCAG 2.4.3 Focus Order fails when that jumping changes meaning, for example when a form's Submit button is visited before its fields.

The fix is to change the DOM order, not to patch it with tabindex. A CSS property called reading-flow, from the CSS Display Level 4 draft, lets focus follow the visual order of flex and grid items. Chrome shipped it in version 137, but Firefox and Safari do not support it yet, so treat it as an extra, not a fix.

Portals are the other common cause. A React portal renders a popover at the end of body, far from the button that opened it, so the next Tab after the popover lands at the bottom of the page. The focus management lesson covers how to handle this.

html
<div class="actions" style="display: flex; flex-direction: row-reverse">
  <button>Save</button>
  <button>Cancel</button>
</div>

<!-- Fix: write the DOM in the order people read it -->
<div class="actions" style="display: flex">
  <button>Cancel</button>
  <button>Save</button>
</div>
The visual order says Cancel then Save; the DOM says Save then Cancel, so Tab goes right to left.

Check your understanding · Exercise 3

A settings page has a heading that you move focus to after the user saves, so screen readers announce "Settings saved". Which tabindex should the heading have? Pick one.

Focus you can always see

A keyboard user's focus ring is their mouse pointer. Without it they press Tab and have no idea where they are. Yet outline: none is one of the most common lines in CSS resets, added because designers disliked the ring that appeared when someone clicked a button.

WCAG 2.4.7 Focus Visible (level AA) requires a visible indicator for every focusable element when it has keyboard focus. Browsers draw one by default; the bug is almost always author CSS that removes it, or a custom style that is too faint to see against the background.

:focus versus :focus-visible

The :focus pseudo-class matches whenever an element has focus, however it got there. :focus-visible matches only when the browser decides the user would benefit from seeing focus. That is what lets you keep a strong ring for keyboard users without showing it after every mouse click. It is supported in every current browser.

  • Keyboard focus: Tab, Shift+Tab, or arrow keys moving focus. The ring shows.
  • Mouse or touch on a button or link: no ring. The user already knows what they clicked.
  • Text inputs and textareas: the ring always shows, even on click, because the user needs to see where typing goes.
  • Script calls focus(): the browser carries over the last input. After a key press it shows the ring; after a click it usually does not. You can force it with element.focus({ focusVisible: true }), which works in Firefox 104, Safari 18.4, and Chrome 145 and later.
A matrix of when the :focus-visible ring shows. Columns are how focus arrived: Tab or an arrow key, a mouse click, a script focus call after a key press, and a script focus call after a click. For a button and for a link: ring, no ring, ring, no ring. For a text input: ring in all four cases, because the user always needs to see where typing goes.Tab orarrow keymouseclickfocus()after a keyfocus()after a clickbuttonringnoneringnonelinkringnoneringnonetext inputringringringringsame CSS rule, different outcome: the browser decides
The browser guesses whether a ring helps. Keyboard focus always shows it; clicks on buttons and links do not; text fields always do. Script focus copies the last input the user made.
css
/* A strong ring that works on light and dark backgrounds */
:focus-visible {
  outline: 3px solid #1a5fd0;
  outline-offset: 2px;
}

/* Mouse clicks on buttons: no ring. Keyboard: the rule above. */
button:focus:not(:focus-visible) {
  outline: none;
}

/* Windows High Contrast and other forced colors modes drop
   box-shadow, so never use a shadow as the only indicator. */
@media (forced-colors: active) {
  :focus-visible { outline-color: Highlight; }
}
Remove the default only where you replace it, and only for :focus-visible.

What makes a ring good enough

WCAG 2.2 added 2.4.13 Focus Appearance at level AAA, which gives a useful target even if you only aim for AA: the indicator should be at least as large as a 2 CSS pixel thick outline around the element, with a contrast ratio of at least 3 to 1 between its focused and unfocused pixels. A 1 pixel dotted grey line on a white page fails that easily.

  • Use outline, not box-shadow or border. Outlines do not change layout and survive forced colors mode.
  • Use outline-offset so the ring does not sit on top of the element's own border and blend into it.
  • Test on every background the element appears on. A blue ring on a blue button vanishes; a two-tone ring (an outline plus a contrasting inner shadow) works on any colour.
  • Do not rely on a colour change alone, like a slightly darker background. It is easy to miss and fails for people with low colour vision.

Focus not obscured: sticky headers and banners

A ring you cannot see because something covers it is no better than no ring. WCAG 2.2 added 2.4.11 Focus Not Obscured (Minimum) at level AA: when an element gets keyboard focus, it must not be entirely hidden by content the author created. 2.4.12, the AAA version, says no part of it may be hidden.

The classic failure is a sticky header. The user presses Shift+Tab, the browser scrolls just enough to bring the focused link into the viewport, and the link lands right under a 64 pixel fixed header. Cookie banners, chat launchers, and sticky "Add to cart" bars at the bottom do the same thing.

Two browser windows with a 64 pixel sticky header. On the left, without scroll padding, the user presses Shift+Tab and the browser scrolls the focused Pricing link just into the viewport, where it sits fully under the header: the focus ring cannot be seen, failing WCAG 2.4.11. On the right, with scroll-padding-top set to 72 pixels, the browser scrolls the Pricing link to just below the header, where it is fully visible.no scroll-paddingscroll-padding-top: 72pxShift+Tab to PricingPricinglinklinklinksticky header, 64pxfocused link is under itShift+Tab to PricingPricinglinklinklinksticky header, 64pxfocus lands below the header
The browser scrolls focus into the viewport but does not know about your header. scroll-padding-top tells it how much of the top is covered.

The fix is one line of CSS: scroll-padding-top on the scrolling element (usually html) set to the header height, and scroll-padding-bottom for bottom bars. The browser then scrolls focused elements and anchor targets into the area that is not covered. For banners, either make them non-modal and out of the way, or put them first in the DOM so users deal with them before anything else.

Check your understanding · Exercise 4

Which of these meet the focus visible and not obscured rules? Pick one.

Arrow keys inside composite widgets

A composite widget holds many items but takes one tab stop. Tab enters it and lands on one item, the active item. Arrow keys move the active item, and Tab again leaves the widget.

The WAI-ARIA Authoring Practices Guide (APG) defines this for toolbars, tab lists, radio groups, listboxes, menus, menubars, trees, tree grids, and grids.

There are two ways to track the active item, and every composite widget picks one: move real DOM focus to it (roving tabindex), or keep focus on the container and point at the item (aria-activedescendant).

Roving tabindex

Exactly one item has tabindex="0"; all the others have tabindex="-1". Tab therefore lands on that one item. When the user presses Right Arrow, the script sets the current item to -1, sets the next item to 0, and calls focus() on it. The 0 "roves" through the group.

Because the 0 stays where the user left it, Tabbing back into the widget returns to the last active item, not the first. That memory is part of the pattern: a user who chose Italic in a toolbar and tabbed away comes back to Italic.

A toolbar of five buttons: Bold, Italic, Underline, Link, Code. Bold has tabindex 0 and the others minus 1. Focus is on Bold.focus inside the toolbarBoldtabindex=0Italictabindex=-1Underlinetabindex=-1Linktabindex=-1Codetabindex=-1one tab stop for the whole toolbar
Exactly one button holds tabindex=0 at any moment. Arrow keys move it; Tab only enters and leaves. A hatched box is the remembered button while focus is outside.

Focus is on Bold, the only button with tabindex=0. Press a key below.

aria-activedescendant

Real focus stays on one element, often a text input or the listbox container. That element has aria-activedescendant set to the id of the active item. Arrow keys change the attribute, not focus. Screen readers announce the referenced item as if it were focused, and you draw the highlight yourself with a class.

This is the only option for a combobox, because focus must stay in the text input so the user can keep typing. The published autocomplete guide uses exactly this: the input keeps focus, Down Arrow moves aria-activedescendant through the suggestions. It also suits huge virtualized lists, where the active item may not be in the DOM long enough to hold focus.

A sequence diagram with four columns: the user, the focused text input, the listbox, and the screen reader. The user presses Down arrow in the input. The input's script sets aria-activedescendant to the id opt-2 and adds a highlight class to that option in the listbox. The screen reader notices the active option changed and says Canada, 2 of 5. The user keeps typing the letter n, and it goes straight into the input, because focus never left it.UserInput (focus)ListboxScreen readerDown arrowactive: opt-2highlight classactive changedCanada, 2 of 5types nstill focused: typing works
Only an attribute moves. Real focus stays in the input the whole time, which is why comboboxes use aria-activedescendant instead of roving tabindex.

Decision How should a composite widget track its active item?

Roving tabindex

One item has tabindex="0", the rest -1

Arrow keys move the 0 and call focus()

Pros

  • Real focus: the browser scrolls the item into view and :focus-visible works
  • Every screen reader supports it reliably
  • Remembers the last item when Tabbing back in

Cons

  • Item must be in the DOM to take focus, which is awkward with virtualization
  • Cannot work when focus must stay in a text input

aria-activedescendant

Focus stays on the container or input

Its aria-activedescendant holds the active item's id

Pros

  • Focus never leaves the input, so typing continues
  • Fewer focus and blur events; simpler with virtualized lists

Cons

  • You must scroll the item into view and draw the highlight yourself
  • Referenced id must exist in the same document, not in another shadow root
  • Some older screen reader and browser pairs announce it less reliably

What we pick: Use roving tabindex for toolbars, tab lists, radio groups, menus, trees, and grids. Use aria-activedescendant when focus must stay in a text field (combobox, autocomplete, command palette) or the list is virtualized and very long.

The key details users notice

  • Orientation. A horizontal toolbar or tab list uses Left and Right. A vertical listbox or menu uses Up and Down. A grid uses all four. Set aria-orientation when it is not the role's default, so screen readers describe the right keys.
  • Wrapping. Toolbars, tab lists, and menus usually wrap from the last item to the first. Listboxes and grids usually stop at the ends. Be consistent within a product.
  • Home and End. First and last item. In a grid, Home and End move within the row, and Ctrl+Home and Ctrl+End go to the first and last cell of the grid.
  • Typeahead. In listboxes, menus, and trees, typing a letter moves to the next item starting with it. Typing "ca" quickly moves to "Canada", not to the first "a" item. The typed string resets after about half a second of no typing.
  • Disabled items. In a toolbar or menu, disabled items usually stay focusable so users can discover them; in a listbox they may be skipped. Either way, mark them with aria-disabled="true".
  • Right to left. In Arabic or Hebrew layouts, Left and Right swap meaning for horizontal widgets. Read the direction from the document, not a hard-coded constant.

Grids: two dimensions

A data grid with role="grid" is one tab stop. Arrow keys move between cells, and Page Up and Page Down move by a screenful. If a cell contains a widget, like a text box or a menu button, Enter or F2 moves focus into it and Escape brings focus back to the cell.

Many tables do not need this: a plain table with a few links in it is fine with normal Tab. Use the grid pattern only when the table is an interactive widget, like a spreadsheet or a file list.

Check your understanding · Exercise 5

Which widgets should use aria-activedescendant rather than roving tabindex? Select all that apply.

Building a roving tabindex hook

The roving pattern splits cleanly into two parts. A pure function decides which index a key press leads to; it knows nothing about the DOM, so you can test it in Node in milliseconds. A small React hook keeps the active index in state, hands out tabIndex and onKeyDown props, and moves real focus.

Step 1: the key logic as a pure function

ts
type Orientation = "horizontal" | "vertical";
type Options = { count: number; orientation: Orientation; wrap: boolean; rtl?: boolean };

export function nextIndex(key: string, index: number, o: Options): number | null {
  const last = o.count - 1;
  const prevKey = o.orientation === "vertical" ? "ArrowUp" : o.rtl ? "ArrowRight" : "ArrowLeft";
  const nextKey = o.orientation === "vertical" ? "ArrowDown" : o.rtl ? "ArrowLeft" : "ArrowRight";
  if (key === "Home") return 0;
  if (key === "End") return last;
  if (key === nextKey) return index < last ? index + 1 : o.wrap ? 0 : last;
  if (key === prevKey) return index > 0 ? index - 1 : o.wrap ? last : 0;
  return null;
}

export function typeahead(labels: string[], from: number, typed: string): number | null {
  const q = typed.toLowerCase();
  for (let step = 1; step <= labels.length; step++) {
    const i = (from + step) % labels.length;
    if (labels[i].toLowerCase().startsWith(q)) return i;
  }
  return null;
}
Returns the next index, or null when the key is not ours (so the browser keeps its default).

Two details matter. Returning null for keys the widget does not own, like Tab, means the handler will not call preventDefault() on them, so Tab still leaves the widget. And typeahead searches from the item after the current one, so pressing "b" twice cycles through every item starting with b.

Step 2: test the logic in Node

ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { nextIndex, typeahead } from "./roving.ts";

const bar = { count: 4, orientation: "horizontal", wrap: true } as const;

test("arrows move and wrap", () => {
  assert.equal(nextIndex("ArrowRight", 0, bar), 1);
  assert.equal(nextIndex("ArrowRight", 3, bar), 0);
  assert.equal(nextIndex("ArrowLeft", 0, bar), 3);
  assert.equal(nextIndex("ArrowLeft", 0, { ...bar, wrap: false }), 0);
});

test("Home, End, and keys we do not own", () => {
  assert.equal(nextIndex("End", 1, bar), 3);
  assert.equal(nextIndex("Home", 2, bar), 0);
  assert.equal(nextIndex("Tab", 2, bar), null);
  assert.equal(nextIndex("ArrowDown", 2, bar), null);
});

test("typeahead cycles from the next item", () => {
  const labels = ["Bold", "Italic", "Bullets", "Link"];
  assert.equal(typeahead(labels, 0, "b"), 2);
  assert.equal(typeahead(labels, 2, "b"), 0);
  assert.equal(typeahead(labels, 0, "z"), null);
});
Runs with node --test; no browser, no DOM, no React.

Step 3: the hook

tsx
export function useRovingTabindex(count: number, opts: Omit<Options, "count">) {
  const [active, setActive] = useState(0);
  const refs = useRef<(HTMLElement | null)[]>([]);

  function move(to: number) {
    setActive(to);
    refs.current[to]?.focus();
  }

  function itemProps(i: number) {
    return {
      ref: (el: HTMLElement | null) => { refs.current[i] = el; },
      tabIndex: i === active ? 0 : -1,
      onFocus: () => setActive(i),
      onKeyDown: (e: React.KeyboardEvent) => {
        if (e.altKey || e.ctrlKey || e.metaKey) return;
        const to = nextIndex(e.key, i, { count, ...opts });
        if (to === null) return;
        e.preventDefault();
        move(to);
      },
    };
  }
  return { active, itemProps };
}
  • onFocus syncs the state. If the user clicks an item, it gets focus and becomes the active one, so Tabbing away and back returns there.
  • Modifier keys pass through. Alt+Left is browser Back on Windows and Linux; Cmd+Left goes to the start of a line on macOS. A widget that swallows them breaks platform behaviour.
  • preventDefault() only for keys you handle. Otherwise arrow keys also scroll the page while moving focus.
  • Keep the active index valid. If items are removed and active is now past the end, clamp it, or no item has tabindex="0" and the widget vanishes from the Tab sequence.

Lab: three ways to build the same toolbar

The lab below is a real toolbar with five buttons. Switch the strategy and use the keyboard on it: press Tab to enter, try the arrow keys, then Tab out to the button after it. Watch the tabindex row and the counter of Tab presses needed to cross it.

The toolbar uses

One tab stop: Tab enters on the active button, arrows and Home or End move inside, and Tab leaves. Come back and you land where you left.

Click “Start here”, then use only the keyboard: Tab into the toolbar, try the Left and Right arrows, Home, and End, then Tab on to “After the toolbar”. In Safari on a Mac, turn on “Press Tab to highlight each item” first, or use Option+Tab.

tabindex now: Bold=0 Italic=-1 Underline=-1 Link=-1 Code=-1

Last key: Nothing yet. Focus is on: nothing in the lab.

With every button at 0, Tab visits all five and arrows do nothing. With roving tabindex, one Tab enters, arrows move, one Tab leaves, and coming back returns to the last button. With all buttons at -1, the toolbar disappears from the Tab sequence entirely: a mouse user would never notice, and a keyboard user cannot reach it at all.

Check your understanding · Exercise 6

A developer's roving toolbar calls preventDefault() on every keydown. What breaks? Select all that apply.

Keyboard traps and the modal exception

A keyboard trap is a place focus can enter but not leave with the keyboard. It is the most serious keyboard bug, because it does not just slow the user down, it ends their session. WCAG 2.1.2 No Keyboard Trap is level A: if focus can move into a component, it must be able to move out using only the keyboard, and if that needs anything other than Tab, Shift+Tab, or the arrow keys, the user must be told how.

How traps happen

  • Swallowed Tab. A keydown handler calls preventDefault() on every key, or on Tab specifically, to keep focus in a widget.
  • Embedded content. Third-party iframes, old video players, maps, and code editors that grab Tab and never give it back.
  • Rich text and code editors. Tab inserts an indent, which is useful, but then nothing leaves the editor.
  • Focus loops in script. A blur handler that moves focus back "until the field is valid", or two widgets that each pull focus to themselves.
  • Focus lost to the body. Not a trap, but close: the focused element is removed (a deleted list item, a closed popover), focus falls to body, and the next Tab starts from the top of the page.
A state machine of where keyboard focus can be. From the Page, in normal Tab order, Tab moves into a Widget such as a toolbar or editor, and Tab moves back out to the Page. Enter on a trigger opens a Modal dialog, where the page behind is inert and Tab cycles inside the dialog. Escape or the Close button goes back to the Page, with focus returned to the trigger. A buggy widget handler that swallows Tab leads to a Trap state, which has no way out: a failure of WCAG 2.1.2.Trapno key leavesfails WCAG 2.1.2Widgettoolbar, editorPagenormal Tab orderModal dialogpage behind inerthandler eats TabTab inTab outEnter opensTab cycles insideEscape or Close: focus back to trigger
Every state needs a keyboard way out. The modal loops on purpose but always exits on Escape; the trap is the only state with no outgoing arrow.

A modal dialog deliberately keeps focus inside it. While it is open, the page behind is not usable, so Tab cycles through the dialog's controls only. This is not a WCAG failure, because the user can always leave: Escape closes the dialog, and so does its Close or Cancel button, and then focus returns to the button that opened it.

Today the platform does most of this for you. dialog.showModal() makes everything outside the dialog inert, closes on Escape, and the browser restores focus to the previously focused element on close in current Chrome, Firefox, and Safari. For custom overlays, the inert attribute on the rest of the page does the same job as the old hand-written focus loops. The modal dialog guide walks through the full build, and the focus management lesson covers where focus goes when it opens and closes.

ts
editor.addEventListener("keydown", (e) => {
  if (e.key === "Escape") {
    escapeMode = true; // the next Tab moves focus instead of indenting
    return;
  }
  if (e.key === "Tab" && !escapeMode) {
    e.preventDefault();
    indent(e.shiftKey ? -1 : 1);
  }
  if (e.key === "Tab") escapeMode = false;
});
Editors: let Tab indent, but give users a documented way out.

That is the approach VS Code and the Monaco editor take: Ctrl+M (Ctrl+Shift+M on macOS) toggles "Tab moves focus", and the editor announces it. CodeMirror 6 does not bind Tab by default for the same reason, and documents Escape then Tab as the way out when you do bind it. Whatever the key, the 2.1.2 rule is that the user must be able to find it, so put it in the editor's help text or announce it on focus.

Finding traps before users do

Automated scanners cannot find traps: they would have to press keys and notice that focus never left. Add a manual pass to every release that adds a widget: Tab into it, Tab out of it, Shift+Tab back in and out, and press Escape on anything that opened. Browser test tools such as Playwright can automate the same pass with page.keyboard.press("Tab") and a check on document.activeElement.

Check your understanding · Exercise 7

Which of these are failures of WCAG 2.1.2 No Keyboard Trap? Select all that apply.

Shortcuts, screen readers, and external keyboards

Once the basics work, many products add keyboard shortcuts: j and k to move through a list, c to compose, / to focus search, ? to show help. Done well, they make power users much faster. Done carelessly, they fight with screen readers, speech input, and the browser.

Who gets a key press first

A key press passes through several layers before your keydown handler sees it, and any layer can keep it. The operating system takes its own shortcuts, and a screen reader takes the keys of its current mode. The browser keeps a few shortcuts for itself. Only then does the page get the event, and even then a focused native control may act on it first.

A stack of five layers a key press passes through, top first. The operating system takes keys like Alt+Tab, Cmd+Space, and Cmd+Tab. The screen reader takes Insert combinations, and single letters like H, K, and B in browse mode. The browser takes Ctrl+T, Ctrl+W, F6, and Ctrl+L. Then your page's keydown handler sees the event, for shortcuts and roving focus. Last, unless your handler prevents it, the element's default action runs, such as Tab moving focus or Space scrolling.key press travels downOperating systemAlt+Tab, Cmd+Space, Cmd+TabScreen readerInsert+key; H, K, B (browse)BrowserCtrl+T, Ctrl+W, F6, Ctrl+LYour keydown handlershortcuts, roving focusElement default actionTab moves focus, Space scrolls
Your handler sees only what the layers above let through, and anything you preventDefault() never reaches the layer below. Pick shortcuts the upper layers do not want.

Screen readers on Windows have two modes. In browse mode (NVDA) or the virtual cursor (JAWS), single letters are navigation commands. In NVDA, for example, H moves to the next heading, K to the next link, B to the next button, and F to the next form field. The page never sees those letters.

In focus mode, entered automatically on a text field or a widget with an application-like role, keys go through to the page. That is why a composite widget with the right role gets its arrow keys, and why a div with only a keydown handler often does not.

Single-key shortcuts and WCAG 2.1.4

Speech input users have a different problem. Dragon and Voice Control type words into the page when the user dictates. If the page has single-letter shortcuts active everywhere, the user saying "can you check this" fires c, a, n, and so on as commands. People with tremors hit keys by accident and trigger the same chaos.

WCAG 2.1.4 Character Key Shortcuts (level A) says that if you use shortcuts made only of letters, numbers, punctuation, or symbols, at least one of these must be true:

  1. Turn off: the user can switch them off.
  2. Remap: the user can change them to include a modifier key like Ctrl or Alt.
  3. Active only on focus: the shortcut works only when a specific component has focus, like typeahead in a listbox.

Gmail's single-key shortcuts are off by default and switched on in settings. GitHub has a setting in its accessibility preferences to turn character key shortcuts off. Shortcuts that include Ctrl, Alt, or Cmd are outside 2.1.4, but they can clash with browser and screen reader commands instead.

ShortcutClash riskRule
j, k, c everywhereSpeech input, accidental presses, screen reader browse modeMust be switchable off or remappable (2.1.4)
/ to focus searchSame as above; also typed in any text fieldIgnore when focus is in an input; allow turning off
Ctrl+K command paletteLow; Chrome and Firefox on Windows and Linux use Ctrl+K to focus the search or address bar, but a page may take itFine; document it
Ctrl+D, Ctrl+T, Ctrl+WHigh: bookmark, new tab, close tabDo not override
Insert+key, Caps Lock+keyReserved by NVDA, JAWS, and NarratorNever use

Shortcut hygiene

  • Ignore shortcuts while focus is in input, textarea, select, or contenteditable, and while event.isComposing is true (users typing with an IME, for Chinese or Japanese, are mid-word).
  • Match on event.key for letter shortcuts so they follow the user's layout (a French AZERTY keyboard has A where QWERTY has Q). Use event.code only for position-based keys like WASD in games.
  • Use Cmd on macOS and Ctrl elsewhere for the same action, and show the right one in your UI.
  • Show shortcuts in tooltips and menus, and add aria-keyshortcuts on the control so screen readers can announce them.
  • Avoid the old accesskey attribute. Its modifier keys differ by browser and OS, and its keys clash with screen reader and browser commands.

Phones and tablets with keyboards

Keyboard users are not only on desktop. iPads with keyboard cases, Android tablets and phones with Bluetooth keyboards, and Chromebooks in tablet mode all send real key events. On Android with a keyboard, Tab and the arrow keys move focus through web pages much as on desktop.

Safari is the odd one. On macOS, Tab by default moves only between text fields and pop-up menus, skipping links and most buttons. Users change that with "Press Tab to highlight each item on a webpage" in Safari's Advanced settings, and Option+Tab reaches every item either way.

Safari on an iPad with a hardware keyboard has similar Tab behaviour, and Full Keyboard Access in the iPhone and iPad Accessibility settings gives system-wide keyboard control. So when you test in Safari, switch these settings on, or a perfectly good page will seem broken.

Also make sure every control works with keys even on a layout you think of as "mobile". A hamburger menu that opens only on touchstart is unreachable from an iPad keyboard.

Check your understanding · Exercise 8

Your app adds e to archive the selected email, active anywhere on the page. Which changes make it meet WCAG 2.1.4? Select all that apply.

In real products and in the interview

How well-known products do it

  • GitHub puts a skip link first on every page, builds arrow-key focus zones into its Primer components, and lets users turn off its character key shortcuts in accessibility settings.
  • Gmail keeps its single-key shortcuts (j, k, e, r) behind a setting that is off by default, and lists them all in a ? dialog.
  • Google Docs and Sheets behave like applications: the sheet moves between cells with the arrow keys, and Alt+/ opens a search box for every menu command, so users need not walk the menus at all.
  • Design systems such as Adobe's React Spectrum (built on React Aria), Radix UI, and GitHub's Primer (its useFocusZone hook) ship roving focus and typeahead as shared primitives, so product teams do not each reinvent them.
  • VS Code documents a full keyboard model, including the Tab focus mode toggle, and treats a trapped focus as a bug.

A checklist for any component you design

QuestionGood answer
How many tab stops does it add?One per control, or one for the whole composite widget
Which keys move inside it?The APG pattern's keys: arrows by orientation, Home, End, typeahead where it fits
Where does focus go when it opens, closes, or an item is removed?To a named, sensible place; never to body
Can you always see focus?A :focus-visible ring with 3 to 1 contrast, never under a sticky header
Can you always leave?Tab, Shift+Tab, or Escape; anything else is documented
Any single-key shortcuts?Off switch, remap, or only active on focus

Using it in a system design interview

Keyboard navigation comes up in almost every component design question: an autocomplete, a dropdown menu, a data table, a date picker, an image carousel, a modal, a rich text editor. It belongs in the non-functional requirements at the start ("fully keyboard operable, WCAG 2.2 AA") and in the interface section, where you describe each component's keyboard model next to its props.

  • Junior: everything reachable with Tab and usable with Enter and Space; native elements first; a visible focus ring; no positive tabindex.
  • Mid-level: the hallway and rooms model; roving tabindex versus aria-activedescendant and why a combobox uses the second; Escape to close; focus return on close; skip links.
  • Senior: shared primitives in the design system (a useRovingFocus hook, a focus scope, a shortcut registry with an off switch); WCAG 2.1.4 and 2.4.11; screen reader browse versus focus mode; RTL and virtualized lists; keyboard tests in CI with Playwright.

What to draw: for a component question, a small table of keys and what each does, like the one in the mental model chapter. For an app question, a wireframe with the tab stops numbered, showing the skip link first and each composite widget as one stop. Both take a minute and show the interviewer you design for keyboards on purpose, not as a fix at the end.

Check your understanding · Exercise 9

You are designing a date picker in an interview. Which keyboard points are worth stating? Select all that apply.

Summary

  • Tab moves between widgets; arrow keys move inside a composite widget, which is a single tab stop. Count tab stops and presses to judge a design.
  • The Tab sequence follows the DOM. Use tabindex="0" to add a stop, -1 for script-only focus, and never a positive value. Fix order in the DOM, not with CSS or tabindex.
  • Focus must always be visible (WCAG 2.4.7) and never fully hidden by sticky headers or banners (2.4.11). Style :focus-visible with a strong outline and use scroll-padding.
  • Skip links, first in the DOM and visible on focus, let keyboard users jump past repeated headers; landmarks do the same for screen reader users.
  • Use roving tabindex for toolbars, tabs, menus, trees, and grids; use aria-activedescendant when focus must stay in a text input or the list is virtualized.
  • Keep the key logic a pure function and test it in Node; only preventDefault() keys you handle, and let modified keys and Tab through.
  • Focus must always be able to leave a component. Modal dialogs are the legitimate exception because Escape and Close always work.
  • Single-key shortcuts need an off switch, a remap, or focus scoping (2.1.4); test with external keyboards on tablets and with Safari's Tab setting on.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

How would you make a custom toolbar keyboard accessible?

Question 2

Roving tabindex or aria-activedescendant: how do you choose?

Question 3

Why is positive tabindex considered harmful?

Question 4

A modal traps focus. Isn't that a keyboard trap?

Question 5

Our app wants Gmail-style single-key shortcuts. What do you watch out for?

Practice it

These design problems lean on keyboard navigation.

References

Read next