A custom radiogroup or checkbox group built from <div>s must behave exactly like the native control it replaces β one Tab stop for the whole group, arrow keys to move between options β and the mechanism that delivers this is roving tabindex: precisely one option carries tabindex="0" at any moment while every other option carries tabindex="-1".
The bug this page fixes: a custom option group where every option is a tab stop, so a keyboard user must press Tab five times to pass a five-option group, and a screen reader announces each as a separate control instead of β1 of 5β. The fix is not more ARIA β it is moving the single tabindex="0" as focus roves, and wiring arrow keys to drive both focus and (for radios) selection.
This is the canonical keyboard navigation pattern for composite widgets, and it is what lets focus-first-invalid land on the correct member of a group rather than a dead container.
What roving tabindex actually is
Native radio groups have two properties a naive <div role="radio"> reimplementation loses: a single tab stop, and arrow-key traversal. Roving tabindex restores both with one invariant:
- Exactly one option is tabbable. At all times one option has
tabindex="0"(the groupβs tab stop) and every other option hastabindex="-1"(focusable by script, skipped by Tab). - Arrow keys move the
0. Pressing Down/Right sets the current option to-1, sets the next option to0, and focuses it. The0βrovesβ to wherever focus is. - Selection semantics diverge by role. In a
radiogroup, moving focus also moves selection (only one can be checked). In a checkbox group, arrows move focus only andSpacetoggles the focused option (many can be checked).
Core implementation
The controller manages one group. Option state lives in a WeakMap keyed by the element, and the roving tabindex is a single source of truth derived from the active index.
interface OptionState {
index: number; // position within the group
checked: boolean; // selection state
disabled: boolean;
}
type GroupKind = "radio" | "checkbox";
class RovingGroup {
private options: HTMLElement[];
/**
* WeakMap keyed by the option element. Chosen over a Map or a data-*
* attribute because when an option node is removed from the DOM and
* dropped elsewhere, its entry becomes unreachable and is garbage-
* collected automatically β no manual delete(), no leak if the group
* re-renders. State also stays off the DOM, so mutating `checked` does
* not fire attribute observers or reflow on every arrow keypress.
*/
private state = new WeakMap<HTMLElement, OptionState>();
private activeIndex = 0;
constructor(
private group: HTMLElement,
private kind: GroupKind,
private onChange: (checked: HTMLElement[]) => void
) {
this.options = Array.from(
group.querySelectorAll<HTMLElement>('[role="radio"], [role="checkbox"]')
);
this.options.forEach((el, index) => {
this.state.set(el, {
index,
checked: el.getAttribute("aria-checked") === "true",
disabled: el.getAttribute("aria-disabled") === "true",
});
});
// Initial roving position: the checked option, or the first enabled one.
this.activeIndex = Math.max(0, this.options.findIndex((el) => this.state.get(el)?.checked));
this.syncTabindex();
group.addEventListener("keydown", this.onKeydown);
group.addEventListener("click", this.onClick);
}
/** Exactly one option gets tabindex 0; all others get -1. */
private syncTabindex(): void {
this.options.forEach((el, i) => {
el.tabIndex = i === this.activeIndex ? 0 : -1;
});
}
private moveTo(index: number, opts: { select: boolean }): void {
const target = this.options[index];
if (!target || this.state.get(target)?.disabled) return;
this.activeIndex = index;
this.syncTabindex();
target.focus(); // the newly-tabbable option receives focus
if (opts.select) this.select(target, true);
}
/** Next/previous enabled option, wrapping around the ends. */
private step(delta: 1 | -1): number {
const n = this.options.length;
let i = this.activeIndex;
for (let c = 0; c < n; c++) {
i = (i + delta + n) % n;
if (!this.state.get(this.options[i])?.disabled) return i;
}
return this.activeIndex;
}
private onKeydown = (e: KeyboardEvent): void => {
switch (e.key) {
case "ArrowDown":
case "ArrowRight":
e.preventDefault();
// Radios select on move; checkboxes only move focus.
this.moveTo(this.step(1), { select: this.kind === "radio" });
break;
case "ArrowUp":
case "ArrowLeft":
e.preventDefault();
this.moveTo(this.step(-1), { select: this.kind === "radio" });
break;
case "Home":
e.preventDefault();
this.moveTo(this.firstEnabled(), { select: this.kind === "radio" });
break;
case "End":
e.preventDefault();
this.moveTo(this.lastEnabled(), { select: this.kind === "radio" });
break;
case " ": // Space toggles the focused checkbox; confirms a radio.
e.preventDefault();
this.select(this.options[this.activeIndex], this.kind === "checkbox" ? "toggle" : true);
break;
}
};
private onClick = (e: MouseEvent): void => {
const option = (e.target as HTMLElement).closest<HTMLElement>('[role="radio"], [role="checkbox"]');
if (!option || this.state.get(option)?.disabled) return;
this.moveTo(this.state.get(option)!.index, { select: false });
this.select(option, this.kind === "checkbox" ? "toggle" : true);
};
private select(el: HTMLElement, mode: boolean | "toggle"): void {
const st = this.state.get(el);
if (!st || st.disabled) return;
if (this.kind === "radio") {
// Single selection: clear the rest, set this one.
this.options.forEach((o) => {
const s = this.state.get(o)!;
s.checked = o === el;
o.setAttribute("aria-checked", String(s.checked));
});
} else {
st.checked = mode === "toggle" ? !st.checked : Boolean(mode);
el.setAttribute("aria-checked", String(st.checked));
}
this.onChange(this.options.filter((o) => this.state.get(o)!.checked));
}
private firstEnabled(): number {
return this.options.findIndex((el) => !this.state.get(el)?.disabled);
}
private lastEnabled(): number {
for (let i = this.options.length - 1; i >= 0; i--) {
if (!this.state.get(this.options[i])?.disabled) return i;
}
return 0;
}
destroy(): void {
this.group.removeEventListener("keydown", this.onKeydown);
this.group.removeEventListener("click", this.onClick);
// No need to clear the WeakMap: dropping the RovingGroup reference makes
// every entry unreachable, and the GC reclaims them with the elements.
}
}
Step-by-step walkthrough
- Roles and initial tabindex. The group is
role="radiogroup"(or agroupwithrole="checkbox"children).syncTabindexsetstabindex="0"on the active option and-1on the rest, producing the single tab stop. - State goes in the WeakMap. Each optionβs index, checked, and disabled flags are stored in
state, keyed by the element, so no per-option lookup touches the DOM during navigation. - Arrow keys rove.
onKeydownmaps Down/Right tostep(1)and Up/Left tostep(-1),moveToshifts thetabindex="0", focuses the target, and β for radios β selects it. - Selection semantics split.
moveToβsselectflag is true for radios (focus moves selection) and false for checkboxes (focus only);Spacetoggles a checkbox but merely confirms a radio. - Home/End and wrapping.
HomeandEndjump to the first/last enabled option;stepwraps around the ends and skips disabled options. - Teardown is trivial.
destroyremoves the two listeners; theWeakMapneeds no explicit clearing because dropping the controller makes its entries collectible.
Failure modes and edge cases
Every option is a tab stop
Leaving the default tabindex (or setting all options to 0) makes each option a separate tab stop β the exact bug this pattern exists to fix.
// WRONG β all options tabbable, Tab stops on every one
options.forEach((el) => (el.tabIndex = 0));
// RIGHT β exactly one tab stop that roves with focus
options.forEach((el, i) => (el.tabIndex = i === activeIndex ? 0 : -1));
Focus lost after the active option is removed
If the option holding tabindex="0" is removed from the DOM, the group loses its tab stop entirely and Tab skips it. On any dynamic add/remove, recompute activeIndex (clamp into range) and call syncTabindex so some enabled option is always tabbable.
Using a Map instead of a WeakMap leaks
A plain Map<HTMLElement, OptionState> keeps every option element alive as long as the map exists, so a frequently re-rendering group leaks detached nodes. The WeakMap holds its keys weakly β removed options are reclaimed automatically. This is precisely why the state store is a WeakMap and not a Map.
Selecting radios on arrow conflicts with disabled options
Moving selection onto a disabled option is invalid, yet arrow traversal must skip it. step loops until it finds an enabled option, and moveTo bails if the target is disabled, so selection never lands on a disabled radio.
Space scrolls the page
Space on a focused <div> scrolls the viewport unless prevented. The e.preventDefault() in the " " case stops the scroll so Space only toggles or confirms the option.
Verification checklist
Frequently Asked Questions
Why should a radio group have only one tab stop?
A native radio group is a single tab stop: Tab enters the group and arrow keys move between options. A custom group must reproduce that with roving tabindex β exactly one option has tabindex="0" and the rest have tabindex="-1" β so Tab does not stop on every option and keyboard users move through the group the way they expect. Making every option tabbable is the most common regression in hand-built groups.
What is the difference between roving tabindex for radios versus checkboxes?
For a radiogroup, arrow keys move focus and selection together, since only one option can be selected. For a checkbox group, arrow keys move focus only, and Space toggles the focused option independently, because multiple options can be checked. Both share the single-tab-stop roving mechanism; only the selection semantics differ β the implementation captures this with a select flag that is true for radios and false for checkboxes on arrow movement.
Why use a WeakMap to store option state instead of a data attribute?
A WeakMap keyed by the element lets option state be garbage-collected automatically when the option is removed from the DOM, with no manual cleanup and no risk of a leak. It also keeps rich state (index, checked, disabled) off the DOM where it cannot be tampered with or trigger attribute-mutation observers on every keystroke. A plain Map would pin every element in memory for the mapβs lifetime.