Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

export type RovingOrientation = "horizontal" | "vertical" | "grid"

export interface UseRovingTabindexOptions {
  /** How many focusable items the group has. Non-integers / negatives / NaN / Infinity are clamped to an integer `>= 0`. */
  count: number
  /**
   * Which pair of arrow keys this group owns:
   * - `"horizontal"` (default) — ←/→ act, ↑/↓ are **not intercepted** and stay with page scrolling;
   * - `"vertical"` — ↑/↓ act, ←/→ pass through;
   * - `"grid"` — all four act, ↑/↓ cross rows by `columns` and ←/→ walk inside the row.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-roving-tabindex.json

Prompt

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, so loop: false stops 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-op setState makes React skip the render and the effect never runs.
  • Silent render-phase repair — when count shrinks 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 fire onIndexChange: 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.

On This Page