useRovingTabindex
A roving-tabindex hook that collapses a whole control group into one Tab stop — arrow keys inside, with horizontal/vertical/grid lanes, disabled-item skipping, loop-or-stop, Home/End and typeahead.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-roving-tabindex.jsonPrompt
Build a React + TypeScript "useRovingTabindex" hook (React only — no other
dependencies; it renders nothing and owns no markup).
Contract
- `useRovingTabindex(options): { activeIndex, getItemProps, setActiveIndex,
containerProps }` where options =
`{ count, orientation = "horizontal", columns, loop = true,
defaultIndex = 0, index, onIndexChange, disabledIndexes,
getItemText, typeaheadTimeout = 500 }`.
- `count: number` — how many focusable items the group renders.
- `orientation: "horizontal" | "vertical" | "grid"` — which arrow pair the
group owns.
- `columns?: number` — only meaningful for `"grid"`; missing or invalid
values degrade to 1 column (a vertical list).
- `loop?: boolean` — wrap at the end (default) or stop dead. In grid mode
"the end" means the end of the current row / current column, not of the
whole list.
- `defaultIndex` / `index` / `onIndexChange` — the usual uncontrolled /
controlled triad. Passing `index` (even `0`) switches to controlled: the
hook stops owning state and only reports upward.
- `disabledIndexes?: Iterable<number>` — never focusable by arrow keys and
never the Tab stop.
- `getItemText?: (index: number) => string` — supplying it turns typeahead
on; omit it and letter keys are left alone.
- `getItemProps(index)` returns `{ ref, tabIndex, onKeyDown, onFocus, onClick,
"data-index" }`, meant to be spread onto the focusable element itself.
Exactly one item gets `tabIndex: 0` (the active one); everything else gets
`-1`. That single 0 is the whole point: the group occupies one Tab stop.
- `setActiveIndex(index, options?)` moves the Tab stop AND, unless
`{ focus: false }`, moves DOM focus there. Out-of-range values are clamped;
a disabled target is ignored outright rather than silently nudged to a
neighbour.
- `containerProps` is `{ onFocus }` only — deliberately no `role`. Whether the
group is a toolbar, a radiogroup or a tablist is the consumer's call, and
guessing wrong is worse than not guessing. Its `onFocus` fires only when the
container itself receives focus (blank padding, a programmatic
`container.focus()`) and hands focus to the active item.
- Consumers who need their own `onClick` must COMPOSE, not overwrite:
spreading `getItemProps(i)` and then declaring `onClick` afterwards drops the
hook's click sync. Call `props.onClick(event)` first, then your own handler.
Behavior
- Movement runs over "lanes" — a lane is the one-dimensional sequence an arrow
key walks. For horizontal/vertical the lane is the whole list; in a grid,
left/right walk the current ROW and up/down walk the current COLUMN. One
search routine serves all three.
- Ragged last row: lane lengths are computed from the real cell count, not from
`columns`. With 14 items in 5 columns, column 4 holds only #4 and #9, so
ArrowDown from #9 wraps back to #4 instead of walking into a hole at #14; the
last row holds #10–#13 and ArrowRight from #13 wraps to #10.
- Disabled items are stepped over. The search is hard-capped at one lane length
of iterations, so a group whose items are ALL disabled terminates and returns
"no move" instead of spinning forever. That group still keeps one
`tabIndex: 0` — dropping it would delete the whole group from the Tab order.
- `loop: false` stops at the lane edge; `loop: true` wraps within the same
lane. Either way an arrow key the group owns is always `preventDefault()`ed,
even when nothing moved — otherwise "stop at the end" turns into "scroll the
page at the end".
- The arrow pair the group does NOT own is passed through untouched (no
`preventDefault`). A horizontal toolbar must let ArrowDown scroll the page.
Arrow keys held with ⌘/Ctrl/⌥ are also passed through — those are system
actions (back, line start, word jump).
- `Home` / `End` go to the first / last ENABLED item; in a grid they are
row-scoped and ⌘/Ctrl + Home/End addresses the whole grid (the ARIA grid
pattern).
- Typeahead (only when `getItemText` is given): buffer printable characters,
reset after `typeaheadTimeout` ms, match case-insensitively on prefix, wrap
around, skip disabled items. Repeating the SAME character cycles through all
items starting with it (search starts at the NEXT index); a multi-character
buffer re-matches from the CURRENT index, so refining a prefix keeps you on
the item you just landed on whenever it still matches.
`preventDefault()` only on a hit, so a miss still belongs to the page. Space
is excluded — it activates the item.
- Focus is imperative and SYNCHRONOUS. Do not schedule it from an effect that
depends on the index changing: moving focus to the index that is already
active is a no-op `setState`, React skips the render, and the effect never
runs. Focus the node right away from a `Map<number, HTMLElement>` filled by
the item refs, guarded with `el.isConnected` (a detached node silently
swallows `focus()` and drops focus to `body`) and with an
`activeElement` check. Keep exactly one post-commit retry for the case where
the target has not mounted yet, then drop the request — a stale focus request
that survives will hijack focus during some unrelated later render.
- `count` changes clamp the index during RENDER (adjust-state), not in an
effect: a synchronous `setState` in an effect body is a lint error
(react-hooks/set-state-in-effect) and shows one frame of an out-of-range
index. The same pass moves the Tab stop off an item that just became
disabled.
- Clamping and disabled-skipping are silent repairs: they do NOT call
`onIndexChange`, because calling a prop during render is illegal and a
shrinking list is not a user action. A controlled parent that persists the
index across list changes should clamp on its side too.
- `onIndexChange` lives in a latest-ref and never enters a dependency array —
consumers pass inline arrows, and depending on them would re-run effects
every render. The per-item handlers and refs are memoized on `count` so ref
callbacks are not detached and re-attached on every render.
- Clamp every numeric input (`count`, `columns`, `defaultIndex`): `NaN` /
`Infinity` / negatives / fractions arrive from real consumers, and an
unclamped loop bound freezes the tab.
Rendering & styling
- The hook renders nothing; the consumer owns every element and every class.
Spread `getItemProps(i)` onto the focusable element itself (`button`, `a`,
`[role=radio]`), not onto a wrapper.
- Keep the ARIA ownership chain intact: `role="toolbar"` /
`role="radiogroup"` + `role="radio"` / `role="listbox"` + `role="option"`
with no role-less div in between (add `role="presentation"` if you need a
layout wrapper). Add `aria-orientation` on the container yourself.
- Items need a visible focus ring — `focus-visible:ring-2
focus-visible:ring-ring` on semantic tokens, never a hardcoded colour. Since
only one item is tabbable, the ring is the only thing telling a keyboard user
where they are.
- Mark unavailable items with `aria-disabled` (still focusable, still
announced) rather than the `disabled` attribute when you want screen readers
to read them out, and pass their indexes in `disabledIndexes` so arrow keys
skip them.
Customization levers
- Grid arrow flow — this recipe keeps left/right inside the row and up/down
inside the column (the ARIA grid pattern). For emoji-picker-style "flow"
behaviour where ArrowRight at the end of a row falls into the next row, swap
the row lane for the flat lane on left/right and keep the column lane for
up/down.
- RTL — not handled: ArrowRight always means "next". In a right-to-left group,
read `getComputedStyle(el).direction` inside the key handler and flip the
left/right delta.
- Extra keys — PageUp/PageDown (jump a screenful), `*` (expand all in a tree),
Delete: add cases in the same switch, reusing the lane search.
- Activation model — this hook only moves focus. For "selection follows focus"
(tabs that switch panel on arrow), call your select handler from
`onIndexChange`; for explicit activation, keep selection on click/Enter.
- Typeahead window — raise `typeaheadTimeout` for long labels, or drop
`getItemText` entirely if single letters are shortcuts in your surface.
- Multiple groups — one hook call per group. Nothing is global, so a toolbar
and a grid on the same page keep independent Tab stops.Concepts
- Single Tab stop — the group publishes exactly one
tabIndex: 0; every other item is-1. Tab therefore enters the group once and the next Tab leaves it entirely, no matter how many items there are. That one 0 must never disappear: even when every item is disabled the hook keeps it, otherwise the whole group falls out of the Tab order. - Lane — the one-dimensional sequence an arrow key walks. The whole list is one lane for horizontal/vertical groups; a grid has one lane per row (left/right) and one per column (up/down). Lane lengths come from the real cell count, which is what makes an unfilled last row behave: a short column wraps back to its own top instead of stepping into a cell that does not exist.
- Bounded skip search — the search steps one position at a time, skipping
disabledIndexes, and is capped at one lane length of iterations. That cap is what turns "everything is disabled" from an infinite loop into a clean "no move". - Owned vs. passed-through keys — a group only calls
preventDefault()on the axis it owns (and on Home/End). A horizontal toolbar leaves ArrowUp/ArrowDown to the page, so the document still scrolls while focus sits inside the group. Keys the group owns are prevented even when nothing moved, soloop: falsestops instead of scrolling. - Imperative focus — focus moves the moment the key is handled, straight to the element registered by the item ref, guarded by
isConnected. Deferring it to an effect breaks silently whenever the target is the index that is already active, because a no-opsetStatemakes React skip the render and the effect never runs. - Silent render-phase repair — when
countshrinks or the active item becomes disabled, the index is clamped during render (adjust-state, not an effect) so no frame ever renders an out-of-range Tab stop. The repair does not fireonIndexChange: it is not a user action, and firing a callback during render is not allowed. - Typeahead — printable keys build a buffer that resets on a timer. A repeated single character cycles through every item starting with it; a longer buffer is treated as a prefix and re-matches from the current item, so refining a search never skips the item you just landed on.
useFocusTrap
A hook that keeps keyboard focus inside a container — moves focus in, cycles Tab, defers Escape to you, and returns focus to the trigger.
usePermission
Subscribes to navigator.permissions for one name or a whole panel of them — query only, never request — folding Firefox's rejections, Safari's gaps, unknown names and insecure origins into one honest unsupported state with a reason.