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 }
Tab into the toolbar. Use Left, Right, Home or End to move between buttons; Tab leaves the group.

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,
}
Tab to Cut, then press Up to reach the disabled Redo action. Keep pressing Up or Down to wrap around.

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,
}
Use the arrows to reach Labels. Press Enter to open it, then Down to reach Work.

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)
}
Console
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,
}
Type a tag and press Enter. The new tag receives focus, and the console logs the count.

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,
}
In Notes, press Control or Command with U, then Shift+Tab. Focus returns to Underline.

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 }
Focus a number. Use Up and Down to move a row, or Left and Right to move one item.

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 }
Focus an action, then keep pressing Left or Right to cycle around the ring. Home and End jump to the first and last actions.

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 }
Focus a note, then use the arrow keys to move to a nearby note in that direction.

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,
}
Focus the map and use the arrow keys to choose a city. Focus stays on the map.

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 Apple and press P repeatedly to cycle through matches. Pause, then type PL quickly to reach Plum.

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 }
Focus a task and press Delete, or click Done. Focus moves to a remaining task.

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,
}
Focus a task and press Enter. Focus follows it between lists. Turn off Keep focus on the task to compare.

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,
}
Focus a seat and use the arrows to move. Home and End reach the row's ends; with Control, or Command on a Mac, they reach the grid's corners.

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 the arrows to move past taken seats. Press Tab to leave the grid; the active seat stays marked.

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 }
Click a cell, then hold Shift and use the arrows or click another cell to extend the range. Move without Shift to start again.

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,
}
Focus a cell and press Down past the last visible row, or Page Down, to bring the next rows into view.

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.

KindKeys and Return keyAutofill and corrections
"email", "url"Keys for an addressAutofills the address, no capitals or corrections
"tel", "one-time-code"A number padAutofills the number, or the code from a text message
"numeric", "decimal"Digits, with a decimal point for "decimal"No autofill
"search"A search Return keyNo autofill, capitals or spelling marks
"name", "username", "postal-code"The usual keysAutofills them, capitalizing names and postal codes
"current-password", "new-password"The usual keysOffers the saved password, or suggests a new one
"message"A send Return keyCapitals, 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 }
Tap each field on a phone: the keys change, and so does the Return key, which iOS 26 draws as a glyph, like › for next and → for search.

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 bind with roving.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 undefined to leave the press alone. Replaces the default, which moves along orientation with 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, or null before 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
cols() => number
How many columns.
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