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 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.
| Key | What users expect | Where it applies |
|---|---|---|
Tab / Shift+Tab | Go to the next or previous tab stop | Whole page |
Enter | Follow a link, press a button, submit a form | Links, buttons, form fields |
Space | Press a button, toggle a checkbox, scroll the page when nothing is focused | Buttons, checkboxes, switches |
| Arrow keys | Move between items in a group; change a slider or radio value | Composite widgets, radios, sliders, selects |
Home / End | Go to the first or last item | Lists, toolbars, menus, grids, sliders |
Escape | Close what just opened and go back to where you were | Menus, dialogs, popovers, comboboxes |
| Printable characters | Typeahead: jump to the item starting with that letter | Listboxes, 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(anawithouthrefis not focusable). button,input(excepttype="hidden"),select,textarea, unlessdisabled.summaryinsidedetails,iframe, and media elements withcontrols.- 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
| Value | Focusable by script | In the Tab sequence | Use it for |
|---|---|---|---|
tabindex="0" | Yes | Yes, at its place in the DOM | The focusable part of a custom widget; the active item of a roving group |
tabindex="-1" | Yes, with element.focus() | No | Headings and containers you move focus to; the inactive items of a roving group |
tabindex="1" or more | Yes | Yes, before every 0 element, in number order | Nothing. Avoid it. |
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.
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.
<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>
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 withelement.focus({ focusVisible: true }), which works in Firefox 104, Safari 18.4, and Chrome 145 and later.
/* 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; }
}
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, notbox-shadoworborder. Outlines do not change layout and survive forced colors mode. - Use
outline-offsetso 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.
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.
Skip links: jumping past repeated content
Most pages start with the same block every time: a logo, a search box, a main menu, maybe an account menu and a promo bar. A mouse user's eyes skip it in a moment. A keyboard user has to press Tab through every link in it, on every page they open.
A skip link is the first focusable element on the page, a link that jumps straight to the main content. It is usually hidden until it gets focus, then appears at the top-left corner. WCAG 2.4.1 Bypass Blocks (level A) requires some way to skip repeated blocks; a skip link is the simplest one, and the one that helps sighted keyboard users, who do not have a screen reader's landmark keys.
A skip link saves 380 key presses over 10 pages.
Building one that works
<body>
<a class="skip-link" href="#main">Skip to main content</a>
<header>... 40 links ...</header>
<main id="main" tabindex="-1">
<h1>Inbox</h1>
</main>
</body>
<style>
.skip-link { position: absolute; left: 8px; top: -100px; }
.skip-link:focus { top: 8px; }
</style>
- First in the DOM. It must be the first tab stop, before the logo, or users will not find it.
- Visible on focus. Move it on-screen when focused. Hiding it with
display: nonetakes it out of the Tab sequence completely. - A real target.
href="#main"scrolls to the element with that id and moves the start point of the Tab sequence there. Addingtabindex="-1"to the target makes focus land on it in every browser, so a screen reader announces it and the next Tab goes to the first link inside main. - Clear text. "Skip to main content" is the convention. Large apps add a few more, like "Skip to search" or "Skip to results".
Skip links in single page apps
Client-side routers often intercept every link click, and a hash link like #main can be swallowed or turned into a route change. Test it. If your router breaks it, handle the click yourself: call preventDefault(), then focus() on the main element. Some routers also use the URL hash for state, which makes skip links clash with routing; a button that moves focus works too, as long as it is first and visible on focus.
Landmarks are the other half of bypassing blocks. A page with header, nav, main, and footer elements lets screen reader users jump between regions with one key (D in NVDA, R in JAWS, the rotor in VoiceOver). Use both: landmarks for screen reader users, a skip link for everyone else.
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.
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.
aria-activedescendant instead of roving tabindex.Decision How should a composite widget track its active item?
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-orientationwhen 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
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;
}
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
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);
});
Step 3: the hook
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 };
}
onFocussyncs 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
activeis now past the end, clamp it, or no item hastabindex="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.
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
blurhandler 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.
The legitimate trap: modal dialogs
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.
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;
});
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.
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:
- Turn off: the user can switch them off.
- Remap: the user can change them to include a modifier key like Ctrl or Alt.
- 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.
| Shortcut | Clash risk | Rule |
|---|---|---|
j, k, c everywhere | Speech input, accidental presses, screen reader browse mode | Must be switchable off or remappable (2.1.4) |
/ to focus search | Same as above; also typed in any text field | Ignore when focus is in an input; allow turning off |
| Ctrl+K command palette | Low; Chrome and Firefox on Windows and Linux use Ctrl+K to focus the search or address bar, but a page may take it | Fine; document it |
| Ctrl+D, Ctrl+T, Ctrl+W | High: bookmark, new tab, close tab | Do not override |
| Insert+key, Caps Lock+key | Reserved by NVDA, JAWS, and Narrator | Never use |
Shortcut hygiene
- Ignore shortcuts while focus is in
input,textarea,select, orcontenteditable, and whileevent.isComposingis true (users typing with an IME, for Chinese or Japanese, are mid-word). - Match on
event.keyfor letter shortcuts so they follow the user's layout (a French AZERTY keyboard has A where QWERTY has Q). Useevent.codeonly 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-keyshortcutson the control so screen readers can announce them. - Avoid the old
accesskeyattribute. 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
useFocusZonehook) 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
| Question | Good 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-activedescendantand why a combobox uses the second; Escape to close; focus return on close; skip links. - Senior: shared primitives in the design system (a
useRovingFocushook, 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,-1for 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-visiblewith a strong outline and usescroll-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-activedescendantwhen 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?
I would give it role="toolbar" and an accessible name, and make the whole toolbar one tab stop with roving tabindex: the active button has tabindex="0", the rest -1.
Left and Right move focus between buttons and wrap at the ends, Home and End jump to the first and last, and Tab leaves the toolbar. Tabbing back in returns to the last active button, because the 0 stays where the user left it.
I would only call preventDefault() on the keys I handle, ignore modified keys, flip Left and Right in right-to-left layouts, and keep a :focus-visible ring on the active button. The key logic is a pure function, so I would unit-test it in Node and add one Playwright test that tabs in, arrows, and tabs out.
Question 2
Roving tabindex or aria-activedescendant: how do you choose?
Roving tabindex moves real DOM focus between items. It is the default for toolbars, tab lists, menus, trees, and grids: the browser scrolls the focused item into view, :focus-visible works, and screen reader support is strong.
aria-activedescendant keeps focus on one element and points at the active item by id. I pick it when focus must stay in a text field, as in a combobox or command palette, or when the list is virtualized and items come and go from the DOM.
The cost of aria-activedescendant is that I must draw the highlight and scroll the item into view myself, and the referenced id must be in the same document or shadow root.
Question 3
Why is positive tabindex considered harmful?
Any positive value moves an element in front of every tabindex="0" and native control on the page, sorted by number. So the Tab sequence no longer matches the reading order: focus jumps to that element first, then back to the top.
It also does not compose across components. Two teams that each use tabindex="1" collide, and the order changes when a third is added. The fix is to put elements in the right DOM order and use 0 or -1 only.
Question 4
A modal traps focus. Isn't that a keyboard trap?
No. WCAG 2.1.2 forbids focus that cannot leave a component with the keyboard. A modal keeps Tab inside it on purpose because the rest of the page is not usable while it is open, but the user can always leave with Escape or the Close button, and focus then returns to the trigger.
With dialog.showModal() the browser makes the page behind inert and handles Escape for me. For custom overlays I would set inert on the rest of the page rather than hand-write a Tab loop.
Question 5
Our app wants Gmail-style single-key shortcuts. What do you watch out for?
WCAG 2.1.4 requires that letter-only shortcuts can be turned off, remapped to include a modifier, or are active only when a specific component has focus. Speech input users would otherwise trigger them by dictating, and screen reader browse mode already uses single letters.
I would build a shortcut registry with a user setting to turn them off, ignore keys while focus is in text fields or during IME composition, match on event.key so layouts work, avoid browser and screen reader combinations, and list everything in a ? dialog with aria-keyshortcuts on the controls.
Practice it
These design problems lean on keyboard navigation.
- Design an accordionEasy
- Design a tabs componentEasy
- Design a tooltipEasy
- Design a star rating widgetEasy
- Design a modal dialogEasy
- Design breadcrumbsEasy
- Design an OTP inputEasy
- Design an autocompleteMedium
- Design a custom select dropdownMedium
- Design an image carouselMedium
- Design a date pickerMedium
- Design a tag (multi-select) inputMedium
- Design a file-explorer tree viewMedium
- Design a command paletteMedium
- Design a drag-and-drop reorderable listMedium
- Design an @-mention inputMedium
- Design a markdown editor with live previewMedium
- Design a color pickerMedium
- Design an avatar image cropperMedium
- Design an emoji and GIF pickerMedium
- Design a podcast audio playerMedium
- Design an image lightbox with zoomMedium
- Design a podcast player with a synced transcriptMedium
- Design a nested comments threadMedium
- Design a grocery delivery slot pickerMedium
- Design a dropdown menuMedium
- Design an online chess gameMedium
- Design a gamified learning app like DuolingoMedium
- Design an e-signature workflow like DocuSignMedium
- Design an AI image generation studioMedium
- Design a data tableHard
- Design a calendar week viewHard
- Design a Kanban boardHard
- Design a combobox for 100,000 optionsHard
- Design a rich text editorHard
- Design an embeddable code editorHard
- Design a drag-and-drop form builderHard
- Design a video playerHard
- Design a PDF viewerHard
- Design undo and redoHard
- Design DiscordHard
- Design NetflixHard
- Design a seat selection map for eventsHard
- Design Google SheetsHard
- Design GmailHard
- Design Google CalendarHard
- Design Trello or JiraHard
- Design Google DriveHard
- Design an online code playground like CodePenHard
- Design an online judge like LeetCodeHard
- Design a code review diff viewerHard
- Design a web-based terminalHard
- Design a log viewer for millions of linesHard
- Design a customer support ticket dashboardHard
- Design Google MapsHard
- Design a collaborative pixel canvas like r/placeHard
- Design a web photo editorHard
- Design a browser-based music studioHard
- Design a no-code website builderHard
- Design a video call web app like Google MeetHard
- Design a Twitch-style live stream pageHard
- Design Google PhotosHard
- Design a browser video editorHard
- Design a stock trading appHard
References
- WAI-ARIA Authoring Practices Guide: Developing a Keyboard Interface Roving tabindex, aria-activedescendant, and key conventions for every pattern.
- WCAG 2.2: Understanding Success Criterion 2.1.2 No Keyboard Trap
- WCAG 2.2: Understanding Success Criterion 2.4.11 Focus Not Obscured (Minimum)
- WCAG 2.2: Understanding Success Criterion 2.1.4 Character Key Shortcuts
- MDN: tabindex
- MDN: :focus-visible
- web.dev: Keyboard access fundamentals Tab order, focus, and skip links with examples.
Read next
- Semantic HTML firstUsing the HTML element that already means what you need, like
button,a,nav, andh2, so keyboards, screen readers, and voice control understand the page with no extra code. - Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.