Focus and Keys
Move through lists and grids, find items by typing, keep focus when the page changes, and give fields the right keyboard.
Use createRovingFocus for a group with one tab stop and arrow-key navigation, and createGridFocus for rows and columns. The other helpers find items by typing, choose nearby targets, restore focus when items move or disappear, and give a field the right keyboard with inputHints.
Import them from @ui-sdk/core. Compose Behaviors shows how to connect roving focus to a part. Your component decides what a focused item does; Selection covers choosing items and ranges.
Roving Focus
One Tab Stop
The rating used orientation: () => "both" for all four arrows. A row of buttons only needs Left and Right:
const roving = createRovingFocus(context, {
slot: "item",
key: "focused",
orientation: () => "horizontal",
})This handles Left, Right, Home and End. Leave out orientation for a vertical list that uses Up and Down. Left and Right follow the page's text direction.
import * as React from "react"
import { createRovingFocus, definePrimitive, selects } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type ToolbarState = { focused: string | null }
const toolbar = definePrimitive({
name: "toolbar",
state: (): ToolbarState => ({ focused: null }),
api: (context) => ({
roving: createRovingFocus<ToolbarState, { value: string }>(context, {
slot: "item",
key: "focused",
orientation: () => "horizontal",
}),
}),
slots: (slot) => ({
root: slot({ attrs: () => ({ role: "toolbar" }) }),
item: slot<{ value: string }>({
props: { value: "string" },
selects: selects({ focused: ({ key }) => [key, null] }),
attrs: (state, part) => ({
type: "button",
tabindex: part.api.roving.tabIndex(state, part),
}),
on: { keydown: (event, { el, api }) => api.roving.keydown(event, el!) },
bind: (el, part) => part.api.roving.bind(el, part),
}),
}),
})
const parts = createParts(toolbar, { root: "div", item: "button" })
const Toolbar = parts.Root
const ToolbarItem = parts.Item
function Example() {
return (
<Toolbar aria-label="Formatting">
{["Bold", "Italic", "Underline", "Strike"].map((label) => (
<ToolbarItem key={label} value={label}>
{label}
</ToolbarItem>
))}
</Toolbar>
)
}
export { Example, toolbar, Toolbar, ToolbarItem, type ToolbarState }By default, the last focused item keeps the tab stop. To return to the selected item instead, use remember: false, preferred and claims, as we did in the rating guide.
Disabled Items and Wrapping
An unavailable action can still be useful to find. Pass disabled to prefer enabled items when choosing the tab stop, while letting the arrows reach every action:
const roving = createRovingFocus(context, {
slot: "item",
key: "focused",
disabled: (item) => !!item.props.disabled,
loop: () => true,
})loop wraps from the last item to the first, and back.
import * as React from "react"
import { createRovingFocus, definePrimitive, selects } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type ToolbarState = { focused: string | null }
type ToolProps = { value: string; disabled?: boolean }
const toolbar = definePrimitive({
name: "vertical-toolbar",
state: (): ToolbarState => ({ focused: null }),
api: (context) => ({
roving: createRovingFocus<ToolbarState, ToolProps>(context, {
slot: "item",
key: "focused",
orientation: () => "vertical",
loop: () => true,
disabled: (item) => !!item.props.disabled,
}),
}),
slots: (slot) => ({
root: slot({
attrs: () => ({ role: "toolbar", "aria-orientation": "vertical" }),
}),
item: slot<ToolProps>({
props: { value: "string", disabled: "boolean" },
selects: selects({ focused: ({ key }) => [key, null] }),
attrs: (state, part) => ({
type: "button",
tabindex: part.api.roving.tabIndex(state, part),
"aria-disabled": part.props.disabled ? "true" : undefined,
}),
on: { keydown: (event, { el, api }) => api.roving.keydown(event, el!) },
bind: (el, part) => part.api.roving.bind(el, part),
}),
}),
})
const parts = createParts(toolbar, { root: "div", item: "button" })
const Toolbar = parts.Root
const ToolbarItem = parts.Item
function Example() {
return (
<Toolbar aria-label="Edit">
{["Undo", "Redo", "Cut", "Copy", "Paste"].map((label) => (
<ToolbarItem
key={label}
value={label}
disabled={label === "Undo" || label === "Redo"}
>
{label}
</ToolbarItem>
))}
</Toolbar>
)
}
export {
Example,
toolbar,
Toolbar,
ToolbarItem,
type ToolbarState,
type ToolProps,
}The item still needs aria-disabled and a press handler that ignores it. A native disabled button can't receive focus. If every item is disabled, the group can still give one of them the tab stop.
Items That Can't Take Focus
Use focusable when an item should be skipped altogether. These nested folders stay rendered while their branch is closed:
focusable: (item) => !item.props.nested || context.store.get().open,import * as React from "react"
import { createRovingFocus, definePrimitive, selects } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { ChevronRight } from "lucide-react"
type FoldersState = { focused: string | null; open: boolean }
type FolderProps = { value: string; nested?: boolean }
const folders = definePrimitive({
name: "folders",
state: (): FoldersState => ({ focused: null, open: false }),
api: (context) => ({
roving: createRovingFocus<FoldersState, FolderProps>(context, {
slot: "item",
key: "focused",
focusable: (item) => !item.props.nested || context.store.get().open,
}),
toggle: () => context.store.set({ open: !context.store.get().open }),
}),
slots: (slot) => ({
root: slot({}),
item: slot<FolderProps>({
props: { value: "string", nested: "boolean" },
selects: selects({ focused: ({ key }) => [key, null] }),
attrs: (state, part) => ({
type: "button",
tabindex: part.api.roving.tabIndex(state, part),
hidden: part.props.nested && !state.open ? "" : undefined,
}),
on: { keydown: (event, { el, api }) => api.roving.keydown(event, el!) },
bind: (el, part) => part.api.roving.bind(el, part),
}),
toggle: slot<{ value: string }>({
props: { value: "string" },
selects: selects({ focused: ({ key }) => [key, null] }),
attrs: (state, part) => ({
type: "button",
tabindex: part.api.roving.tabIndex(state, part),
"aria-expanded": String(state.open),
}),
press: ({ api }) => api.toggle(),
on: { keydown: (event, { el, api }) => api.roving.keydown(event, el!) },
bind: (el, part) => part.api.roving.bind(el, part),
}),
}),
})
const parts = createParts(folders, {
root: "div",
item: "button",
toggle: "button",
})
const Folders = parts.Root
const FoldersItem = parts.Item
const FoldersToggle = parts.Toggle
function Example() {
return (
<Folders role="group" aria-label="Folders">
<FoldersItem value="Inbox">Inbox</FoldersItem>
<FoldersItem value="Drafts">Drafts</FoldersItem>
<FoldersToggle value="Labels">
Labels
<ChevronRight aria-hidden="true" />
</FoldersToggle>
{["Work", "Family", "Travel"].map((label) => (
<FoldersItem key={label} value={label} nested>
{label}
</FoldersItem>
))}
<FoldersItem value="Trash">Trash</FoldersItem>
</Folders>
)
}
export {
Example,
folders,
Folders,
FoldersItem,
type FoldersState,
FoldersToggle,
}The group checks focusable again when state changes. If it depends on something outside that state, call roving.check() after it changes.
Items That Come and Go
roving.focus(key) moves focus to an item. You can also call it just before that item renders:
function add(tag: string) {
context.options().onAdd?.(tag)
roving.focus(tag)
}import * as React from "react"
import { createRovingFocus, definePrimitive, selects } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"
type TagListOptions = {
onAdd?: (tag: string) => void
onCountChange?: (count: number) => void
}
type TagListState = { focused: string | null }
const tagList = definePrimitive({
name: "tag-list",
options: { onAdd: "callback", onCountChange: "callback" },
state: (_options: TagListOptions): TagListState => ({ focused: null }),
api: (context) => {
const roving = createRovingFocus<TagListState, { value: string }>(context, {
slot: "tag",
key: "focused",
orientation: () => "horizontal",
onItemsChange: () =>
context.options().onCountChange?.(roving.items().length),
})
return {
roving,
add(input: HTMLInputElement) {
const tag = input.value.trim()
if (!tag) {
return
}
context.options().onAdd?.(tag)
input.value = ""
roving.focus(tag)
},
}
},
slots: (slot) => ({
root: slot({}),
input: slot({
on: {
keydown: (event, { el, api }) => {
if (event.key !== "Enter") {
return
}
event.preventDefault()
api.add(el as HTMLInputElement)
},
},
}),
list: slot({}),
tag: slot<{ value: string }>({
props: { value: "string" },
selects: selects({ focused: ({ key }) => [key, null] }),
attrs: (state, part) => ({
type: "button",
tabindex: part.api.roving.tabIndex(state, part),
}),
on: { keydown: (event, { el, api }) => api.roving.keydown(event, el!) },
bind: (el, part) => part.api.roving.bind(el, part),
}),
}),
})
const parts = createParts(tagList, {
root: "div",
input: "input",
list: "div",
tag: "button",
})
const TagList = parts.Root
const TagListInput = parts.Input
const TagListList = parts.List
const TagListTag = parts.Tag
function Example() {
const log = useLog()
const [tags, setTags] = React.useState(["design", "docs"])
return (
<TagList
onAdd={(tag: string) =>
setTags((tags) => (tags.includes(tag) ? tags : [...tags, tag]))
}
onCountChange={(count: number) =>
log(`${count} ${count === 1 ? "tag" : "tags"}`)
}
>
<TagListInput
aria-label="New tag"
placeholder="Type a tag and press Enter"
/>
<TagListList role="group" aria-label="Tags">
{tags.map((tag) => (
<TagListTag data-size="sm" key={tag} value={tag}>
{tag}
</TagListTag>
))}
</TagListList>
</TagList>
)
}
export {
Example,
tagList,
TagList,
TagListInput,
TagListList,
type TagListOptions,
TagListTag,
}roving.items() returns the focusable items in document order, with each item's key, props and el. This demo uses onItemsChange to update the count after items join or leave.
This works when the new item renders as part of the current update. For data loaded later, call focus(key) when the item is ready. If the user moves focus elsewhere in the meantime, it stays there.
Move the Tab Stop Without Moving Focus
A shortcut can use a toolbar action while focus stays in an editor. Call remember from that shortcut's handler so the next visit to the toolbar lands on the action:
api.roving.remember("Underline")import * as React from "react"
import { createRovingFocus, definePrimitive, selects } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type EditorState = { focused: string | null }
const shortcuts: Record<string, string> = {
b: "Bold",
i: "Italic",
u: "Underline",
}
const editor = definePrimitive({
name: "editor",
state: (): EditorState => ({ focused: null }),
api: (context) => ({
roving: createRovingFocus<EditorState, { value: string }>(context, {
slot: "button",
key: "focused",
orientation: () => "horizontal",
}),
}),
slots: (slot) => ({
root: slot({}),
toolbar: slot({ attrs: () => ({ role: "toolbar" }) }),
button: slot<{ value: string }>({
props: { value: "string" },
selects: selects({ focused: ({ key }) => [key, null] }),
attrs: (state, part) => ({
type: "button",
tabindex: part.api.roving.tabIndex(state, part),
}),
on: { keydown: (event, { el, api }) => api.roving.keydown(event, el!) },
bind: (el, part) => part.api.roving.bind(el, part),
}),
field: slot({
on: {
keydown: (event, { api }) => {
const button = shortcuts[event.key.toLowerCase()]
if (!button || !(event.ctrlKey || event.metaKey)) {
return
}
event.preventDefault()
api.roving.remember(button)
},
},
}),
}),
})
const parts = createParts(editor, {
root: "div",
toolbar: "div",
button: "button",
field: "textarea",
})
const Editor = parts.Root
const EditorToolbar = parts.Toolbar
const EditorButton = parts.Button
const EditorField = parts.Field
function Example() {
return (
<Editor>
<EditorToolbar aria-label="Format">
{Object.values(shortcuts).map((label) => (
<EditorButton key={label} value={label}>
{label}
</EditorButton>
))}
</EditorToolbar>
<EditorField
aria-label="Notes"
rows={3}
defaultValue="Press Control or Command with U, then Shift+Tab."
/>
</Editor>
)
}
export {
Editor,
EditorButton,
EditorField,
type EditorState,
EditorToolbar,
editor,
Example,
}remember(key) changes the tab stop without moving focus. Groups with remember: false ignore it.
Any Layout
The rating's move function stepped through stars. A keypad can use the same hook to move three items at a time for Up and Down:
move: (event, { from, items, rtl }) => {
if (event.ctrlKey || event.metaKey) {
return
}
const index = items.findIndex((item) => item.key === from.key)
const by = {
ArrowDown: 3,
ArrowUp: -3,
ArrowRight: rtl ? -1 : 1,
ArrowLeft: rtl ? 1 : -1,
}[event.key]
return by === undefined ? undefined : items[index + by]?.key
},import * as React from "react"
import { createRovingFocus, definePrimitive, selects } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type KeypadState = { focused: string | null }
const columns = 3
const keypad = definePrimitive({
name: "keypad",
state: (): KeypadState => ({ focused: null }),
api: (context) => ({
roving: createRovingFocus<KeypadState, { value: string }>(context, {
slot: "digit",
key: "focused",
move: (event, { from, items, rtl }) => {
if (event.ctrlKey || event.metaKey) {
return
}
const index = items.findIndex((item) => item.key === from.key)
const by = {
ArrowDown: columns,
ArrowUp: -columns,
ArrowRight: rtl ? -1 : 1,
ArrowLeft: rtl ? 1 : -1,
}[event.key]
return by === undefined ? undefined : items[index + by]?.key
},
}),
}),
slots: (slot) => ({
root: slot({ attrs: () => ({ role: "group" }) }),
digit: slot<{ value: string }>({
props: { value: "string" },
selects: selects({ focused: ({ key }) => [key, null] }),
attrs: (state, part) => ({
type: "button",
tabindex: part.api.roving.tabIndex(state, part),
}),
on: { keydown: (event, { el, api }) => api.roving.keydown(event, el!) },
bind: (el, part) => part.api.roving.bind(el, part),
}),
}),
})
const parts = createParts(keypad, { root: "div", digit: "button" })
const Keypad = parts.Root
const KeypadDigit = parts.Digit
function Example() {
return (
<Keypad aria-label="Keypad">
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((digit) => (
<KeypadDigit key={digit} value={digit}>
{digit}
</KeypadDigit>
))}
</Keypad>
)
}
export { Example, keypad, Keypad, KeypadDigit, type KeypadState }move receives the focused item, the focusable items in document order, and the text direction. Return a key to move there, or undefined to leave the key press alone. It replaces the built-in arrow, Home, End and wrapping rules, so include any of those your component needs.
Like focus(key), it can return the key of an item about to render. A calendar can use this to move from the last day of one month into the next.
Around a Ring
For actions arranged around a circle, Left and Right can follow their order around the ring. Use the built-in horizontal keys with wrapping:
const roving = createRovingFocus(context, {
slot: "action",
key: "focused",
orientation: () => "horizontal",
loop: () => true,
})Render the actions in their order around the circle. Their positions don't change which action comes next.
import * as React from "react"
import { createRovingFocus, definePrimitive, selects } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type RingState = { focused: string | null }
const actions = ["Reply", "Forward", "Archive", "Snooze", "Delete", "Label"]
function place(action: string) {
const angle =
(actions.indexOf(action) / actions.length) * 2 * Math.PI - Math.PI / 2
return { x: Math.cos(angle), y: Math.sin(angle) }
}
const ring = definePrimitive({
name: "ring",
state: () => ({ focused: null as string | null }),
api: (context) => ({
roving: createRovingFocus<RingState, { value: string }>(context, {
slot: "action",
key: "focused",
orientation: () => "horizontal",
loop: () => true,
}),
}),
slots: (slot) => ({
root: slot({ attrs: () => ({ role: "toolbar" }) }),
action: slot<{ value: string }>({
props: { value: "string" },
selects: selects({ focused: ({ key }) => [key, null] }),
attrs: (state, part) => ({
type: "button",
tabindex: part.api.roving.tabIndex(state, part),
}),
on: { keydown: (event, { el, api }) => api.roving.keydown(event, el!) },
bind: (el, part) => part.api.roving.bind(el, part),
}),
}),
})
const parts = createParts(ring, { root: "div", action: "button" })
const Ring = parts.Root
const RingAction = parts.Action
function Example() {
return (
<Ring aria-label="Message">
{actions.map((action) => {
const { x, y } = place(action)
return (
<RingAction
data-size="sm"
key={action}
value={action}
style={
{
"--x": `${50 + x * 36}%`,
"--y": `${50 + y * 36}%`,
} as React.CSSProperties
}
>
{action}
</RingAction>
)
})}
</Ring>
)
}
export { Example, ring, Ring, RingAction, type RingState }Spatial Focus
Follow the Layout
On a board, the next item in the document may not be the one to the right. Use spatialMove() to choose a nearby item in the arrow's direction:
const roving = createRovingFocus(context, {
slot: "note",
key: "focused",
move: spatialMove(),
})import * as React from "react"
import {
createRovingFocus,
definePrimitive,
selects,
spatialMove,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type PinboardState = { focused: string | null }
const pinboard = definePrimitive({
name: "pinboard",
state: (): PinboardState => ({ focused: null }),
api: (context) => ({
roving: createRovingFocus<PinboardState, { value: string }>(context, {
slot: "note",
key: "focused",
move: spatialMove(),
}),
}),
slots: (slot) => ({
root: slot({ attrs: () => ({ role: "group" }) }),
note: slot<{ value: string }>({
props: { value: "string" },
selects: selects({ focused: ({ key }) => [key, null] }),
attrs: (state, part) => ({
type: "button",
tabindex: part.api.roving.tabIndex(state, part),
}),
on: { keydown: (event, { el, api }) => api.roving.keydown(event, el!) },
bind: (el, part) => part.api.roving.bind(el, part),
}),
}),
})
const parts = createParts(pinboard, { root: "div", note: "button" })
const Pinboard = parts.Root
const PinboardNote = parts.Note
const notes = [
{ text: "Call Hollis", x: 8, y: 12 },
{ text: "Ship docs", x: 62, y: 6 },
{ text: "Lunch", x: 36, y: 44 },
{ text: "Review", x: 4, y: 70 },
{ text: "Gym", x: 70, y: 64 },
]
function Example() {
return (
<Pinboard aria-label="Notes">
{notes.map((note) => (
<PinboardNote
key={note.text}
value={note.text}
style={{ left: `${note.x}%`, top: `${note.y}%` }}
>
{note.text}
</PinboardNote>
))}
</Pinboard>
)
}
export { Example, pinboard, Pinboard, PinboardNote, type PinboardState }spatialMove measures the elements and favors items directly ahead. Left always means left on screen, even in right-to-left text. Home and End use document order. Alt, Control and Command combinations are left for other shortcuts.
Supply Your Own Positions
If your app already knows where items are, pass a rect function. This is useful for a zoomed canvas or a layout calculated from angles:
move: spatialMove({
rect: (el, key) => {
const { x, y } = place(key)
return { left: x, right: x, top: y, bottom: y }
},
}),Use the same units for every item, with larger y values farther down.
Choose a Target Without Moving Focus
nearestInDirection uses the same position-based search on plain boxes. It returns a key, so you can decide what to do with the result:
const next = nearestInDirection(
{ left: 0, right: 10, top: 0, bottom: 10 },
[{ key: "next", rect: { left: 20, right: 30, top: 0, bottom: 10 } }],
[1, 0]
)
// next is "next".The direction is [x, y]: [1, 0] means right and [0, -1] means up. If nothing lies that way, the result is undefined.
import * as React from "react"
import { definePrimitive, nearestInDirection } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type CityMapState = { city: string }
const cities: Record<string, { x: number; y: number }> = {
Lisbon: { x: 14, y: 78 },
Paris: { x: 36, y: 40 },
London: { x: 30, y: 18 },
Berlin: { x: 64, y: 22 },
Rome: { x: 62, y: 74 },
Vienna: { x: 78, y: 46 },
}
const directions: Record<string, readonly [number, number]> = {
ArrowUp: [0, -1],
ArrowDown: [0, 1],
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
}
const box = (city: string) => {
const { x, y } = cities[city]
return { left: x, right: x, top: y, bottom: y }
}
const cityMap = definePrimitive({
name: "city-map",
state: (): CityMapState => ({ city: "Paris" }),
api: (context) => ({
go(direction: readonly [number, number]) {
const { city } = context.store.get()
const others = Object.keys(cities)
.filter((other) => other !== city)
.map((other) => ({ key: other, rect: box(other) }))
const next = nearestInDirection(box(city), others, direction)
if (next) {
context.store.set({ city: next })
}
},
}),
slots: (slot) => ({
map: slot({
attrs: (state) => ({
role: "img",
tabindex: "0",
"aria-label": `Map, ${state.city} chosen`,
}),
on: {
keydown: (event, { api }) => {
const direction = directions[event.key]
if (!direction) {
return
}
event.preventDefault()
api.go(direction)
},
},
}),
city: slot<{ value: string }>({
props: { value: "string" },
attrs: (state, part) => ({
"data-chosen": state.city === part.key ? "" : undefined,
}),
}),
arrow: slot<{ value: string }>({
props: { value: "string" },
attrs: () => ({ type: "button" }),
press: ({ api, key }) => api.go(directions[key]),
}),
}),
})
const parts = createParts(cityMap, {
map: "div",
city: "span",
arrow: "button",
})
const CityMap = parts.Root
const CityMapMap = parts.Map
const CityMapCity = parts.City
const CityMapArrow = parts.Arrow
const labels = {
ArrowUp: "North",
ArrowLeft: "West",
ArrowRight: "East",
ArrowDown: "South",
}
function Example() {
return (
<CityMap>
<div data-slot="stack">
<div data-slot="controls">
{Object.entries(labels).map(([key, label]) => (
<CityMapArrow key={key} value={key} data-size="sm">
{label}
</CityMapArrow>
))}
</div>
<CityMapMap>
{Object.entries(cities).map(([city, { x, y }]) => (
<CityMapCity
key={city}
value={city}
aria-hidden="true"
style={{ left: `${x}%`, top: `${y}%` }}
>
{city}
</CityMapCity>
))}
</CityMapMap>
</div>
</CityMap>
)
}
export {
CityMap,
CityMapArrow,
CityMapCity,
CityMapMap,
cityMap,
type CityMapState,
Example,
}Typeahead
Text covers finding an item from typed characters. To move focus to that match, combine it with roving.items() and roving.focus():
const typeahead = createTypeahead()
function keydown(event: KeyboardEvent, el: HTMLElement) {
if (
event.defaultPrevented ||
roving.keydown(event, el) ||
!isTypeaheadKey(event)
) {
return
}
if (event.key === " " && typeahead.active()) {
event.preventDefault()
}
const items = roving.items()
const index = typeahead(
event.key,
items.map((item) => labelOf(item.el)),
items.findIndex((item) => item.el === el)
)
if (index !== -1) {
roving.focus(items[index].key)
}
}Create typeahead once in api, then connect this handler to the item's keydown.
import * as React from "react"
import {
createRovingFocus,
createTypeahead,
definePrimitive,
isTypeaheadKey,
labelOf,
selects,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type FruitListState = { focused: string | null }
const fruitList = definePrimitive({
name: "fruit-list",
state: (): FruitListState => ({ focused: null }),
api: (context) => {
const roving = createRovingFocus<FruitListState, { value: string }>(
context,
{
slot: "item",
key: "focused",
}
)
const typeahead = createTypeahead()
return {
roving,
keydown(event: KeyboardEvent, el: HTMLElement) {
if (
event.defaultPrevented ||
roving.keydown(event, el) ||
!isTypeaheadKey(event)
) {
return
}
if (event.key === " " && typeahead.active()) {
event.preventDefault()
}
const items = roving.items()
const index = typeahead(
event.key,
items.map((item) => labelOf(item.el)),
items.findIndex((item) => item.el === el)
)
if (index !== -1) {
roving.focus(items[index].key)
}
},
}
},
slots: (slot) => ({
root: slot({ attrs: () => ({ role: "listbox" }) }),
item: slot<{ value: string }>({
props: { value: "string" },
selects: selects({ focused: ({ key }) => [key, null] }),
attrs: (state, part) => ({
role: "option",
tabindex: part.api.roving.tabIndex(state, part),
}),
on: { keydown: (event, { el, api }) => api.keydown(event, el!) },
bind: (el, part) => part.api.roving.bind(el, part),
}),
}),
})
const parts = createParts(fruitList, { root: "div", item: "div" })
const FruitList = parts.Root
const FruitListItem = parts.Item
const fruits = ["Apple", "Banana", "Cherry", "Peach", "Pear", "Plum", "Quince"]
function Example() {
return (
<FruitList aria-label="Fruit">
{fruits.map((fruit) => (
<FruitListItem key={fruit} value={fruit}>
{fruit}
</FruitListItem>
))}
</FruitList>
)
}
export { Example, fruitList, FruitList, FruitListItem, type FruitListState }Focus Recovery
When a focused row is removed, the browser can leave focus on the page. createFocusRecovery lets you choose where it should go:
const recovery = createFocusRecovery<{ value: string; index: number }>({
fallback: (lost) => {
const items = roving.items()
return (items[lost.props.index] ?? items.at(-1))?.el
},
})This picks the row that takes the old one's place, or the last row if we removed the end. Return recovery from api, then bind it alongside roving focus:
bind: (el, part) => {
const unbindFocus = part.api.roving.bind(el, part)
const unbindRecovery = part.api.recovery.bind(el, part)
return () => {
unbindFocus()
unbindRecovery()
}
},import * as React from "react"
import {
createFocusRecovery,
createRovingFocus,
definePrimitive,
selects,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type TasksState = { focused: string | null }
type TaskProps = { value: string; index: number }
const tasks = definePrimitive({
name: "tasks",
state: (): TasksState => ({ focused: null }),
api: (context) => {
const roving = createRovingFocus<TasksState, TaskProps>(context, {
slot: "task",
key: "focused",
})
const recovery = createFocusRecovery<TaskProps>({
fallback: (lost) => {
const items = roving.items()
return (items[lost.props.index] ?? items.at(-1))?.el
},
})
return { roving, recovery }
},
slots: (slot) => ({
root: slot({ attrs: () => ({ role: "list" }) }),
task: slot<TaskProps>({
props: { value: "string", index: "number" },
selects: selects({ focused: ({ key }) => [key, null] }),
attrs: (state, part) => ({
role: "listitem",
tabindex: part.api.roving.tabIndex(state, part),
}),
on: { keydown: (event, { el, api }) => api.roving.keydown(event, el!) },
bind: (el, part) => {
const cleanups = [
part.api.roving.bind(el, part),
part.api.recovery.bind(el, part),
]
return () => cleanups.forEach((cleanup) => cleanup?.())
},
}),
}),
})
const parts = createParts(tasks, { root: "div", task: "div" })
const Tasks = parts.Root
const TasksItem = parts.Task
function Example() {
const [list, setList] = React.useState([
"Write the intro",
"Add examples",
"Fix the links",
"Ship it",
])
const done = (task: string) =>
setList((current) => current.filter((other) => other !== task))
return (
<Tasks aria-label="Tasks">
{list.map((task, index) => (
<TasksItem
key={task}
value={task}
index={index}
onKeyDown={(event) => event.key === "Delete" && done(task)}
>
{task}
<button
type="button"
tabIndex={-1}
data-variant="ghost"
data-size="sm"
onClick={() => done(task)}
>
Done
</button>
</TasksItem>
))}
{!list.length && <p data-slot="hint">All done.</p>}
</Tasks>
)
}
export { Example, tasks, Tasks, TasksItem, type TaskProps, type TasksState }fallback receives the removed part's key, last props and element. Returning nothing leaves focus where it is. Recovery runs when the part unbinds and waits for the render to finish; it won't take focus back from something else, such as a dialog.
Focus That Follows a Move
Sometimes an item moves to another list and renders as a new element. Call keepFocus after changing state, with a function that finds the new element:
move(task: string) {
const { done } = context.store.get()
context.store.set({ done: [...done, task] })
keepFocus(() => context.element("task", task))
},import * as React from "react"
import { definePrimitive, keepFocus } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type ChecklistOptions = {
keep?: boolean
}
type ChecklistState = { done: string[] }
const checklist = definePrimitive({
name: "checklist",
options: { keep: "boolean" },
state: (_options: ChecklistOptions): ChecklistState => ({ done: [] }),
api: (context) => ({
toggle(task: string) {
const { done } = context.store.get()
context.store.set({
done: done.includes(task)
? done.filter((other) => other !== task)
: [...done, task],
})
if (context.options().keep) {
keepFocus(() => context.element("task", task))
}
},
}),
slots: (slot) => ({
root: slot<{}, ChecklistState>({
state: (state) => ({ done: state.done }),
}),
list: slot({ attrs: () => ({ role: "list" }) }),
task: slot<{ value: string }>({
props: { value: "string" },
attrs: (state, part) => ({
type: "button",
"aria-pressed": String(state.done.includes(part.key)),
}),
press: ({ api, key }) => api.toggle(key),
}),
}),
})
const parts = createParts(checklist, {
root: "div",
list: "ul",
task: "button",
})
const Checklist = parts.Root
const ChecklistList = parts.List
const ChecklistTask = parts.Task
const tasks = ["Write the intro", "Add examples", "Fix the links"]
function Example() {
const [keep, setKeep] = React.useState(true)
return (
<div data-slot="stack">
<div data-slot="controls">
<label data-slot="label">
<input
type="checkbox"
checked={keep}
onChange={(event) => setKeep(event.target.checked)}
/>
Keep focus on the task
</label>
</div>
<Checklist keep={keep}>
{({ done }) =>
[false, true].map((finished) => (
<section key={String(finished)} data-slot="keep-focus-column">
<h4>{finished ? "Done" : "To do"}</h4>
<ChecklistList aria-label={finished ? "Done" : "To do"}>
{tasks
.filter((task) => done.includes(task) === finished)
.map((task) => (
<li key={task}>
<ChecklistTask value={task}>{task}</ChecklistTask>
</li>
))}
</ChecklistList>
</section>
))
}
</Checklist>
</div>
)
}
export {
Checklist,
checklist,
ChecklistList,
type ChecklistOptions,
type ChecklistState,
ChecklistTask,
Example,
}keepFocus retries after the update so the new element has time to render. If the user moves focus elsewhere in the meantime, it leaves that focus alone.
Grids
One Tab Stop in a Grid
For cells addressed by row and column, use createGridFocus. Import the GridCell type from @ui-sdk/core, start the active cell at null, then create the behavior in api:
state: () => ({ active: null as GridCell | null }),
api: (context) => ({
grid: createGridFocus(context, {
key: "active",
rows: () => 3,
cols: () => 6,
}),
}),Each cell has a zero-based row and col. Connect its tab stop, keys and element to the behavior:
seat: slot<{ row: number; col: number }>({
props: { row: "number", col: "number" },
selects: selects({ active: byCell }),
attrs: (state, { props, api }) => ({
role: "gridcell",
tabindex: api.grid.tabIndex(state, props),
}),
on: {
keydown: (event, { api }) => api.grid.keydown(event),
pointerdown: (event, { api, props }) => api.grid.focus(props, { reason: "item-press", event }),
},
bind: (el, { api, props }) => api.grid.bind(el, props),
}),Render the cells inside rows with role="row", inside a root with role="grid". Give each cell a stable id, such as "1:2", so the primitive can tell them apart.
selects({ active: byCell }) lets a move reach only the cell it leaves and the cell it lands on, so a grid with thousands of cells stays fast.
import * as React from "react"
import {
byCell,
createGridFocus,
definePrimitive,
selects,
type GridCell,
} from "@ui-sdk/core"
import { createParts, usePart, type PartProps } from "@ui-sdk/react/primitive"
type SeatMapState = { active: GridCell | null }
type SeatMapOptions = { rows: number; cols: number }
const seatMap = definePrimitive({
name: "seat-map",
options: { rows: "number", cols: "number" },
state: (_options: SeatMapOptions): SeatMapState => ({ active: null }),
api: (context) => ({
grid: createGridFocus<SeatMapState>(context, {
key: "active",
rows: () => context.options().rows,
cols: () => context.options().cols,
}),
}),
slots: (slot) => ({
root: slot({ attrs: () => ({ role: "grid" }) }),
row: slot<{ id: string }>({
props: { id: "string" },
attrs: () => ({ role: "row" }),
}),
seat: slot<{ id: string; row: number; col: number }>({
props: { row: "number", col: "number" },
selects: selects({ active: byCell }),
attrs: (state, { props, api }) => ({
role: "gridcell",
tabindex: api.grid.tabIndex(state, props),
}),
on: {
keydown: (event, { api }) => api.grid.keydown(event),
pointerdown: (event, { api, props }) =>
api.grid.focus(props, { reason: "item-press", event }),
},
bind: (el, { api, props }) => api.grid.bind(el, props),
}),
}),
})
const parts = createParts(seatMap, { root: "div", row: "div" })
const SeatMap = parts.Root
const SeatMapRow = parts.Row
function SeatMapSeat({
row,
col,
...props
}: { row: number; col: number } & PartProps<"div">) {
return usePart(
seatMap,
"seat",
{ id: `${row}:${col}`, row, col },
props,
"div"
)
}
const letters = "ABCD"
function Example() {
return (
<SeatMap rows={3} cols={6} aria-label="Seats">
{[0, 1, 2].map((row) => (
<SeatMapRow key={row} id={String(row)}>
{[0, 1, 2, 3, 4, 5].map((col) => (
<SeatMapSeat
key={col}
row={row}
col={col}
aria-label={`Seat ${letters[row]}${col + 1}`}
>
{letters[row]}
{col + 1}
</SeatMapSeat>
))}
</SeatMapRow>
))}
</SeatMap>
)
}
export {
Example,
seatMap,
SeatMap,
SeatMapRow,
SeatMapSeat,
type SeatMapOptions,
type SeatMapState,
}Left and Right follow the text direction. Call grid.focus({ row, col }) to move to a cell from another interaction, such as a click.
Cells That Can't Take Focus
Pass disabled to skip unavailable cells:
disabled: (cell) => context.options().taken.includes(`${cell.row}:${cell.col}`),Unlike roving focus's disabled option, this skips the cell during navigation. Before the grid is entered, Tab lands on the first cell that isn't disabled, row by row.
import * as React from "react"
import {
byCell,
createGridFocus,
definePrimitive,
selects,
type GridCell,
} from "@ui-sdk/core"
import { createParts, usePart, type PartProps } from "@ui-sdk/react/primitive"
type TheaterState = { active: GridCell | null }
type TheaterOptions = {
taken: string[]
}
const theater = definePrimitive({
name: "theater",
options: { taken: "list" },
state: (_options: TheaterOptions): TheaterState => ({ active: null }),
api: (context) => ({
grid: createGridFocus<TheaterState>(context, {
key: "active",
rows: () => 3,
cols: () => 6,
disabled: (cell) =>
context.options().taken.includes(`${cell.row}:${cell.col}`),
}),
}),
slots: (slot) => ({
root: slot({ attrs: () => ({ role: "grid" }) }),
row: slot<{ id: string }>({
props: { id: "string" },
attrs: () => ({ role: "row" }),
}),
seat: slot<{ id: string; row: number; col: number }>({
props: { row: "number", col: "number" },
selects: selects({ active: byCell }),
attrs: (state, { props, api }) => ({
role: "gridcell",
tabindex: api.grid.tabIndex(state, props),
"data-active": api.grid.isActive(state, props) ? "" : undefined,
}),
on: {
keydown: (event, { api }) => api.grid.keydown(event),
pointerdown: (event, { api, props }) =>
api.grid.focus(props, { reason: "item-press", event }),
},
bind: (el, { api, props }) => api.grid.bind(el, props),
}),
}),
})
const parts = createParts(theater, { root: "div", row: "div" })
const Theater = parts.Root
const TheaterRow = parts.Row
function TheaterSeat({
row,
col,
...props
}: { row: number; col: number } & PartProps<"div">) {
return usePart(
theater,
"seat",
{ id: `${row}:${col}`, row, col },
props,
"div"
)
}
const letters = "ABC"
const taken = ["0:1", "0:2", "1:3", "2:0", "2:4"]
function Example() {
return (
<Theater taken={taken} aria-label="Theater">
{[0, 1, 2].map((row) => (
<TheaterRow key={row} id={String(row)}>
{[0, 1, 2, 3, 4, 5].map((col) => {
const isTaken = taken.includes(`${row}:${col}`)
return (
<TheaterSeat
key={col}
row={row}
col={col}
aria-label={`Seat ${letters[row]}${col + 1}${isTaken ? ", taken" : ""}`}
aria-disabled={isTaken || undefined}
>
{letters[row]}
{col + 1}
</TheaterSeat>
)
})}
</TheaterRow>
))}
</Theater>
)
}
export {
Example,
theater,
Theater,
TheaterRow,
TheaterSeat,
type TheaterOptions,
type TheaterState,
}Use grid.isActive(state, cell) to keep the active cell marked when focus leaves:
"data-active": api.grid.isActive(state, props) ? "" : undefined,Selecting a Range
onMove tells you where focus moved, where it came from, and whether Shift was held. Use that to keep the start of a selection:
onMove: (cell, { from, shift }) => {
const anchor = context.store.get().anchor ?? from
context.store.set({ anchor: shift && anchor ? anchor : cell })
},import * as React from "react"
import { createGridFocus, definePrimitive, type GridCell } from "@ui-sdk/core"
import { createParts, usePart, type PartProps } from "@ui-sdk/react/primitive"
type SheetState = { active: GridCell | null; anchor: GridCell | null }
const sheet = definePrimitive({
name: "sheet",
state: (): SheetState => ({ active: null, anchor: null }),
api: (context) => ({
grid: createGridFocus<SheetState>(context, {
key: "active",
rows: () => 4,
cols: () => 4,
onMove: (cell, { from, shift }) => {
const anchor = context.store.get().anchor ?? from
context.store.set({ anchor: shift && anchor ? anchor : cell })
},
}),
}),
slots: (slot) => ({
root: slot({
attrs: () => ({ role: "grid", "aria-multiselectable": "true" }),
}),
row: slot<{ id: string }>({
props: { id: "string" },
attrs: () => ({ role: "row" }),
}),
cell: slot<{ id: string; row: number; col: number }>({
props: { row: "number", col: "number" },
attrs: (state, { props, api }) => {
const { active, anchor } = state
const inside =
!!active &&
!!anchor &&
props.row >= Math.min(active.row, anchor.row) &&
props.row <= Math.max(active.row, anchor.row) &&
props.col >= Math.min(active.col, anchor.col) &&
props.col <= Math.max(active.col, anchor.col)
return {
role: "gridcell",
tabindex: api.grid.tabIndex(state, props),
"aria-selected": String(inside),
}
},
on: {
keydown: (event, { api }) => api.grid.keydown(event),
pointerdown: (event, { api, props }) =>
api.grid.focus(props, {
reason: "item-press",
event,
shift: event.shiftKey,
}),
},
bind: (el, { api, props }) => api.grid.bind(el, props),
}),
}),
})
const parts = createParts(sheet, { root: "div", row: "div" })
const Sheet = parts.Root
const SheetRow = parts.Row
function SheetCell({
row,
col,
...props
}: { row: number; col: number } & PartProps<"div">) {
return usePart(sheet, "cell", { id: `${row}:${col}`, row, col }, props, "div")
}
const columns = "ABCD"
function Example() {
return (
<Sheet aria-label="Sheet">
{[0, 1, 2, 3].map((row) => (
<SheetRow key={row} id={String(row)}>
{[0, 1, 2, 3].map((col) => (
<SheetCell key={col} row={row} col={col}>
{columns[col]}
{row + 1}
</SheetCell>
))}
</SheetRow>
))}
</Sheet>
)
}
export { Example, sheet, Sheet, SheetCell, SheetRow, type SheetState }For Shift-click, pass shift: event.shiftKey to grid.focus. The callback also receives the event and a reason: "list-navigation" for keys, "item-press" for a press or "imperative", the names a listbox reports. Selection covers turning the anchor and active cell into a range.
Rows That Aren't Rendered
A large grid may render only the visible rows. Add reveal to bring a target row into view before focus moves there:
reveal: ({ row }) => {
const { first } = context.store.get()
if (row < first) {
context.store.set({ first: row })
} else if (row >= first + 6) {
context.store.set({ first: row - 5 })
}
},
pageSize: () => 6,This timetable shows six rows at a time.
import * as React from "react"
import {
byCell,
createGridFocus,
definePrimitive,
selects,
type GridCell,
} from "@ui-sdk/core"
import { createParts, usePart, type PartProps } from "@ui-sdk/react/primitive"
type TimetableState = { active: GridCell | null; first: number }
const count = 100
const shown = 6
const timetable = definePrimitive({
name: "timetable",
state: (): TimetableState => ({ active: null, first: 0 }),
api: (context) => ({
grid: createGridFocus<TimetableState>(context, {
key: "active",
rows: () => count,
cols: () => 3,
pageSize: () => shown,
reveal: ({ row }) => {
const { first } = context.store.get()
if (row < first) {
context.store.set({ first: row })
} else if (row >= first + shown) {
context.store.set({ first: row - shown + 1 })
}
},
}),
}),
slots: (slot) => ({
root: slot<{}, { first: number }>({
state: (state) => ({ first: state.first }),
}),
body: slot({
attrs: () => ({ role: "grid", "aria-rowcount": String(count) }),
}),
row: slot<{ id: string; index: number }>({
props: { index: "number" },
attrs: (_state, { props }) => ({
role: "row",
"aria-rowindex": String(props.index + 1),
}),
}),
cell: slot<{ id: string; row: number; col: number }>({
props: { row: "number", col: "number" },
selects: selects({ active: byCell }),
attrs: (state, { props, api }) => ({
role: "gridcell",
tabindex: api.grid.tabIndex(state, props),
}),
on: {
keydown: (event, { api }) => api.grid.keydown(event),
pointerdown: (event, { api, props }) =>
api.grid.focus(props, { reason: "item-press", event }),
},
bind: (el, { api, props }) => api.grid.bind(el, props),
}),
}),
})
const parts = createParts(timetable, { root: "div", body: "div" })
const Timetable = parts.Root
const TimetableBody = parts.Body
function TimetableRow({
index,
...props
}: { index: number } & PartProps<"div">) {
return usePart(timetable, "row", { id: String(index), index }, props, "div")
}
function TimetableCell({
row,
col,
...props
}: { row: number; col: number } & PartProps<"div">) {
return usePart(
timetable,
"cell",
{ id: `${row}:${col}`, row, col },
props,
"div"
)
}
const places = ["Leeds", "York", "Bath", "Hull", "Ely"]
const time = (row: number) => {
const minutes = 6 * 60 + row * 7
return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`
}
const cellText = (row: number, col: number) =>
col === 0
? time(row)
: col === 1
? places[row % places.length]
: `Platform ${(row % 4) + 1}`
function Example() {
return (
<Timetable>
{({ first }) => (
<TimetableBody aria-label="Departures">
{Array.from({ length: shown }, (_, offset) => first + offset).map(
(row) => (
<TimetableRow key={row} index={row}>
{[0, 1, 2].map((col) => (
<TimetableCell key={col} row={row} col={col}>
{cellText(row, col)}
</TimetableCell>
))}
</TimetableRow>
)
)}
</TimetableBody>
)}
</Timetable>
)
}
export {
Example,
timetable,
Timetable,
TimetableBody,
TimetableCell,
TimetableRow,
type TimetableState,
}pageSize sets how far Page Up and Page Down move. With a virtualized list, reveal can call virtualizer.scrollToIndex(row) instead. Focus lands when the cell binds.
Input Hints
A few attributes tell the browser what a field holds: inputmode picks a phone's keys, like an @ for an email address or a number pad for a code, and enterkeyhint picks its Return key. autocomplete says what to autofill, and autocapitalize, autocorrect and spellcheck say whether to change what people type. inputHints fills them in for a kind of field:
import { inputHints } from "@ui-sdk/core"
const hints = inputHints("email", { enter: "next" })
// { inputMode: "email", enterKeyHint: "next", autoComplete: "email", autoCapitalize: "none", autoCorrect: "off", spellCheck: "false" }Spread them on an <input> or <textarea>. They're spelled as React spells them, and HTML reads attribute names in any case, so the same object works in Vue, Svelte, Solid and setAttribute. enter sets the Return key, like "next" while more fields follow and "done" on the last one. iPhone's number pads, for "tel", "numeric", "decimal" and "one-time-code", have no Return key, so there enter only shows on other phones.
| Kind | Keys and Return key | Autofill and corrections |
|---|---|---|
"email", "url" | Keys for an address | Autofills the address, no capitals or corrections |
"tel", "one-time-code" | A number pad | Autofills the number, or the code from a text message |
"numeric", "decimal" | Digits, with a decimal point for "decimal" | No autofill |
"search" | A search Return key | No autofill, capitals or spelling marks |
"name", "username", "postal-code" | The usual keys | Autofills them, capitalizing names and postal codes |
"current-password", "new-password" | The usual keys | Offers the saved password, or suggests a new one |
"message" | A send Return key | Capitals, corrections and spelling marks on |
import * as React from "react"
import { inputHints, type EnterKeyHint, type InputHintKind } from "@ui-sdk/core"
import {
Field,
FieldControl,
FieldDescription,
FieldLabel,
} from "@ui-sdk/react/field"
const fields: { label: string; kind: InputHintKind; enter?: EnterKeyHint }[] = [
{ label: "Email", kind: "email", enter: "next" },
{ label: "Phone", kind: "tel", enter: "next" },
{ label: "Verification code", kind: "one-time-code", enter: "done" },
{ label: "Search", kind: "search" },
{ label: "Message", kind: "message" },
]
const keys: Record<string, string> = {
text: "Letter keys",
email: "Email keys",
tel: "Phone pad with + * #",
url: "Web address keys",
numeric: "Number pad",
decimal: "Number pad with a decimal point",
search: "Search keys",
}
const pads = new Set(["tel", "numeric", "decimal"])
const returns: Record<EnterKeyHint, string> = {
enter: "return or ⏎",
next: "next or ›",
previous: "previous or ‹",
done: "done or ✓",
go: "go or →",
search: "search or →",
send: "send or ↑",
}
function Example() {
return (
<form data-slot="stack" onSubmit={(event) => event.preventDefault()}>
{fields.map(({ label, kind, enter }) => {
const hints = inputHints(kind, { enter })
return (
<Field key={kind}>
<FieldLabel>{label}</FieldLabel>
<FieldControl {...hints} />
<FieldDescription>
{keys[hints.inputMode ?? "text"]}
{pads.has(hints.inputMode ?? "")
? ""
: ` · Return says ${returns[hints.enterKeyHint ?? "enter"]}`}
</FieldDescription>
</Field>
)
})}
</form>
)
}
export { Example }They're only hints: a field keeps its own type and validation, so a type="email" field still checks the address. Attributes after the spread replace the hints.
API Reference
Roving Focus Options
slotstring- The slot whose parts are the group's items. Each joins in the slot's
bindwithroving.bind(el, part). - Example: One Tab Stop
keykeyof State & string- The state key that holds the key of the item with the tab stop. Start it at
null. - Example: One Tab Stop
orientation?(() => RovingOrientation | "both")- Which arrows move focus when there's no
move: Up and Down, Left and Right, or"both"for all four, like a rating. Defaults to"vertical". - Example: One Tab Stop
loop?(() => boolean)- Whether moving past either end wraps around. Defaults to
false. - Example: Disabled Items and Wrapping
remember?boolean- Whether the item focused last keeps the stop, like a toolbar, rather than the preferred one, like tabs. Defaults to
true. - Example: One Tab Stop
disabled?((item: RovingFocusItem<Props>) => boolean)- Whether an item can't be activated. Enabled items take the stop first; disabled ones can still take focus.
- Example: Disabled Items and Wrapping
focusable?((item: RovingFocusItem<Props>) => boolean)- Whether an item can take focus now. An item that can't stays in the group and is skipped, like a tree item under a collapsed branch. Defaults to every item.
- Example: Items That Can't Take Focus
preferred?((item: RovingFocusItem<Props>) => boolean)- Whether an item takes the stop before focus has been in the group: the selected tab, the checked radio.
- Example: One Tab Stop
claims?((state: State, item: RovingFocusItem<Props>) => boolean)- Whether an item rendering before any item binds, like on the server, may hold the stop. The first such item does, so server markup has exactly one stop. Defaults to enabled and focusable items. Pass it when the stop should start on a selected item, like
(state, item) => state.value === null || item.key === state.value. - Example: One Tab Stop
move?((event: KeyboardEvent, context: RovingFocusMoveContext<Props>) => string | undefined)- The key focus moves to for a key press, or
undefinedto leave the press alone. Replaces the default, which moves alongorientationwith the arrows, Home and End. Use it for any other shape: a tree's visible order, or a grid's rows. Shift is never swallowed, so a list can extend a range. The key may be one that isn't rendered or focusable yet, like the first day of next month in a calendar: the stop moves to it at once, and focus follows when its item can take it, as long as focus is still where the press left it. - Example: Any Layout
onItemsChange?(() => void)- Runs once after items join or leave, however many did, once the stop has moved to where the rules put it.
- Example: Items That Come and Go
Grid Focus Options
keykeyof State & string- The state key that holds the active
GridCell, ornullbefore the grid is entered. - Example: One Tab Stop in a Grid
rows() => number- How many rows the grid has, header rows included when they hold cells.
- Example: One Tab Stop in a Grid
pageSize?(() => number)- How many rows Page Up and Page Down move, usually how many show.
- Example: Rows That Aren't Rendered
disabled?((cell: GridCell) => boolean)- Cells that can't take focus, skipped by the arrows and by the tab stop before the grid is entered.
- Example: Cells That Can't Take Focus
reveal?((cell: GridCell) => void)- Shows a cell that may not be rendered, like
virtualizer.scrollToIndex(cell.row), before focus moves to it. - Example: Rows That Aren't Rendered
onMove?((cell: GridCell, details: { from: GridCell | null; reason: GridFocusReason; event?: Event; shift: boolean; }) => void)- Runs after the active cell moves, with the cell it left.
- Example: Selecting a Range