Skip to content
UI Elements & ComponentsHeraclespattern

Roving Focus

A grouped control that takes one tab stop and moves internally by arrow key, with the active tabindex reported live.

Built here · no external address

Heracles: Foundational labour. Difficult, necessary technique.

Exhibit case

The component itself, in every state it can hold.

Arming the exhibit…

Why it matters

A segmented control, tab bar, or toolbar built from plain buttons puts one tab stop in the page for every option. Eight filters means eight presses of Tab to get past the filters. Keyboard users experience a well-designed interface as an obstacle course, and nobody notices because nobody tests with the mouse put away.

Roving focus fixes this properly: the group is one tab stop, and arrow keys move within it. This is the pattern the native <select>, radio group, and OS menu bar have used for decades, and it is the single highest-leverage accessibility technique in component work — one mechanism, applied everywhere, and the whole interface becomes traversable.

Key techniques

  • Exactly one child holds tabindex="0"; every other child holds tabindex="-1". The zero roves to whichever option is active.
  • role="radiogroup" with aria-checked on each role="radio" child — selection is announced as a group state, not as eight unrelated pressed buttons.
  • Arrow keys, Home, and End are handled with preventDefault(), so arrow keys do not also scroll the page.
  • Movement wraps modulo the option count, so the group has no dead ends.
  • Focus is moved imperatively after the state update, because changing tabindex alone does not move the caret.
  • The readout beneath the group reports which element currently holds tabindex="0", making the mechanism inspectable instead of merely asserted.

What to steal

The transferable part.

The whole hook. Roving focus is about fifteen lines: an index in state, a tabIndex={i === index ? 0 : -1} expression, and a keydown switch. Extract it once and every group in the codebase inherits it.

Reporting the invariant. Rendering "which element holds tabindex 0" next to the group turns an invisible property into something you can see break. Do this in your own component documentation and regressions are caught by looking.

Handling Home and End. They cost two lines and they are the difference between an accessible group and a good one — with thirty options, arrow-stepping is not a real answer.

What not to copy

Context-specific or costly.

Do not use role="radiogroup" for a group that navigates rather than selects. A set of links or tabs needs role="tablist" with aria-selected, or plain links with no role at all. Roles describe what the thing is; borrowing one because the keyboard behaviour matches produces announcements that lie.

Do not implement roving focus on a group whose options can change position while focused — a sortable or filterable list. The index goes stale, focus lands on whatever moved into that slot, and the user is silently relocated. Track focus by option identity instead of by index in that case.

Inclusion rationale

Included because it is the pattern most often skipped and most cheaply fixed, and because its failure mode is invisible to the person building it. It also raises the floor for everything else in Athena — every grouped control in this canon uses this implementation, so it functions as infrastructure and as a study at the same time.

Technical register

Performance

No listeners are attached at the document level; the handler lives on the children and relies on event delegation through React's synthetic system. State is a single integer. Focus is moved with one .focus() call per keypress, and no measurement or layout read is involved.

Mobile

The pattern is inert on touch — there are no arrow keys — so the group must remain fully operable by tap, which it is: every option keeps its own click handler. The demo's vertical orientation exists for this reason; a horizontal eight-option group is unusable at 375px, and orientation is a layout decision that the keyboard model should follow rather than dictate.

Accessibility

This entry is an accessibility study. aria-label names the group. aria-checked communicates selection. One tab stop means the group can be skipped in a single press. :focus-visible ring is preserved on every option. Arrow key handling is scoped to the group, so page scrolling is unaffected outside it. The one deliberate limitation: the pattern assumes options are announced in DOM order, so visual reordering via CSS is prohibited inside the group.