A form that works perfectly with a mouse can be completely unusable from a keyboard: the tab order zig-zags because the DOM does not match the layout, a radio group forces the user to Tab through every option, a custom combobox swallows the arrow keys, and a modal lets Tab escape to the page behind it. Keyboard operability is not an add-on β WCAG 2.1.1 makes it a baseline, and for screen-reader users the keyboard is the interface.
This page specifies the keyboard model for the controls forms are actually built from: native inputs, grouped radio/checkbox sets, custom comboboxes and date pickers, and the modals and wizards that wrap them. It sits under the accessibility and error UX area and pairs with focus management after validation, which handles where focus goes when a submit fails.
Problem Statement
Keyboard navigation breaks along four seams, and each has a distinct fix:
- Tab order drift. Natural tab order is DOM order. When the visual layout is reordered with CSS (flex
order, grid placement, absolute positioning) but the DOM is not, Tab jumps around the screen unpredictably. Positivetabindexvalues are the usual βfixβ that makes it worse. - Group verbosity. A native
<input type="radio">group is already a single tab stop with arrow-key navigation. Custom-built groups β segmented buttons, tag pickers, toolbars β usually forget this and force one Tab stop per item. - Missing widget models. Custom comboboxes, listboxes, and date pickers have a specified keyboard contract in the WAI-ARIA Authoring Practices. Skipping it leaves arrow keys,
Home/End, and type-ahead dead. - Focus escaping containers. Modals and wizard steps must trap Tab so it cycles within the layer; otherwise focus falls behind the overlay and the user is lost.
The controllers below address the two hardest seams β roving tabindex for groups and the combobox model β with production TypeScript, and the surrounding sections cover tab order, Enter/Escape semantics, and focus trapping.
Focus Order and Roving Tabindex
The roving tabindex rule is exact: at any moment one element in the group carries tabindex="0" and is the groupβs single tab stop; every other element carries tabindex="-1". Tab enters the group onto the active element and leaves on the next Tab. Arrow keys move the tabindex="0" designation β and the focus β from element to element, wrapping at the ends.
| Key | Context | Behaviour |
|---|---|---|
Tab |
anywhere | move to the next tab stop (the group counts as one) |
Shift+Tab |
anywhere | move to the previous tab stop |
ArrowRight / ArrowDown |
inside group | activate the next item; wrap to first at the end |
ArrowLeft / ArrowUp |
inside group | activate the previous item; wrap to last at the start |
Home / End |
inside group | activate the first / last item |
Enter |
text field | submit the form (native) |
Escape |
open popup / edit | dismiss the nearest transient layer, no submit |
Core Implementation
The RovingTabindexController turns any container of like controls into a single tab stop with arrow-key movement. It stores per-element bookkeeping in a WeakMap so removed nodes are collected without a manual cleanup pass.
export interface RovingOptions {
/** Orientation decides which arrow keys move the active element. */
orientation?: "horizontal" | "vertical" | "both";
/** Wrap from last to first (and back) at the ends of the group. */
wrap?: boolean;
}
interface ItemState {
index: number;
}
export class RovingTabindexController {
private readonly container: HTMLElement;
private readonly opts: Required<RovingOptions>;
// Keyed by the DOM element so per-item state is collected automatically when
// a re-render removes the node. A plain Map would pin every detached element
// the controller has ever seen, leaking memory across list re-renders.
private readonly itemState = new WeakMap<HTMLElement, ItemState>();
private items: HTMLElement[] = [];
private activeIndex = 0;
constructor(container: HTMLElement, options: RovingOptions = {}) {
this.container = container;
this.opts = { orientation: "both", wrap: true, ...options };
this.refresh();
this.container.addEventListener("keydown", this.onKeydown);
// Focusing any item promotes it to the single tab stop, so a mouse click
// and keyboard navigation stay in sync.
this.container.addEventListener("focusin", this.onFocusin);
}
/** Re-read the item set after the group's contents change. */
refresh(): void {
this.items = Array.from(
this.container.querySelectorAll<HTMLElement>('[role="radio"], [role="option"], [data-roving-item]')
);
this.items.forEach((el, index) => {
this.itemState.set(el, { index });
// Exactly one item is reachable by Tab; the rest are focusable only
// programmatically via the arrow-key handler below.
el.tabIndex = index === this.activeIndex ? 0 : -1;
});
}
destroy(): void {
this.container.removeEventListener("keydown", this.onKeydown);
this.container.removeEventListener("focusin", this.onFocusin);
// No need to clear itemState: the WeakMap releases entries as soon as the
// elements themselves become unreachable.
}
private onFocusin = (e: FocusEvent): void => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
const state = this.itemState.get(target);
if (state) this.setActive(state.index, { moveFocus: false });
};
private onKeydown = (e: KeyboardEvent): void => {
const { orientation, wrap } = this.opts;
const forward =
(orientation !== "vertical" && e.key === "ArrowRight") ||
(orientation !== "horizontal" && e.key === "ArrowDown");
const backward =
(orientation !== "vertical" && e.key === "ArrowLeft") ||
(orientation !== "horizontal" && e.key === "ArrowUp");
let next = this.activeIndex;
if (forward) next = this.activeIndex + 1;
else if (backward) next = this.activeIndex - 1;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = this.items.length - 1;
else return; // let every other key (Tab, Enter, Escape, type-ahead) pass
e.preventDefault(); // stop the arrow key from scrolling the page
const last = this.items.length - 1;
if (next < 0) next = wrap ? last : 0;
if (next > last) next = wrap ? 0 : last;
this.setActive(next, { moveFocus: true });
};
private setActive(index: number, opts: { moveFocus: boolean }): void {
const prev = this.items[this.activeIndex];
const nextEl = this.items[index];
if (prev) prev.tabIndex = -1;
if (nextEl) {
nextEl.tabIndex = 0; // the new single tab stop
if (opts.moveFocus) nextEl.focus();
}
this.activeIndex = index;
}
}
Key design decisions:
WeakMapfor per-item state. Grouped controls are frequently re-rendered β tags added, options filtered. Keying state by the element node means a removed itemβs entry is reclaimed by GC automatically; aMapor object keyed by id would retain detached nodes indefinitely.- One
tabindex="0"invariant.setActive()always demotes the previous item to-1before promoting the next, so the group can never present two tab stops or zero. focusinkeeps mouse and keyboard in sync. Clicking an item promotes it to the active tab stop, so a subsequent Tab leaves from where the user actually is.- Unhandled keys pass through. The handler
returns for Tab, Enter, Escape, and printable characters, so native submit and type-ahead still work.
Enter, Escape, and Focus Trapping
Native semantics are the contract; custom widgets must reproduce them. Enter in a single-line text input submits the form β do not preventDefault() it globally or you break the one keystroke most users rely on. Inside a <textarea> Enter inserts a newline. On a custom button or option it activates the control. Escape dismisses the nearest transient layer: it closes an open combobox listbox, cancels an inline edit, or closes a modal β and it must never submit or wipe the form.
Modal dialogs and wizard steps require focus trapping so Tab cycles within the layer instead of falling to the page behind:
export function trapFocus(container: HTMLElement): () => void {
const selector =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function onKeydown(e: KeyboardEvent): void {
if (e.key !== "Tab") return;
const focusable = Array.from(
container.querySelectorAll<HTMLElement>(selector)
).filter((el) => el.offsetParent !== null); // skip visually hidden nodes
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
// Wrap the two ends so focus stays inside the dialog.
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
container.addEventListener("keydown", onKeydown);
return () => container.removeEventListener("keydown", onKeydown);
}
Restore focus to the trigger element when the trap tears down, or the user is dropped at the top of the document. In multi-step forms the trap boundary and the focus-restoration target both move per step, which the focus management after validation page covers alongside wizard focus.
The Custom Combobox Keyboard Model
A custom combobox (an editable text input with an attached listbox popup) has a specified contract in the WAI-ARIA Authoring Practices Guide. Reproduce it exactly β screen-reader users have learned these keystrokes across every conforming widget:
ArrowDownβ open the listbox if closed; move to the next option if open.ArrowUpβ open at the last option, or move to the previous option.Enterβ commit the highlighted option and close the listbox.Escapeβ close the listbox without committing; a second Escape clears the input.Home/Endβ move to the first / last option.- Printable characters β type into the input and filter the options; use
aria-activedescendantto point at the highlighted option rather than moving DOM focus, so the input keeps the caret.
The listbox is a roving structure, but the combobox model uses aria-activedescendant (virtual focus) instead of physically moving focus(), because the text caret must stay in the input. The controller updates aria-activedescendant on the input to the id of the highlighted [role="option"], and toggles aria-selected on that option β DOM focus never leaves the text field. Date pickers follow the analogous grid model (arrow keys across days, PageUp/PageDown across months). A worked implementation lives in roving tabindex for option groups.
Integration Guidance
Keyboard operability underpins every other accessibility concern in the accessibility and error UX area: focus after validation only works if tab order is coherent, and live-region announcements only reach users who can navigate to the fields being announced. The roving-tabindex controller and focus-trap here are the primitives that focus management after validation builds its βfirst invalid fieldβ logic on top of β βfirstβ means first in tab order, which these patterns define.
Two integration rules keep the keyboard layer robust:
- DOM order is the source of truth. Whatever CSS does to the visual layout, order the DOM to match the reading order so Tab, focus-after-validation, and screen-reader linearisation all agree.
- Groups are one tab stop. Any set of like controls β radios, segmented buttons, a tag list, a toolbar β gets a roving controller, not one tab stop per item. This is what makes a forty-control form navigable in a handful of Tab presses.
Edge Cases and Failure Modes
Positive tabindex fragments the order
Someone adds tabindex="1" to βfixβ one fieldβs position. Now every positive-tabindex element forms its own sequence that runs before all the tabindex="0" elements, and the order silently drifts every time the layout changes.
Resolution: Never use a positive tabindex. Reorder the DOM to match the visual order; reserve 0 for βjoin the natural orderβ and -1 for βfocusable only programmatically.β
Arrow keys scroll the page instead of moving within the group
If the roving handler does not preventDefault() on the arrow keys it acts on, the browser both moves the active item and scrolls the viewport.
Resolution: Call e.preventDefault() only for the keys the controller actually handles (as above), and let every other key pass so native scrolling and type-ahead elsewhere keep working.
Focus trap with zero focusable children
An empty modal, or one whose only controls are disabled, has no focusable elements. A naive trap throws or leaves focus stranded behind the overlay.
Resolution: Guard for an empty focusable set (the implementation returns early) and give the dialog container itself tabindex="-1" so focus has somewhere to rest.
aria-activedescendant points at a removed option
When the user types and the option list re-renders, the id in aria-activedescendant may reference an option that no longer exists, so the screen reader announces nothing on the next arrow press.
Resolution: After every filter, re-validate aria-activedescendant against the current option ids; reset it to the first option (or clear it) when the previous target is gone.
Custom widget swallows Tab
A combobox or grid that calls preventDefault() on Tab to βkeep focus insideβ traps keyboard users, who can no longer leave the control.
Resolution: Never intercept Tab for movement within a widget β use arrow keys for internal movement and let Tab move to the next form control. Reserve Tab-trapping strictly for modal dialogs, where escaping is the bug.
Troubleshooting Reference
| Failure scenario | Diagnostic step | Recovery action |
|---|---|---|
| Tab jumps around the screen unpredictably | Compare DOM order to visual order; search for positive tabindex |
Reorder the DOM; remove all positive tabindex values |
| Every radio/option is a separate tab stop | Count how many items have tabindex="0" |
Apply a roving controller so exactly one item is tabindex="0" |
| Arrow keys scroll the page inside a group | Check for a missing preventDefault on handled keys |
Call e.preventDefault() for the keys the controller acts on |
| Focus escapes a modal to the page behind | Verify the trap wraps at both first and last focusable | Wrap Shift+Tab at the first element and Tab at the last |
| Screen reader silent when arrowing a combobox | Inspect aria-activedescendant against live option ids |
Re-point it after each filter; toggle aria-selected on the active option |
Testing and QA Hooks
Expose the active item and group role via data attributes so keyboard behaviour is assertable without pixel checks.
// Mirror the controller's active index onto the container for test selectors.
function syncRovingAttributes(container: HTMLElement, activeIndex: number): void {
container.dataset.rovingActive = String(activeIndex);
}
// Playwright: the group is a single tab stop and arrows move within it.
await page.locator('[name="firstName"]').focus();
await page.keyboard.press("Tab"); // enters the radiogroup once
await expect(page.locator('[role="radiogroup"]')).toHaveAttribute("data-roving-active", "0");
await page.keyboard.press("ArrowDown"); // move within the group, not to the next field
await expect(page.locator('[role="radiogroup"]')).toHaveAttribute("data-roving-active", "1");
await page.keyboard.press("Tab"); // leaves the group to the next control
await expect(page.locator(":focus")).toHaveAttribute("name", "email");
// Assert Escape closes a combobox popup without submitting the form.
await page.locator('[role="combobox"]').press("ArrowDown");
await expect(page.locator('[role="listbox"]')).toBeVisible();
await page.locator('[role="combobox"]').press("Escape");
await expect(page.locator('[role="listbox"]')).toBeHidden();
For ARIA regression coverage, run axe-core against the open combobox and modal states, not just the initial render, and assert that aria-activedescendant always resolves to an element that exists in the DOM.
Common Pitfalls
Using a positive tabindex to reorder focus. It creates a separate, fragile tab sequence that drifts out of sync with the layout on every change. Reorder the DOM instead and keep tabindex to 0 and -1.
Making every item in a group its own tab stop. A ten-option group becomes ten Tab presses. Apply roving tabindex so the group is one stop with arrow-key movement inside.
Trapping Tab inside a non-modal widget. Comboboxes and toolbars must let Tab leave; only true modal dialogs trap Tab. Intercepting Tab elsewhere strands keyboard users.
Swallowing Enter globally. A blanket keydown handler that preventDefault()s Enter breaks native single-line submit. Scope Enter handling to the control that needs custom behaviour.
Moving DOM focus in a combobox instead of using aria-activedescendant. Physically focusing an option pulls the caret out of the text input, so the user can no longer type to filter. Use virtual focus and keep DOM focus on the input.
Frequently Asked Questions
What is roving tabindex and when should I use it?
Roving tabindex makes a group of related controls β radio buttons, a toolbar, a listbox of options β a single tab stop. Exactly one element in the group has tabindex="0" and is reachable by Tab; every other element has tabindex="-1". Arrow keys move the tabindex="0" designation from element to element. Use it whenever Tab-ing through every item in a group would be tedious and the items form a single conceptual control, which is nearly every custom group you build.
Should tab order follow the DOM or can I use tabindex to reorder it?
Tab order should follow DOM order, and DOM order should match the visual reading order. Positive tabindex values (1 and above) override the natural order and create a separate, fragile tab sequence that almost always drifts out of sync with the layout as it changes. Reorder the DOM instead, and reserve tabindex="0" (join the natural order) and tabindex="-1" (focusable only programmatically) for their intended roles.
Why does my WeakMap of element handlers matter for keyboard controllers?
A WeakMap keyed by DOM element lets the controller associate per-element state β its index, its cleanup function β without preventing that element from being garbage collected when it is removed from the DOM. A plain Map or object would hold a strong reference to every element the controller has ever seen, leaking detached nodes across re-renders. The WeakMap entry disappears automatically once nothing else references the element, which is exactly what you want for a group whose items are added and removed dynamically.
How should Enter and Escape behave inside a form?
Enter in a single-line text field submits the form (native behaviour); inside a <textarea> it inserts a newline; on a custom button or option it activates that control. Escape should dismiss the nearest transient layer β close an open combobox listbox, cancel an inline edit, or close a modal β without submitting. Never let Escape wipe the whole form, and never swallow Enter with a global handler, or you break native single-line submit.