Keyboard Operability
Every interaction must be reachable, understandable and completable with a keyboard alone — because the keyboard is also the switch device, the voice command, the braille display and the screen reader.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
Can a person reach, understand and complete every flow in this interface without a pointer?
Someone is filling in an order form with one hand on the keyboard, or driving the page with a switch, or using speech, or simply has no mouse plugged in. They want to get to the end of the task without a dead end.
Everything is clickable, and the important buttons are real buttons, so the keyboard works. Anything else is an edge case that affects almost nobody.
The keyboard is not a minority path. It is the input model underneath switch access, sip-and-puff devices, voice control, braille displays and every screen reader — plus everyone with a temporary injury, everyone on a laptop with a broken trackpad, and every power user in a data-entry flow.
- The keyboard is not a minority path. It is the input model underneath switch access, sip-and-puff devices, voice control, braille displays and every screen reader — plus everyone with a temporary injury, everyone on a laptop with a broken trackpad, and every power user in a data-entry flow.
- A
divwith a click handler is invisible to Tab. The flow simply ends there, and there is no error, no warning and no visual clue that anything is missing (Semantics Before ARIA). - Hover-only disclosure — a menu that opens on
mouseenter, a tooltip that appears on hover — has no keyboard equivalent unless you add one, and no touch equivalent either. - Custom key handling swallows platform keys: a
keydownhandler that callspreventDefault()unconditionally kills Tab, kills browser find, kills the screen reader's own shortcuts, and kills text selection. - Visual order and DOM order drift apart. Flexbox
order,grid-areaplacement and absolute positioning reposition boxes without touching the DOM, so Tab jumps around the screen in a sequence that looks random (Positioning and Stacking Contexts). - A scrollable region built from a
divwithoverflow: autoand no focusable content cannot be scrolled by keyboard in several engines, so the content inside it is unreachable.
What is actually happening
In the browser, not in the framework.
- The browser maintains a sequential focus navigation order, derived from DOM order and modified by
tabindex. Tab and Shift+Tab move through it. Focusable-by-default is a short list: links withhref, form controls,button,summary,iframe, elements withcontenteditable, and anything with atabindex. - Tab moves between tab stops, not between all focusable elements. A composite widget — toolbar, tab list, grid, menu, radio group — is one tab stop, and the arrow keys move within it. That pattern is called roving tabindex, and it is why a 40-cell toolbar is one Tab press, not forty.
- Activation differs by role, and users rely on the difference. A
buttonactivates on Enter and Space; a link activates on Enter only; a checkbox toggles on Space only; aselectopens with arrows or Alt+Down depending on platform. Copying "Enter and Space activate everything" into a custom control is itself a defect. - Key events fire on the focused element and bubble (How an Event Is Dispatched). If nothing is focused, they land on
body, which is why a document-level shortcut works and an element handler on an unfocusable node never runs (Keyboard Events). - A screen reader sits between the keyboard and the page. In browse mode, NVDA and JAWS intercept most single keys for their own navigation and pass them to the page only in forms mode, which they enter automatically on native form controls and on some ARIA widget roles. VoiceOver instead reserves a modifier combination. Custom single-letter shortcuts collide with all of this.
What this makes the browser do
And which of it is avoidable.
- Focus movement forces a style recalculation on the elements entering and leaving focus, and the browser scrolls the focused element into view — a layout read plus a scroll, per Tab press (The Cost of a Change).
- A global
keydownlistener runs for every keystroke in the document, including every character typed into every input. Anything expensive in it is on the typing path (Interaction Responsiveness). - Avoidable: per-item key listeners on long lists. One listener on the container, dispatching on
event.target, is both cheaper and easier to keep consistent (Event Delegation). - Focus that lands inside a
content-visibility: autosubtree forces that subtree to render immediately, which is correct behaviour and can be a surprising layout cost (content-visibility).
Where keyboard support actually goes wrong
The defects are repetitive. Almost every keyboard failure in a real application is one of the rows below, and each has a specific cause and a specific fix — which is good news, because it makes keyboard review a checklist rather than an art.
The most severe of them is the trap, because it has no recovery. Everything else degrades the experience; a trap ends the session.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Tab reaches the end of the header and skips the main action | The primary button can never be activated without a mouse | A div or span with a click handler is not in the focus order | Use a button. If the design forbids it, the design is wrong before the markup is (Semantics Before ARIA). |
| Focus enters an embedded editor or a third-party widget | Tab cycles inside it forever; the only exit is closing the tab | The widget captures Tab and never releases it, usually to implement its own indentation | Provide an escape key that releases the trap, and test every embedded component for it. Never ship a component you have not Tab-exited yourself. |
| A dialog opens | Tab walks out of the dialog into the page behind it | No focus containment and no inert on the background | Use <dialog> with showModal(), or mark the background inert (Focus Management). |
| The user presses Space on a custom control | The page scrolls; the control does not activate | The default action of Space was not prevented on keydown | Call preventDefault() on keydown for the keys you handle, and activate on keyup to match native buttons. |
| A menu opens on hover | Keyboard and touch users cannot open it at all | Only mouseenter was wired up | Open on click or on Enter/Space against a real button with aria-expanded; hover becomes a convenience layered on top. |
Layout was reordered with flexbox order | Focus jumps from the top of the page to the bottom and back | Tab follows DOM order, not visual order | Reorder the DOM. CSS reordering is only safe where reading order genuinely does not matter. |
A single-letter shortcut is registered on document | Typing "n" in a comment field opens a new item dialog | The handler never checks whether the event target is a text entry | Scope shortcuts to a container, or bail out when the target is editable — and prefer modifier combinations. |
One tab stop, then arrows
Composite widgets are the part people get wrong even after they have learned to use button. The platform convention — established by native toolbars, menu bars, tab strips and radio groups — is that the whole widget is a single tab stop, and the arrow keys move within it. Users who navigate by keyboard depend on this: it is the difference between three Tab presses to cross a toolbar and thirty.
The implementation is called roving tabindex: exactly one item carries tabindex="0" at a time, every other item carries tabindex="-1", and the arrow-key handler moves both the tabindex and the actual focus. The alternative, aria-activedescendant, keeps DOM focus on the container and points at the active item by id; it suits listboxes and comboboxes where focus must stay in a text field.
The single hardest part is not the key handling. It is keeping the roving state correct across re-renders, item removal and asynchronous updates — when the item holding tabindex="0" is removed, something else has to take it, or the widget silently leaves the tab order.
1function Toolbar({ items }: { items: Item[] }) {2 const [active, setActive] = useState(0)3 const refs = useRef<(HTMLButtonElement | null)[]>([])4 5 // If the active item disappears, the widget must not fall out of the6 // tab order. Clamping on every change is cheaper than debugging it later.7 const index = Math.min(active, items.length - 1)8 9 function onKeyDown(e: React.KeyboardEvent) {10 const last = items.length - 111 let next = index12 if (e.key === 'ArrowRight') next = index === last ? 0 : index + 113 else if (e.key === 'ArrowLeft') next = index === 0 ? last : index - 114 else if (e.key === 'Home') next = 015 else if (e.key === 'End') next = last16 else return // every other key, including Tab, is left alone17 18 e.preventDefault() // only for the keys we actually handled19 setActive(next)20 refs.current[next]?.focus()21 }22 23 return (24 <div role="toolbar" aria-label="Text formatting" onKeyDown={onKeyDown}>25 {items.map((item, i) => (26 <button27 key={item.id}28 ref={(el) => { refs.current[i] = el }}29 type="button"30 // exactly one 0 in the whole widget31 tabIndex={i === index ? 0 : -1}32 aria-pressed={item.on}33 >34 {item.label}35 </button>36 ))}37 </div>38 )39}Three things carry the weight: return before preventDefault so Tab still escapes the widget, wrapping at both ends so the arrows never dead-end, and clamping the active index so a removed item cannot strand the toolbar outside the tab order.
The specification a composite widget owes
Write this down before writing the component, not after the accessibility review. The keys are not a matter of taste — they are conventions users have already learned from the platform, and inventing your own is a usability defect even when it is technically operable.
The specification below is for a toolbar, the simplest composite. Tabs, menus, listboxes, grids and tree views each have their own established key set, all documented in the ARIA Authoring Practices, and all worth copying rather than deriving (Accessible Component Patterns).
semantics role="toolbar" on the container with an aria-label, real button elements inside, exactly one of them carrying tabindex="0" and the rest tabindex="-1". Toggle buttons expose aria-pressed.
| Tab | Enters the toolbar at the item that currently holds tabindex="0" — which is the last item the user used, not always the first — and on the next press leaves the toolbar entirely. |
| Arrow Left / Right | Moves between items, wrapping at both ends. Vertical toolbars use Up/Down instead and set aria-orientation="vertical". |
| Home / End | Jumps to the first and last item. Cheap to implement, immediately noticed by anyone who uses it. |
| Enter / Space | Activates the focused button. Provided by the native button; nothing to implement. |
- — Focus stays inside the toolbar while arrowing; only Tab leaves it.
- — The remembered position is the toolbar's state, so returning with Tab lands where the user was, not at the start.
- — When the focused item is removed, focus and the roving
tabindexmove to a neighbour — never tobody. - — The focus indicator must be visible on every item, including toggled-on ones with a filled background (Contrast, Colour and Motion).
- — On entering: the toolbar's label, then the focused item's name, role and pressed state.
- — On arrowing: the newly focused item only. No live region — focus movement already announces.
- — On toggling: the new pressed state, from
aria-pressedchanging.
usually broken by Calling preventDefault() for every key in the handler. It reads as harmless tidiness and it removes Tab, so the toolbar becomes a keyboard trap — the most severe defect in this lesson, introduced by one line meant to stop the page scrolling.
How to build it
Most important first.
- Use elements that are focusable and activatable already. This removes the entire problem for the overwhelming majority of controls (What Native Elements Already Do).
- Keep DOM order equal to visual order. Reorder with the DOM, not with CSS, whenever the two would disagree; CSS reordering is fine only where the reading order genuinely does not matter.
- Give composite widgets one tab stop and arrow-key movement inside them. A user should never have to Tab through 30 toolbar buttons to reach the content after them.
- Support Escape everywhere something is dismissable: menus, popovers, dialogs, inline editors. It is the one key users try without being told.
- Handle
keydownfor movement keys and activation, honour key repeat, and callpreventDefault()only for the specific keys you handled — never for the whole event. - Provide a visible skip link as the first tab stop so keyboard users can bypass the navigation, and make sure it becomes visible when focused rather than staying clipped (Document Structure and Reading Order).
- Never build a hover-only affordance. Anything reachable by hover must be reachable by focus, and should stay open while focus is inside it.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Reachable, operable, and escapable — in that order. A control that cannot be reached is invisible; one that cannot be operated is a tease; one that cannot be escaped is a trap.
- Focus must always be visible. A keyboard user who cannot see where focus is has been given operability and denied orientation (Focus Management).
- Do not override keys the platform or the assistive technology owns: Tab, Shift+Tab, browser find, screen-reader modifier combinations, and the OS-level shortcuts.
- Announce the keys where they are not obvious. A widget with a non-obvious interaction model should say so — in a hint associated with
aria-describedby, not in a tooltip only a mouse can reach. - Every pointer gesture needs a keyboard path to the same outcome: long press, swipe, drag, pinch, right-click (Pointer Events).
What can go wrong
- Keyboard trap: focus enters a widget — an embedded editor, a third-party iframe, a custom date picker — and Tab cannot get it back out. This is the most severe keyboard defect there is, because the user's only escape is closing the tab.
- A modal that does not trap focus at all, so Tab walks out of the dialog and into the page behind it, where the user operates controls they cannot see (Focus Management).
- Single-letter shortcuts that fire while the user is typing in an input, because the handler is on
documentand never checks the event target. - Space scrolling the page when it was supposed to activate a custom control, because
preventDefault()was not called onkeydown. - The mitigation failing: a roving tabindex implementation that leaves
tabindex="-1"on every item after a re-render, so the widget disappears from the tab order entirely (Reconciliation and Keys). - Drag-and-drop with no keyboard equivalent. Reordering, resizing and canvas manipulation all need an alternative — usually arrow keys with a modifier, or an explicit "move up / move down" control.
- Key repeat outruns async work: holding an arrow key in a list that fetches per selection queues requests faster than they return, and responses arrive out of order unless each supersedes the last (Cancelling a Request Nobody Is Waiting For).
keydownandkeyupcan land on different elements if focus moves in between — activating onkeyupwithout checking that the same element received thekeydownfires actions the user never intended.- A control that becomes disabled asynchronously between
keydownand activation processes a key press against a control that no longer exists in the state the user saw.
- A global key handler sees every keystroke in the document, including passwords and payment fields. Anything that logs or forwards keys is a data-exfiltration path, and third-party scripts install exactly this kind of handler (Third-Party Scripts and the Supply Chain).
- Session replay and analytics tooling that records keystrokes will capture credentials unless fields are explicitly masked (Session Replay and the Privacy It Costs).
- Keyboard-only paths are still client paths: a keyboard shortcut that triggers a destructive action needs the same server-side authorisation and confirmation as the button (Authorization-Aware UI).
- "Keyboard support is for blind users." It is for switch devices, voice control, motor-impaired users, injured users, power users and anyone whose trackpad died — and a screen reader is one consumer of it, not the reason for it.
- "Adding
tabindexfixes it." It adds reachability, and reachability without activation and without a role is a control that focuses and does nothing. - "Everything should be in the tab order." The opposite: fewer, well-chosen tab stops with arrow-key movement inside composites is the pattern the platform uses and users expect.
- "It works with the keyboard, so it works with a screen reader." Browse mode changes which keys reach the page at all. The two tests are different tests (Accessibility Testing).
Measuring it, and what changes in the field
- Unplug the mouse and complete the flow. That is the test, it takes minutes, and it finds more real defects than any tool.
- Tab through the page watching only the focus ring: is it always visible, does it move in a sensible order, does it ever vanish, does it ever leave the viewport?
- DevTools: Chrome's Elements pane can force
:focusand:focus-visiblestates; the Accessibility pane shows whether the element is exposed as focusable. - Automated rules catch a narrow band — positive
tabindex, a click handler on a non-interactive element, a missing accessible name — and cannot detect a trap, a bad order or a dead end (Accessibility Testing). - End-to-end tests can drive the keyboard directly, which makes "Tab reaches the submit button" a regression test rather than a memory (End-to-End Testing).
- Under a screen reader, browse mode intercepts most keys, so a page that works with a bare keyboard can still be inoperable with NVDA or JAWS running. This is the single most common surprise in keyboard testing.
- On mobile with an external keyboard or with switch access, the same order applies, but the visible viewport is small and focus can move to something the user cannot see without scrolling.
- In a long list, arrow-key navigation with typeahead is the difference between usable and unusable — Tab through 500 rows is not a flow anyone completes (List Virtualization).
- Before hydration, keyboard handlers attached by JavaScript do not exist yet. Native elements work; custom ones look interactive and do nothing (Hydration).
- Roving tabindex is more code than putting every item in the tab order, and it has more ways to break under re-render. It is still correct, because the alternative moves the cost onto every keyboard user, on every visit.
- Rich keyboard shortcuts are a genuine productivity feature and a genuine collision risk with browser and assistive-technology bindings. Scope them to a container, avoid bare single letters outside a focused widget, and let the user see them.
- Keeping DOM order equal to visual order constrains layout, especially in responsive designs where the visual order changes by breakpoint. The constraint is real; the alternative is a tab order that is wrong at some viewport widths (Media Queries Beyond Width).
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALSequential focus navigation, the default focusable element set and activation-by-role are specified behaviour in every engine. Where engines differ is in edge cases, not in the model.
- PLATFORM-SPECIFICScreen readers change which keys reach the page: NVDA and JAWS on Windows intercept single keys in browse mode and pass them through in forms mode, VoiceOver on macOS reserves a Control+Option modifier and passes most keys through, and VoiceOver on iOS plus TalkBack on Android replace keys with gestures entirely. A widget that works with a bare keyboard can be inoperable under one of them.
- BROWSER-SPECIFICHistorically Safari on macOS excluded buttons and other controls from the Tab order unless "Press Tab to highlight each item" was enabled in system or browser settings — a difference that persisted for years and still shapes what users on older macOS versions experience. Keyboard-scrollability of an overflow container without focusable children also differs by engine.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — driving the keyboard in end-to-end tests: asserting the tab order of a flow, asserting that Escape closes what it should, and asserting that focus never reaches
bodymid-task.