useRovingRows
A keyboard cursor for a table: j/k to walk, x to select, Enter to open, and one tab stop for the whole table.
Roving tabindex, so a table of two hundred rows costs the reader one Tab press rather than two hundred: exactly one row is focusable and the cursor moves within the table. The cursor is an index into the ids it was given rather than a DOM read, so a filtered or re-sorted table simply hands over a new list — and a row that has gone gives the cursor back to the first row instead of stranding it. Keys pressed inside a cell's own control (an input, a select, a menu's search box) belong to that control and are left alone. Arrow keys do the same as j and k; Home and End go to the ends and neither end wraps, because wrapping loses the reader's place in a long list.
Install
npx shadcn@latest add @vibra/use-roving-rowsNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { useRovingRows } from "@/hooks/use-roving-rows"
import { StatusBadge } from "@/components/ui/status-badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const ROWS = [
{ id: "inv_1041", customer: "Northwind", amount: "$1,280.00", status: "paid" },
{ id: "inv_1042", customer: "Beacon Retail", amount: "$980.00", status: "pending" },
{ id: "inv_1043", customer: "Granite Retail", amount: "$2,410.00", status: "overdue" },
{ id: "inv_1044", customer: "Harborview Robotics", amount: "$340.00", status: "paid" },
]
export default function UseRovingRowsDemo() {
const [selected, setSelected] = React.useState<string[]>([])
const [opened, setOpened] = React.useState<string | null>(null)
const roving = useRovingRows({
ids: ROWS.map((row) => row.id),
onToggle: (id) =>
setSelected((current) =>
current.includes(id) ? current.filter((other) => other !== id) : [...current, id]
),
onActivate: setOpened,
})
return (
<div className="flex w-full max-w-xl flex-col gap-3">
<p className="text-sm text-muted-foreground">
Click a row, then press <kbd>j</kbd> and <kbd>k</kbd> to walk, <kbd>x</kbd> to select,{" "}
<kbd>Enter</kbd> to open. The whole table is one tab stop.
</p>
<div className="overflow-hidden panel" {...roving.containerProps}>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Invoice</TableHead>
<TableHead>Customer</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ROWS.map((row) => (
<TableRow
key={row.id}
{...roving.rowProps(row.id)}
data-state={selected.includes(row.id) ? "selected" : undefined}
className="cursor-pointer focus-ring"
onClick={() => setOpened(row.id)}
>
<TableCell className="font-mono text-xs">{row.id}</TableCell>
<TableCell>{row.customer}</TableCell>
<TableCell className="text-right tabular-nums">{row.amount}</TableCell>
<TableCell>
<StatusBadge status={row.status} size="sm" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<p aria-live="polite" className="text-xs text-faint-foreground">
{selected.length} selected{opened ? ` · opened ${opened}` : ""}
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| ids | string[] | — | The row ids, in the order they are rendered. |
| onToggle | (id: string) => void | — | Fired by x — usually the row's own toggleSelected. |
| onActivate | (id: string) => void | — | Fired by Enter, for the same action a click on the row performs. |
| enabled | boolean | true | Off for a table that is loading or empty, or one nested in another widget's key handling. |
| returns.containerProps | { onKeyDown, aria-activedescendant } | — | Spread onto the scroll container around the rows. |
| returns.rowProps | (id: string) => { id, tabIndex, data-active, onFocus } | — | Spread onto each row, with that row's own id. |
| returns.activeId | string | undefined | — | The row under the cursor, or undefined before a key has been pressed. |
Dependencies
Registry
Source
"use client"
import * as React from "react"
/**
* Keyboard rows: j and k (or the arrow keys) walk a table, x toggles the
* selection on the row under the cursor, and Enter opens it.
*
* The pattern is the one a mail client uses, and the reason it is a hook rather
* than a handler on each row is the tab order: a table of two hundred rows must
* cost the reader one Tab press, not two hundred, so exactly one row is
* focusable at a time and the cursor moves within it. The rows themselves stay
* plain — they take `tabIndex` and an id from `rowProps`, and the container
* carries the listener and `aria-activedescendant`, which is what tells a
* screen reader that the focus inside the grid has moved.
*
* Nothing here reads the DOM: the cursor is an index into the ids it was given,
* so a filtered or re-sorted table simply hands over a new list.
*/
export type UseRovingRowsOptions = {
/** The row ids in the order they are rendered. */
ids: string[]
/** Fired by x — usually the row's own `toggleSelected`. */
onToggle?: (id: string) => void
/** Fired by Enter, and by a click that did not land on a control. */
onActivate?: (id: string) => void
/** Turns the whole thing off, for a table that is loading or empty. */
enabled?: boolean
}
export type UseRovingRows = {
/** The row the cursor is on, or undefined before the reader has used a key. */
activeId: string | undefined
setActiveId: (id: string | undefined) => void
/** Spread onto the scroll container or the <tbody>. */
containerProps: {
onKeyDown: (event: React.KeyboardEvent) => void
"aria-activedescendant": string | undefined
}
/** Spread onto each row, with the row's own id. */
rowProps: (id: string) => {
id: string
tabIndex: number
"data-active": true | undefined
onFocus: () => void
}
}
/** Prefixed so the id is unique on a page holding more than one table. */
function domId(scope: string, id: string) {
return `${scope}-row-${id}`
}
export function useRovingRows({
ids,
onToggle,
onActivate,
enabled = true,
}: UseRovingRowsOptions): UseRovingRows {
const scope = React.useId()
const [activeId, setActiveId] = React.useState<string | undefined>(undefined)
// A row that has gone — filtered out, or on the previous page — cannot keep
// the cursor, and the reader should not have to press a key to get it back.
const current = activeId !== undefined && ids.includes(activeId) ? activeId : undefined
const cursor = current === undefined ? -1 : ids.indexOf(current)
const focus = React.useCallback(
(index: number) => {
const id = ids[index]
if (id === undefined) return
setActiveId(id)
document.getElementById(domId(scope, id))?.focus()
},
[ids, scope]
)
const onKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
if (!enabled || ids.length === 0) return
// A key pressed inside a cell's own control — a checkbox, a menu, a
// search box — belongs to that control.
const target = event.target as HTMLElement | null
if (target?.closest("input, textarea, select, [contenteditable='true']")) return
const key = event.key
const next =
key === "j" || key === "ArrowDown"
? Math.min(cursor + 1, ids.length - 1)
: key === "k" || key === "ArrowUp"
? Math.max(cursor - 1, 0)
: key === "Home"
? 0
: key === "End"
? ids.length - 1
: null
if (next !== null) {
event.preventDefault()
focus(cursor === -1 && (key === "k" || key === "ArrowUp") ? ids.length - 1 : next)
return
}
if (current === undefined) return
if (key === "x") {
event.preventDefault()
onToggle?.(current)
} else if (key === "Enter") {
event.preventDefault()
onActivate?.(current)
}
},
[current, cursor, enabled, focus, ids, onActivate, onToggle]
)
const rowProps = React.useCallback(
(id: string) => ({
id: domId(scope, id),
// Roving tabindex: the cursor's row, or the first one before the reader
// has touched a key, so the table is one stop in the tab order.
tabIndex: (current === undefined ? ids[0] === id : current === id) ? 0 : -1,
"data-active": (current === id || undefined) as true | undefined,
onFocus: () => setActiveId(id),
}),
[current, ids, scope]
)
return {
activeId: current,
setActiveId,
containerProps: {
onKeyDown,
"aria-activedescendant": current === undefined ? undefined : domId(scope, current),
},
rowProps,
}
}