What is Roving tabindex?
Roving tabindex: Roving tabindex is a keyboard pattern where a group of controls shares one Tab stop and arrow keys move focus inside it; only the active item has tabindex 0.
Roving tabindex is how composite widgets stay fast for keyboard users. A toolbar with twelve buttons should not cost twelve Tab presses to get past; a tab list, a radio group, a menu, a tree or a grid should be one stop in the page's Tab order, with the arrow keys moving around inside. The technique is simple: exactly one item in the group has tabindex="0", every other item has tabindex="-1", and when an arrow key moves focus the attributes move too, so the item the user left last is the one Tab returns to. The tabindex "roves" with focus.
Where it applies
- [Tabs](/glossary/tabs), [radio groups](/glossary/radio-group) and [segmented controls](/glossary/segmented-control): Left and Right (or Up and Down) move, Home and End jump.
- Menus and menu bars: Up and Down move, Left and Right switch menus or open submenus.
- Toolbars: arrows move between buttons, and the active tool can be remembered.
- [Tree views](/glossary/tree-view) and grids: four directions, plus expand and collapse.
- Not for a plain list of links or a set of independent form fields. Those are separate Tab stops, and arrow keys there would be surprising.
Roving tabindex or aria-activedescendant
There is a second way to do the same job. With aria-activedescendant, DOM focus stays on the container (or on a text input, as in a combobox) and an attribute names the currently active item by id; the item only looks focused. That suits widgets where focus must stay in an input while the user moves through options. Roving tabindex moves real focus, which means the browser scrolls the item into view, :focus-visible styles apply and screen readers announce the element the usual way. For anything not attached to a text field, roving tabindex is simpler and more robust.
How to build one in React
Keep a ref per item and the index of the active item. The active item gets tabIndex={0}; arrow keys compute the next index, focus that element and update the state:
"use client"
import * as React from "react"
type Tool = { id: string; label: string; onSelect: () => void }
export function Toolbar({ tools }: { tools: Tool[] }) {
const refs = React.useRef<(HTMLButtonElement | null)[]>([])
const [active, setActive] = React.useState(0)
function move(next: number) {
const i = (next + tools.length) % tools.length
setActive(i)
refs.current[i]?.focus()
}
return (
<div role="toolbar" aria-label="Formatting" className="flex gap-1">
{tools.map((t, i) => (
<button
key={t.id}
ref={(el) => { refs.current[i] = el }}
type="button"
tabIndex={i === active ? 0 : -1}
onClick={t.onSelect}
onFocus={() => setActive(i)}
onKeyDown={(e) => {
if (e.key === "ArrowRight") { e.preventDefault(); move(i + 1) }
if (e.key === "ArrowLeft") { e.preventDefault(); move(i - 1) }
if (e.key === "Home") { e.preventDefault(); move(0) }
if (e.key === "End") { e.preventDefault(); move(tools.length - 1) }
}}
>
{t.label}
</button>
))}
</div>
)
}
Call preventDefault on the handled keys so the page does not scroll, skip disabled items when computing the next index, and decide whether to wrap at the ends (menus and radio groups usually wrap; toolbars often stop). In a radio group or segmented control, moving also selects; in tabs with manual activation, it only moves focus. MiniDev's SegmentedControl implements the pattern by hand, and Tabs, RadioGroup, ToggleGroup and DropdownMenu get it from Base UI.