Search a list of options, render long lists, follow new messages, pick by scrolling, and edit values in place.

Use createListbox for options you move through and search, createVirtualizer for long lists, createStickToBottom for chats and logs, bindScrollPicker for wheels you scroll to pick from, and createInlineEdit for editable labels.

Each helper works on its own or alongside focus and selection in a component.

Move Through the Options

createListbox keeps one option highlighted and moves the highlight with the arrow keys, the way a listbox or a combobox does. Keep the highlighted option's key in state, starting at null, and create the behavior in api:

import { createListbox } from "@ui-sdk/core"

const listbox = createListbox(context, {
  key: "highlighted",
  focus: "virtual",
})

With focus: "virtual", focus stays where it is, like in a search input, and the input points aria-activedescendant at the highlighted option. Leave focus out and the highlight moves real focus from option to option instead.

Each option joins in its slot's bind, and attrs gives it an id, data-highlighted and aria-disabled:

option: slot<{ value: string }>({
  role: "option",
  focus: "active-descendant",
  props: { value: "string" },
  selects: selects({ highlighted: ({ key }) => [key, null] }),
  attrs: (state, part) => ({
    ...part.api.listbox.attrs(state, part),
    "aria-selected": String(state.value === part.key),
  }),
  on: { pointermove: (event, { key, api }) => api.listbox.highlight(key, { event }) },
  bind: (el, part) => part.api.listbox.bind(el, part),
}),

selects lets a move reach only the option it leaves and the one it lands on. On the input, set aria-activedescendant to api.listbox.activeId(state) and pass keydown to api.listbox.keydown(event). The arrows, PageUp and PageDown move the highlight. With real focus, Home, End and typing an option's name move it too. Moving past either end wraps around unless loop returns false.

Choosing an option is yours. On Enter, read the highlighted key from state and write your value with createChange, or with createSelection when people can choose several.

Filter as You Type

Pass the text to filter by as query. In composed lists like the one above, an option only renders while it matches, so add present to the option slot:

const listbox = createListbox(context, {
  key: "highlighted",
  focus: "virtual",
  query: () => context.store.get().query,
})
present: (_state, part) => part.api.listbox.shows(part),

Options match by their label prop, or by their text when they have none, ignoring case and accents. When the query changes, call listbox.move("first") to highlight the best match, as this search does:

Console
import * as React from "react"
import {
  createChange,
  createListbox,
  definePrimitive,
  selects,
  type ChangeDetails,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type FruitPickerState = {
  value: string | null
  query: string
  highlighted: string | null
}

type FruitPickerOptions = {
  onValueChange?: (
    value: string | null,
    details: ChangeDetails<"item-press">
  ) => void
}

type FruitProps = { value: string; label?: string }

const fruitPicker = definePrimitive({
  name: "fruit-picker",
  options: { onValueChange: "callback" },
  state: (_options: FruitPickerOptions): FruitPickerState => ({
    value: null,
    query: "",
    highlighted: null,
  }),
  api: (context) => {
    const change = createChange(context)
    const listbox = createListbox(context, {
      key: "highlighted",
      focus: "virtual",
      query: () => context.store.get().query,
    })
    const choose = (key: string, event?: Event) =>
      change("value", key, { reason: "item-press", event })
    return {
      listbox,
      choose,
      search(query: string) {
        context.store.set({ query })
        listbox.move("first")
      },
      keydown(event: KeyboardEvent) {
        const { highlighted } = context.store.get()
        if (event.key === "Enter" && highlighted !== null) {
          event.preventDefault()
          choose(highlighted, event)
          return
        }
        listbox.keydown(event)
      },
    }
  },
  slots: (slot) => ({
    root: slot({}),
    input: slot({
      attrs: (state, { id, api }) => ({
        role: "combobox",
        "aria-expanded": "true",
        "aria-controls": `${id}-list`,
        "aria-autocomplete": "list",
        "aria-activedescendant": api.listbox.activeId(state),
      }),
      on: {
        input: (event, { api }) =>
          api.search((event.target as HTMLInputElement).value),
        keydown: (event, { api }) => api.keydown(event),
      },
    }),
    list: slot({
      attrs: (_state, { id }) => ({ id: `${id}-list`, role: "listbox" }),
    }),
    option: slot<FruitProps>({
      role: "option",
      focus: "active-descendant",
      props: { value: "string", label: "string" },
      selects: selects({ highlighted: ({ key }) => [key, null] }),
      present: (_state, part) => part.api.listbox.shows(part),
      attrs: (state, part) => ({
        ...part.api.listbox.attrs(state, part),
        "aria-selected": String(state.value === part.key),
      }),
      press: ({ key, api }, event) => api.choose(key, event),
      on: {
        pointerdown: (event) => event.preventDefault(),
        pointermove: (event, { key, api }) =>
          api.listbox.highlight(key, { event }),
      },
      bind: (el, part) => part.api.listbox.bind(el, part),
    }),
  }),
})

const parts = createParts(fruitPicker, {
  root: "div",
  input: "input",
  list: "div",
  option: "div",
})

const FruitPicker = parts.Root

const FruitPickerInput = parts.Input

const FruitPickerList = parts.List

const FruitPickerOption = parts.Option

const fruits = [
  "Apple",
  "Apricot",
  "Banana",
  "Blueberry",
  "Cherry",
  "Grape",
  "Mango",
  "Peach",
  "Pear",
  "Plum",
]

function Example() {
  const log = useLog()
  return (
    <FruitPicker
      onValueChange={(value: string | null) => log(`Chose ${value}`)}
    >
      <FruitPickerInput aria-label="Fruit" placeholder="Search fruit" />
      <FruitPickerList aria-label="Fruit">
        {fruits.map((fruit) => (
          <FruitPickerOption key={fruit} value={fruit}>
            {fruit}
          </FruitPickerOption>
        ))}
      </FruitPickerList>
    </FruitPicker>
  )
}

export {
  Example,
  fruitPicker,
  FruitPicker,
  FruitPickerInput,
  FruitPickerList,
  FruitPickerOption,
  type FruitPickerOptions,
  type FruitPickerState,
}
Type to filter the fruit. Use Up and Down to move the highlight, and Enter or a click to choose one.

filter decides what matches: "contains" by default, "startsWith", or a function that returns true, false, or a score. A score ranks the options, best first, and 0 hides one. listbox.count() says how many options match, for a message when none do.

Options as Data

For options that come from data, pass them as items: strings, items with a value and label, groups and separators. listbox.items() returns what matches the query, in order, so render that instead of the full list:

const listbox = createListbox(context, {
  key: "highlighted",
  items: () => context.options().items,
  query: () => context.store.get().query,
})

Pass onHighlight to hear where the highlight moved, with its index for a virtualizer to scroll to, and onEnd to load the next page when it reaches the last option.

Options

keykeyof State & string
The state key that holds the highlighted option's key. Start it at null.
Example: Move Through the Options
focus?"real" | "virtual"
"real" moves focus to the highlighted option. "virtual" keeps it where it is, like in an input that points aria-activedescendant at activeId(state). Defaults to "real".
Example: Move Through the Options
items?(() => ListData<T>)
The options as data: items, plain strings, groups and separators. Without it, the bound parts are the options, in document order.
Example: Options as Data
query?(() => string)
The text options are filtered by. Empty shows every option.
Example: Filter as You Type
filter?(() => ListFilter<T>)
How query narrows the options. A function that returns a number ranks them too, best first. Defaults to "contains".
Example: Filter as You Type
loop?(() => boolean)
Whether moving past either end wraps around. Defaults to true.
Example: Move Through the Options
onHighlight?((key: string | null, details: ListHighlightDetails) => void)
Runs when the highlight moves, with the option's index among those that match, for a virtualizer to scroll to.
Example: Options as Data
onEnd?(() => void)
Runs when the highlight reaches the last option, so the next page can load.
Example: Options as Data

Long Lists

Render What's in View

createVirtualizer tells you which rows to render and where to place them. Start with the number of rows and an estimated height in pixels:

import { createVirtualizer } from "@ui-sdk/core"

const virtual = createVirtualizer({
  count: () => entries.length,
  estimate: () => 32,
})

Once the scroller mounts, connect it and subscribe to changes:

const disconnect = virtual.connect(scroller)
const unsubscribe = virtual.subscribe(render)

Render virtual.items() inside a relatively positioned box virtual.size() pixels tall. Each item gives you its index, key, start and size. Position its element absolutely at start, leaving the full-height box to provide the scrollbar.

import * as React from "react"
import {
  createVirtualizer,
  css,
  definePrimitive,
  type VirtualItem,
} from "@ui-sdk/core"
import {
  createParts,
  mergeProps,
  renderElement,
  usePart,
  usePrimitive,
  type PartProps,
} from "@ui-sdk/react/primitive"

type LedgerOptions = {
  count: number
}

type LedgerRootState = {
  items: VirtualItem[]
}

type LedgerRowProps = { value: string; index: number; start: number }

const rowHeight = 32

const ledger = definePrimitive({
  name: "ledger",
  options: { count: "number" },
  state: (options: LedgerOptions) => ({ count: options.count }),
  sync: (options) => ({ count: options.count }),
  api: (context) => {
    const virtual = createVirtualizer({
      count: () => context.store.get().count,
      estimate: () => rowHeight,
    })
    context.onDestroy(context.store.subscribe(() => virtual.refresh()))
    return { virtual }
  },
  slots: (slot) => ({
    root: slot({
      attrs: () => ({ tabindex: "0", style: css({ overflow: "auto" }) }),
      bind: (el, { api }) => api.virtual.connect(el),
    }),
    body: slot({
      attrs: (state) => ({
        role: "list",
        style: css({
          position: "relative",
          height: `${state.count * rowHeight}px`,
        }),
      }),
    }),
    row: slot<LedgerRowProps>({
      props: { value: "string", index: "number", start: "number" },
      attrs: (state, { props }) => ({
        role: "listitem",
        "aria-setsize": String(state.count),
        "aria-posinset": String(props.index + 1),
        style: css({
          position: "absolute",
          top: "0",
          left: "0",
          right: "0",
          height: `${rowHeight}px`,
          transform: `translateY(${props.start}px)`,
        }),
      }),
    }),
  }),
})

const parts = createParts(ledger, { body: "div" })

function Ledger({
  count,
  className,
  style,
  children,
  render,
  ...props
}: LedgerOptions & PartProps<"div", LedgerRootState>) {
  const handle = usePrimitive(ledger, { count })
  const { virtual } = handle.api
  const items = React.useSyncExternalStore(
    virtual.subscribe,
    virtual.items,
    virtual.items
  )
  const own = { className, style, children }
  return usePart(
    handle,
    "root",
    {},
    {
      ...props,
      render: (merged) =>
        renderElement("div", mergeProps(own, merged), render, { items }),
    }
  )
}

const LedgerBody = parts.Body

function LedgerRow({
  item,
  ...props
}: { item: VirtualItem } & PartProps<"div">) {
  return usePart(
    ledger,
    "row",
    { value: item.key, index: item.index, start: item.start },
    props,
    "div"
  )
}

const money = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
})
const amount = (index: number) => ((index * 7919) % 100000) / 100

function Example() {
  return (
    <Ledger count={10000}>
      {({ items }) => (
        <>
          <LedgerBody aria-label="Ledger">
            {items.map((item) => (
              <LedgerRow key={item.key} item={item}>
                <span data-slot="hint">Entry {item.index + 1}</span>
                <span>{money.format(amount(item.index))}</span>
              </LedgerRow>
            ))}
          </LedgerBody>
          <p data-rendered={items.length} data-slot="output">
            {items.length} of 10,000 rows in the page
          </p>
        </>
      )}
    </Ledger>
  )
}

export {
  Example,
  ledger,
  Ledger,
  LedgerBody,
  LedgerRow,
  type LedgerOptions,
  type LedgerRootState,
  type LedgerRowProps,
}
Scroll through the ledger. The count shows how few of its 10,000 rows are rendered at once.

Call disconnect() and unsubscribe() when the view unmounts. In a primitive, return virtual.connect(el) from the scroller slot's bind. Call virtual.refresh() after the row count or order changes.

items() returns the same array until its contents change, so it works with subscriptions such as React's useSyncExternalStore. Give each row aria-setsize and aria-posinset, or use aria-rowcount and aria-rowindex for a grid, so assistive technology can tell where it sits in the full list.

Rows of Any Height

For rows that wrap, estimate their height first and measure them after rendering. Give each row a stable data key so its measured size follows it when you sort:

const virtual = createVirtualizer({
  count: () => notes.length,
  key: (index) => notes[index].id,
  estimate: () => 52,
  overscan: 2,
})

In the row slot, return the measurement cleanup:

bind: (el, { api, props }) => api.virtual.measure(props.index, el),

measure also observes later size changes. Keep each measurement binding associated with its index; this demo keys the rendered rows by index while the virtualizer tracks sizes by data key.

import * as React from "react"
import {
  createVirtualizer,
  css,
  definePrimitive,
  type VirtualItem,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type Note = { id: string; text: string }

type NotesState = {
  notes: Note[]
  newest: boolean
  large: boolean
  items: VirtualItem[]
  size: number
}

const sentences = [
  "Moved the launch review to Thursday.",
  "Hollis wants the pricing page copy by Friday.",
  "Inรจs shared the onboarding survey; week-two retention is up.",
  "Kofi is pairing on the billing migration.",
  "Wren flagged two broken links in the help center.",
  "Tomasz will draft the release notes.",
  "Ship the dark mode fixes before the freeze.",
]
const notes: Note[] = Array.from({ length: 500 }, (_, index) => ({
  id: `note-${index + 1}`,
  text: `${index + 1}. ${Array.from({ length: 1 + ((index * 5) % 4) }, (_, k) => sentences[(index + k) % sentences.length]).join(" ")}`,
}))

const notebook = definePrimitive({
  name: "notebook",
  state: (): NotesState => ({
    notes,
    newest: false,
    large: false,
    items: [],
    size: 0,
  }),
  api: (context) => {
    const { store } = context
    let order = notes
    const virtual = createVirtualizer({
      count: () => order.length,
      estimate: () => 52,
      key: (index) => order[index].id,
      overscan: 2,
    })
    const sync = () =>
      store.set({ items: virtual.items(), size: virtual.size() })
    context.onDestroy(virtual.subscribe(sync))
    return {
      virtual,
      note: (index: number) => order[index],
      flip() {
        const newest = !store.get().newest
        order = newest ? [...notes].reverse() : notes
        store.set({ newest })
        virtual.refresh()
      },
      enlarge() {
        store.set({ large: !store.get().large })
        virtual.reset()
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, { items: VirtualItem[]; newest: boolean; large: boolean }>({
      state: (state) => ({
        items: state.items,
        newest: state.newest,
        large: state.large,
      }),
    }),
    scroller: slot({
      attrs: () => ({ tabindex: "0", style: css({ overflow: "auto" }) }),
      bind: (el, { api }) => api.virtual.connect(el),
    }),
    body: slot({
      attrs: (state) => ({
        role: "list",
        "data-size": String(state.size),
        style: css({ position: "relative", height: `${state.size}px` }),
      }),
    }),
    row: slot<
      { value: string; index: number; start: number },
      { text: string }
    >({
      props: { value: "string", index: "number", start: "number" },
      state: (_state, { api, props }) => ({
        text: api.note(props.index)?.text ?? "",
      }),
      attrs: (state, { props }) => ({
        role: "listitem",
        "aria-setsize": String(state.notes.length),
        "aria-posinset": String(props.index + 1),
        style: css({
          position: "absolute",
          top: "0",
          left: "0",
          right: "0",
          transform: `translateY(${props.start}px)`,
        }),
      }),
      bind: (el, { api, props }) => api.virtual.measure(props.index, el),
    }),
    action: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({ type: "button" }),
      press: ({ api, key }) => (key === "flip" ? api.flip() : api.enlarge()),
    }),
  }),
})

const parts = createParts(notebook, {
  root: "div",
  scroller: "div",
  body: "div",
  row: "div",
  action: "button",
})

const Notebook = parts.Root

const NotebookScroller = parts.Scroller

const NotebookBody = parts.Body

const NotebookRow = parts.Row

const NotebookAction = parts.Action

function Example() {
  return (
    <Notebook>
      {({ items, newest, large }) => (
        <div data-slot="stack">
          <div data-slot="controls">
            <NotebookAction data-size="sm" value="flip">
              {newest ? "Oldest First" : "Newest First"}
            </NotebookAction>
            <NotebookAction data-size="sm" value="enlarge">
              {large ? "Smaller Text" : "Larger Text"}
            </NotebookAction>
          </div>
          <NotebookScroller>
            <NotebookBody
              aria-label="Notes"
              data-large={large ? "" : undefined}
            >
              {items.map((item) => (
                <NotebookRow
                  key={item.index}
                  value={item.key}
                  index={item.index}
                  start={item.start}
                >
                  {({ text }) => text}
                </NotebookRow>
              ))}
            </NotebookBody>
          </NotebookScroller>
        </div>
      )}
    </Notebook>
  )
}

export {
  Example,
  notebook,
  Notebook,
  NotebookAction,
  NotebookBody,
  NotebookRow,
  NotebookScroller,
  type Note,
  type NotesState,
}
Scroll through the notes, then try Newest First and Larger Text. Row positions adjust to their measured heights.

Call virtual.reset() to discard cached measurements after a change that affects every row, such as a new text size. overscan keeps a few extra rows beyond each edge; it defaults to four. Fixed-height rows can skip measurement.

Jumping and Loading More

Use scrollToIndex to bring a row into view. Indexes start at zero:

virtual.scrollToIndex(49, { align: "center" })
virtual.scrollToIndex(79)

align can be "start", "center", "end" or "auto". The default, "auto", scrolls only far enough to show the row.

import * as React from "react"
import {
  createVirtualizer,
  css,
  definePrimitive,
  type VirtualItem,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type ContactsState = {
  count: number
  focused: number | null
  items: VirtualItem[]
  size: number
}

const rowHeight = 32
const headerHeight = 32

const contacts = definePrimitive({
  name: "contacts",
  state: (): ContactsState => ({
    count: 100,
    focused: null,
    items: [],
    size: 0,
  }),
  api: (context) => {
    const { store } = context
    const virtual = createVirtualizer({
      count: () => store.get().count,
      estimate: () => rowHeight,
      scrollMargin: headerHeight,
      keep: () => {
        const { focused } = store.get()
        return focused === null ? [] : [focused]
      },
      onEnd: () => {
        const { count } = store.get()
        if (count < 300) {
          store.set({ count: count + 100 })
          virtual.refresh()
        }
      },
      endThreshold: 5,
    })
    context.onDestroy(
      virtual.subscribe(() =>
        store.set({ items: virtual.items(), size: virtual.size() })
      )
    )
    return {
      virtual,
      focus: (index: number) => store.set({ focused: index }),
      act(action: string) {
        const { count } = store.get()
        if (action === "first") {
          virtual.scrollToIndex(0, { align: "start" })
        } else if (action === "middle") {
          virtual.scrollToIndex(49, { align: "center" })
        } else if (action === "last") {
          virtual.scrollToIndex(count - 1, { align: "end" })
        } else if (action === "eighty") {
          virtual.scrollToIndex(79)
        }
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, { items: VirtualItem[]; count: number }>({
      state: (state) => ({ items: state.items, count: state.count }),
    }),
    scroller: slot({
      attrs: () => ({ style: css({ overflow: "auto" }) }),
      bind: (el, { api }) => api.virtual.connect(el),
    }),
    body: slot({
      attrs: (state) => ({
        role: "list",
        style: css({ position: "relative", height: `${state.size}px` }),
      }),
    }),
    row: slot<{ value: string; index: number; start: number }>({
      props: { value: "string", index: "number", start: "number" },
      attrs: (state, { props }) => ({
        role: "listitem",
        tabindex: "-1",
        "aria-setsize": String(state.count),
        "aria-posinset": String(props.index + 1),
        style: css({
          position: "absolute",
          top: "0",
          left: "0",
          right: "0",
          height: `${rowHeight}px`,
          transform: `translateY(${props.start}px)`,
        }),
      }),
      on: { focus: (_event, { api, props }) => api.focus(props.index) },
    }),
    action: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({ type: "button" }),
      press: ({ api, key }) => api.act(key),
    }),
  }),
})

const parts = createParts(contacts, {
  root: "div",
  scroller: "div",
  body: "div",
  row: "div",
  action: "button",
})

const Contacts = parts.Root

const ContactsScroller = parts.Scroller

const ContactsBody = parts.Body

const ContactsRow = parts.Row

const ContactsAction = parts.Action

const names = ["Hollis", "Inรจs", "Kofi", "Wren", "Tomasz"]

function Example() {
  return (
    <Contacts>
      {({ items, count }) => (
        <div data-slot="stack">
          <div data-slot="controls">
            {[
              ["first", "First"],
              ["middle", "Center 50"],
              ["eighty", "Show 80"],
              ["last", "Last"],
            ].map(([value, text]) => (
              <ContactsAction data-size="sm" key={value} value={value}>
                {text}
              </ContactsAction>
            ))}
          </div>
          <ContactsScroller>
            <div data-slot="virtualizer-header">{count} contacts</div>
            <ContactsBody aria-label="Contacts">
              {items.map((item) => (
                <ContactsRow
                  key={item.key}
                  value={item.key}
                  index={item.index}
                  start={item.start}
                >
                  {names[item.index % names.length]} {item.index + 1}
                </ContactsRow>
              ))}
            </ContactsBody>
          </ContactsScroller>
        </div>
      )}
    </Contacts>
  )
}

export {
  Contacts,
  contacts,
  ContactsAction,
  ContactsBody,
  ContactsRow,
  ContactsScroller,
  type ContactsState,
  Example,
}
Try Center 50 and Last. Near the end, more contacts load. Click a contact and scroll away; its focused element stays rendered.

For a sticky header, set scrollMargin to its height. To load more, provide onEnd and optionally endThreshold:

scrollMargin: 32,
onEnd: () => loadMore(),
endThreshold: 5,
keep: () => (focusedIndex === null ? [] : [focusedIndex]),

onEnd runs once per row count when the visible range approaches the end. After loading, update the count and call refresh(). keep() adds indexes that must stay rendered, such as the row holding focus.

Sideways

For a horizontal list, set horizontal: true:

const virtual = createVirtualizer({
  count: () => 365,
  estimate: () => 56,
  horizontal: true,
})

Now start and size describe horizontal positions. Use a spacer virtual.size() pixels wide and position items with translateX.

import * as React from "react"
import {
  createVirtualizer,
  css,
  definePrimitive,
  type VirtualItem,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type StripState = { items: VirtualItem[]; size: number }

const dayWidth = 56

const strip = definePrimitive({
  name: "strip",
  state: (): StripState => ({ items: [], size: 0 }),
  api: (context) => {
    const virtual = createVirtualizer({
      count: () => 365,
      estimate: () => dayWidth,
      horizontal: true,
    })
    context.onDestroy(
      virtual.subscribe(() =>
        context.store.set({ items: virtual.items(), size: virtual.size() })
      )
    )
    return { virtual }
  },
  slots: (slot) => ({
    root: slot<{}, { items: VirtualItem[] }>({
      state: (state) => ({ items: state.items }),
      attrs: () => ({ tabindex: "0", style: css({ overflow: "auto" }) }),
      bind: (el, { api }) => api.virtual.connect(el),
    }),
    body: slot({
      attrs: (state) => ({
        role: "list",
        style: css({
          position: "relative",
          width: `${state.size}px`,
          height: "100%",
        }),
      }),
    }),
    day: slot<{ value: string; start: number }>({
      props: { value: "string", start: "number" },
      attrs: (_state, { props }) => ({
        role: "listitem",
        style: css({
          position: "absolute",
          top: "0",
          bottom: "0",
          left: "0",
          width: `${dayWidth}px`,
          transform: `translateX(${props.start}px)`,
        }),
      }),
    }),
  }),
})

const parts = createParts(strip, { root: "div", body: "div", day: "div" })

const Strip = parts.Root

const StripBody = parts.Body

const StripDay = parts.Day

const date = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  timeZone: "UTC",
})

function Example() {
  return (
    <Strip>
      {({ items }) => (
        <StripBody aria-label="Days">
          {items.map((item) => (
            <StripDay key={item.key} value={item.key} start={item.start}>
              {date.format(Date.UTC(2026, 0, item.index + 1))}
            </StripDay>
          ))}
        </StripBody>
      )}
    </Strip>
  )
}

export { Example, strip, Strip, StripBody, StripDay, type StripState }
Scroll sideways through the days. Only the visible days and a few nearby ones are rendered.

Find in Page

Find in page (โŒ˜F, or Ctrl+F) sees only the rows a virtualizer renders. Give it the text of each row with findable, and connect with the findable layers, so it finds every row and the list scrolls to the one it matched:

const virtual = createVirtualizer({
  count: () => invoices.length,
  estimate: () => 36,
  findable: (index) => [invoices[index].id, invoices[index].customer],
})

virtual.connect(scroller, { findable: true })

Keep findable the text the row shows, so a row is found the same rendered or not. Pass only the text worth finding, like a title rather than a timestamp.

Then render that text through findableText, one empty element per piece, from its ref:

<span ref={(el) => virtual.findableText(item.index, el, 0)} />
<span ref={(el) => virtual.findableText(item.index, el, 1)} />

A row that leaves items() gives its text back on its own, so frameworks that call a ref with null instead of running what it returned work the same.

The element holds the very text find in page searches, so the browser highlights the match in the row itself, in the row's own styles, and counts it once. part, the last argument, picks the piece when findable returns several.

"use client"

import * as React from "react"
import { createVirtualizer } from "@ui-sdk/core"

const customers = [
  "Acme",
  "Globex",
  "Initech",
  "Umbrella",
  "Hooli",
  "Stark",
  "Wayne",
  "Tyrell",
]

const invoices = Array.from({ length: 10_000 }, (_, index) => ({
  id: `INV-${String(index + 1).padStart(5, "0")}`,
  customer: customers[index % customers.length],
}))

const virtual = createVirtualizer({
  count: () => invoices.length,
  estimate: () => 36,
  findable: (index) => [invoices[index].id, invoices[index].customer],
})

function Example() {
  const items = React.useSyncExternalStore(
    virtual.subscribe,
    virtual.items,
    virtual.items
  )
  const scroller = React.useRef<HTMLDivElement>(null)
  React.useEffect(
    () => virtual.connect(scroller.current!, { findable: true }),
    []
  )

  return (
    <div
      ref={scroller}
      role="list"
      aria-label="Invoices"
      data-slot="virtualizer-scroller"
    >
      <div data-slot="virtualizer-body" style={{ height: virtual.size() }}>
        {items.map((item) => (
          <div
            key={item.key}
            ref={(el) => virtual.measure(item.index, el)}
            role="listitem"
            aria-setsize={invoices.length}
            aria-posinset={item.index + 1}
            data-slot="virtualizer-row"
            style={{ transform: `translateY(${item.start}px)` }}
          >
            <span
              ref={(el) => virtual.findableText(item.index, el, 0)}
              data-slot="virtualizer-id"
            />{" "}
            <span
              ref={(el) => virtual.findableText(item.index, el, 1)}
              data-slot="hint"
            />
          </div>
        ))}
      </div>
    </div>
  )
}

export { Example }
Press โŒ˜F or Ctrl+F and search for Acme. The find bar counts every Acme invoice, rendered or not; press Enter to step through them, and the list scrolls to each one.

The Row It Found

The row find in page matched is found in items() until the next match, until it scrolls away, or until someone presses or types in the list. A row that renders its text without findableText still scrolls into view, but the browser's highlight can't show on it, so mark it yourself:

<div data-found={item.found ? "" : undefined} className="data-found:bg-neutral-100">

How It Works

The findable layers are two elements that take no space, one at the start of the scroller and one at its end. They hold a stand-in for every row that isn't rendered: an element with just the row's text, hidden="until-found", which find in page can search and nothing renders.

  1. Order: find in page steps through matches in page order, so the stand-ins of the rows above the ones in view sit in the first layer, and the rest in the last. Rendered rows have no stand-in in the page, so their text is found once, where it shows.
  2. A match fires the stand-in's beforematch before the browser scrolls to it. The list scrolls to the row first and renders it, with the stand-in lying clear over the row, so the browser's own scroll has nothing to do.
  3. On the next frame, the matched text moves into the row's findableText elements, so the browser's highlight shows there and the find bar's Enter continues with the rows after it. A row without them takes the clear stand-in instead, and the find bar counts it twice until it scrolls away.
  4. Groups of stand-ins are hidden until found too, so the browser skips style and layout for all of them until one matches.

Each row costs a stand-in and a text node per piece, with a space between pieces: two DOM nodes for a single string, four for two pieces. They cost almost no layout, since the browser lays out only the groups. Call virtual.refresh() after the rows change: it updates only the text that changed, in place, and rebuilds the stand-ins only when the count changes.

The stand-ins' code loads the first time a list connects with findable, so lists without it don't download it.

Browsers without hidden="until-found" don't get the layers, and find in page sees only the rows on screen, as without findable; supportsHiddenUntilFound() tells you. Closed tabs, accordions, collapsibles, trees and navigation menus are findable the same way, through their hiddenUntilFound option or findableText, and a list inside a closed panel opens the panel first.

Options

count() => number
How many rows the list has. Read on every update, so it can grow, like a list that loads more.
Example: Render What's in View
estimate(index: number) => number
The size of a row before it's measured, in pixels. A close guess keeps the scrollbar steady.
Example: Render What's in View
key?((index: number) => string)
A stable key for each row, so a measured size follows its row when the list sorts or filters. Defaults to the index.
Example: Rows of Any Height
overscan?number
How many rows to render past each edge, so a fast scroll or Tab doesn't show a gap.
Example: Rows of Any Height
horizontal?boolean
Whether the list scrolls sideways.
Example: Sideways
scrollMargin?number
Pixels in the scroller before the first row, like a sticky header.
Example: Jumping and Loading More
keep?(() => readonly number[])
Rows rendered wherever the scroller is, like the one with focus, so scrolling it away doesn't drop focus to the page.
Example: Jumping and Loading More
onEnd?(() => void)
Runs when the rendered rows come within endThreshold rows of the last, to load more. Runs once per count, so it doesn't ask again before the list grows.
Example: Jumping and Loading More
endThreshold?number
How close to the end onEnd runs, in rows.
Example: Jumping and Loading More
findable?((index: number) => string | readonly string[])
The text the browser's find in page should find in a row, like its title, so it finds rows that aren't rendered too. Keep it what the row shows. Return a list of pieces to give each its own findableText.
Example: Find in Page

Logs That Follow Their End

Follow the Newest Line

createStickToBottom follows new content while the reader is at the bottom. Scrolling up lets them read without being pulled away:

import { createStickToBottom } from "@ui-sdk/core"

const stick = createStickToBottom()
const disconnect = stick.connect(scroller)

The helper watches content size changes, including text that arrives a word at a time. Call disconnect() on unmount, or return stick.connect(el) from a slot's bind.

import * as React from "react"
import {
  createStickToBottom,
  css,
  definePrimitive,
  type StickToBottomState,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type BuildLogJumpState = {
  away: boolean
  unseen: boolean
}

const buildLog = definePrimitive({
  name: "build-log",
  state: (): StickToBottomState => ({
    following: true,
    unseen: false,
    atBottom: true,
  }),
  api: (context) => {
    const stick = createStickToBottom({
      onChange: (state) => context.store.set(state),
    })
    return { stick }
  },
  slots: (slot) => ({
    root: slot({}),
    log: slot({
      attrs: () => ({
        role: "log",
        tabindex: "0",
        style: css({ overflow: "auto" }),
      }),
      bind: (el, { api }) => api.stick.connect(el),
    }),
    jump: slot<{}, BuildLogJumpState>({
      state: (state) => ({
        away: !state.following && !state.atBottom,
        unseen: state.unseen,
      }),
      attrs: (state) => ({
        type: "button",
        hidden: !state.following && !state.atBottom ? undefined : "",
        "data-unseen": state.unseen ? "" : undefined,
      }),
      press: ({ api }) => api.stick.scrollToBottom({ behavior: "smooth" }),
    }),
  }),
})

const parts = createParts(buildLog, { root: "div", log: "div", jump: "button" })

const BuildLog = parts.Root

const BuildLogLines = parts.Log

const BuildLogJump = parts.Jump

const steps = [
  "Resolving packages",
  "Compiling",
  "Bundling",
  "Checking types",
  "Running tests",
]

function Example() {
  const [lines, setLines] = React.useState<string[]>([])
  React.useEffect(() => {
    const timer = setInterval(() => {
      setLines((current) => {
        if (current.length >= 80) {
          clearInterval(timer)
          return [...current, "Done in 24s."]
        }
        return [
          ...current,
          `${steps[current.length % steps.length]} (${current.length + 1}/80)`,
        ]
      })
    }, 250)
    return () => clearInterval(timer)
  }, [])
  return (
    <BuildLog>
      <BuildLogLines aria-label="Build output">
        {lines.map((line, index) => (
          <div key={index}>{line}</div>
        ))}
      </BuildLogLines>
      <BuildLogJump data-size="sm">Jump to latest</BuildLogJump>
    </BuildLog>
  )
}

export {
  buildLog,
  BuildLog,
  BuildLogJump,
  BuildLogLines,
  Example,
  type BuildLogJumpState,
}
While new lines arrive, scroll up to pause following. Jump to latest takes you back to the end.

Use stick.get() to read whether the log is following, currently atBottom, or has unseen content below. stick.subscribe(listener) reports changes and returns a cleanup. These values can drive a jump button:

stick.scrollToBottom({ behavior: "smooth" })

Give the content role="log" when new entries should be announced by a screen reader.

Older Messages and Letting Go

To open a conversation at its current position instead of jumping to the end, use initial: false:

const stick = createStickToBottom({ initial: false, threshold: 48 })

threshold is the distance in pixels that still counts as being at the bottom. Before adding older messages above the current ones, call prepend() to preserve the reader's position:

stick.prepend()
addOlderMessages()
Console
import * as React from "react"
import {
  createStickToBottom,
  css,
  definePrimitive,
  type StickToBottomState,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type ThreadOptions = { onStickChange?: (state: StickToBottomState) => void }

type ThreadState = StickToBottomState & { messages: string[]; older: number }

const thread = definePrimitive({
  name: "thread",
  options: { onStickChange: "callback" },
  state: (_options: ThreadOptions): ThreadState => ({
    messages: Array.from({ length: 12 }, (_, index) => `Message ${index + 1}`),
    older: 0,
    following: false,
    atBottom: false,
    unseen: false,
  }),
  api: (context) => {
    const { store } = context
    const stick = createStickToBottom({ initial: false, threshold: 48 })
    context.onDestroy(
      stick.subscribe(() => {
        store.set(stick.get())
        context.options().onStickChange?.(stick.get())
      })
    )
    return {
      stick,
      act(action: string) {
        const { messages, older } = store.get()
        if (action === "send") {
          store.set({
            messages: [...messages, `Message ${messages.length + 1 - older}`],
          })
        } else if (action === "older") {
          stick.prepend()
          store.set({
            older: older + 5,
            messages: [
              ...Array.from(
                { length: 5 },
                (_, index) => `Older ${older + 5 - index}`
              ),
              ...messages,
            ],
          })
        } else if (action === "release") {
          stick.release()
        }
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, { messages: string[] }>({
      state: (state) => ({ messages: state.messages }),
    }),
    log: slot({
      attrs: () => ({
        role: "log",
        tabindex: "0",
        style: css({ overflow: "auto" }),
      }),
      bind: (el, { api }) => api.stick.connect(el),
    }),
    action: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({ type: "button" }),
      press: ({ api, key }) => api.act(key),
    }),
  }),
})

const parts = createParts(thread, { root: "div", log: "div", action: "button" })

const Thread = parts.Root

const ThreadLog = parts.Log

const ThreadAction = parts.Action

function Example() {
  const log = useLog()
  const last = React.useRef("")
  const report = ({ following, atBottom, unseen }: StickToBottomState) => {
    const text = `${following ? "Following" : "Not following"}${atBottom ? ", at the end" : ""}${unseen ? ", new below" : ""}`
    if (text !== last.current) {
      log(text)
    }
    last.current = text
  }
  return (
    <Thread onStickChange={report}>
      {({ messages }) => (
        <div data-slot="stack">
          <div data-slot="controls">
            {[
              ["send", "Send"],
              ["older", "Load Older"],
              ["release", "Let Go"],
            ].map(([value, text]) => (
              <ThreadAction data-size="sm" key={value} value={value}>
                {text}
              </ThreadAction>
            ))}
          </div>
          <ThreadLog aria-label="Thread">
            {messages.map((message) => (
              <div key={message} data-slot="stick-message">
                {message}
              </div>
            ))}
          </ThreadLog>
        </div>
      )}
    </Thread>
  )
}

export {
  Example,
  thread,
  Thread,
  ThreadAction,
  ThreadLog,
  type ThreadOptions,
  type ThreadState,
}
Scroll to the end and Send a message. Load Older adds messages above without moving your view. Let Go stops following new messages. The console logs whether the thread is following.

stick.release() stops following from code. Scrolling back to the end or calling scrollToBottom() resumes it. The helper manages scroll anchoring while connected so the browser doesn't apply a second adjustment.

Options

threshold?number
How close to the end, in pixels, still counts as at the bottom.
Example: Older Messages and Letting Go
initial?boolean
Whether it follows from the start.
Example: Older Messages and Letting Go
onChange?((state: StickToBottomState) => void)
Runs when get() changes.
Example: Follow the Newest Line

Wheels You Scroll to Pick From

Pick by Scrolling

Import bindScrollPicker from @ui-sdk/core. It turns a scrolling listbox into a wheel, like the hour and minute drums of a time picker: the option at the center is the value. Bind it to a Listbox once the list is in the page, and select the option it reports. It returns a cleanup for when the list leaves:

const list = React.useRef<HTMLDivElement>(null)
const select = React.useEffectEvent((index: number) => setHour(hours[index]))

React.useLayoutEffect(
  () => bindScrollPicker(list.current!, { onSelect: (index) => select(index) }),
  []
)

<Listbox ref={list} keepInView={false} value={hour} onValueChange={setHour}>

index counts every option in the list, disabled ones included, so it matches your array. The picker selects nothing itself, so onSelect is where a read-only form or a check of your own decides.

The list stays a listbox with one selection. Scrolling, a flick on a touch screen and a drag with the mouse select the option at the center as it passes, and the list settles on the nearest enabled option once it stops. When the listbox selects an option itself, from a click, an arrow key, Home, End or typing its name, the picker scrolls there smoothly without selecting the options in between. Page Up and Page Down move a screenful of options.

Set the listbox's keepInView option to false, so it doesn't jump to an option the wheel is about to glide to. Give the list a height, scroll-snap-type and room at both ends for the first and last options to reach the center, and give each option scroll-snap-align: center:

[role="listbox"] {
  height: 180px;
  padding-block: 72px;
  overflow-y: auto;
  scroll-snap-type: y mandatory;
}

[role="option"] {
  height: 36px;
  scroll-snap-align: center;
}

Snapping is the browser's own, so the list snaps before your JavaScript loads. Options can be any size: the picker measures them, and measures again when they or the list resize.

Draw a Wheel in CSS

Each option gets --picker-offset, its distance from the center in options: 0 at the center, 1 for the next option, -2 two before it. It's fractional while the list moves between options and updates every frame, so CSS can draw the wheel with no code of your own:

[role="option"] {
  transform: rotateX(calc(var(--picker-offset, 0) * -24deg));
  opacity: calc(1 - abs(var(--picker-offset, 0)) * 0.25);
}

The fallback of 0 covers the server's HTML, before the picker binds. Give the list perspective so the rotation looks round, and fade its ends with a mask-image:

Console
import * as React from "react"
import { bindScrollPicker } from "@ui-sdk/core"
import { Listbox, ListboxItem } from "@ui-sdk/react/listbox"
import { useLog } from "./log"

const hours = Array.from({ length: 12 }, (_, index) => String(index + 1))

const minutes = Array.from({ length: 12 }, (_, index) =>
  String(index * 5).padStart(2, "0")
)

function Wheel({
  label,
  options,
  value,
  onValueChange,
}: {
  label: string
  options: string[]
  value: string
  onValueChange: (value: string) => void
}) {
  const list = React.useRef<HTMLDivElement>(null)
  const select = React.useEffectEvent((index: number) =>
    onValueChange(options[index])
  )

  React.useLayoutEffect(
    () =>
      bindScrollPicker(list.current!, { onSelect: (index) => select(index) }),
    []
  )

  return (
    <div data-slot="wheel">
      <Listbox
        ref={list}
        aria-label={label}
        keepInView={false}
        value={value}
        onValueChange={(next) => next && onValueChange(next)}
      >
        {options.map((option) => (
          <ListboxItem key={option} value={option}>
            {option}
          </ListboxItem>
        ))}
      </Listbox>
    </div>
  )
}

function Example() {
  const log = useLog()
  const [hour, setHour] = React.useState("9")
  const [minute, setMinute] = React.useState("30")
  return (
    <div data-slot="wheels">
      <Wheel
        label="Hour"
        options={hours}
        value={hour}
        onValueChange={(next) => {
          setHour(next)
          log(`${next}:${minute}`)
        }}
      />
      <Wheel
        label="Minute"
        options={minutes}
        value={minute}
        onValueChange={(next) => {
          setMinute(next)
          log(`${hour}:${next}`)
        }}
      />
    </div>
  )
}

export { Example }
Scroll or drag a wheel, or flick it on a touch screen. Click an hour, or focus a wheel and use Up, Down, Home and End. The console logs the time.

With reduced motion, the wheel jumps to an option instead of gliding there.

Sideways

For a horizontal wheel, like a strip of page numbers, set the listbox's orientation to "horizontal". The picker reads it from aria-orientation, so it scrolls and measures sideways, and in right-to-left text the strip runs from the right:

Console
import * as React from "react"
import { bindScrollPicker } from "@ui-sdk/core"
import { Listbox, ListboxItem } from "@ui-sdk/react/listbox"
import { useLog } from "./log"

const pages = Array.from({ length: 40 }, (_, index) => String(index + 1))

function Example() {
  const log = useLog()
  const list = React.useRef<HTMLDivElement>(null)
  const [page, setPage] = React.useState("1")
  const choose = (next: string) => {
    setPage(next)
    log(`Page ${next}`)
  }
  const select = React.useEffectEvent((index: number) => choose(pages[index]))

  React.useLayoutEffect(
    () =>
      bindScrollPicker(list.current!, { onSelect: (index) => select(index) }),
    []
  )

  return (
    <div data-slot="thumbwheel">
      <Listbox
        ref={list}
        aria-label="Page"
        orientation="horizontal"
        keepInView={false}
        value={page}
        onValueChange={(next) => next && choose(next)}
      >
        {pages.map((option) => (
          <ListboxItem key={option} value={option}>
            {option}
          </ListboxItem>
        ))}
      </Listbox>
    </div>
  )
}

export { Example }
Scroll or drag the strip, or focus it and use Left and Right. The page in the middle is the current one, and the console logs it.

Options

orientation?"horizontal" | "vertical"
Which way the list scrolls. A horizontal list runs from the right in right-to-left text.
Example: Sideways
onSelect?((index: number, details: ScrollPickerSelectDetails) => void)
Runs when someone's scroll, drag, or Page Up or Page Down brings another enabled option to the center, with its index among all the options. Select it here. Clicks and arrow keys select through the list itself.
Example: Pick by Scrolling

Editing in Place

Rename Where It Shows

createInlineEdit manages the editing session. Your state holds the item's id, or null when nothing is being edited. Your commit callback saves the new text:

import { createInlineEdit } from "@ui-sdk/core"

const edit = createInlineEdit(context, {
  key: "editing",
  value: (id) => nameOf(id),
  commit: (id, text, details) => {
    if (!text.trim()) {
      return false
    }
    return rename(id, text, details)
  },
})

Returning false keeps the editor open. Unchanged text finishes editing without calling commit.

import * as React from "react"
import {
  createChange,
  createInlineEdit,
  createRovingFocus,
  definePrimitive,
  selects,
  type ChangeDetails,
  type InlineEditReason,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type Playlist = { id: string; name: string }

type PlaylistsState = {
  items: Playlist[]
  editing: string | null
  focused: string | null
}

type PlaylistsOptions = {
  defaultItems: Playlist[]
  onItemsChange?: (
    items: Playlist[],
    details: ChangeDetails<InlineEditReason>
  ) => void
}

type PlaylistsItemState = { name: string; editing: boolean }

const playlists = definePrimitive({
  name: "playlists",
  options: { defaultItems: "value", onItemsChange: "callback" },
  state: (options: PlaylistsOptions): PlaylistsState => ({
    items: options.defaultItems,
    editing: null,
    focused: null,
  }),
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    const roving = createRovingFocus<PlaylistsState, { value: string }>(
      context,
      {
        slot: "item",
        key: "focused",
      }
    )
    const edit = createInlineEdit<PlaylistsState>(context, {
      key: "editing",
      value: (id) =>
        store.get().items.find((item) => item.id === id)?.name ?? "",
      commit: (id, text, details) =>
        !!text.trim() &&
        change(
          "items",
          store
            .get()
            .items.map((item) =>
              item.id === id ? { ...item, name: text.trim() } : item
            ),
          details
        ),
    })
    return { roving, edit }
  },
  slots: (slot) => ({
    root: slot({ attrs: () => ({ role: "list" }) }),
    item: slot<{ value: string }, PlaylistsItemState>({
      props: { value: "string" },
      selects: selects({ focused: ({ key }) => [key, null] }),
      state: (state, { key }) => ({
        name: state.items.find((item) => item.id === key)?.name ?? "",
        editing: state.editing === key,
      }),
      attrs: (state, part) => ({
        role: "listitem",
        tabindex: part.api.roving.tabIndex(state, part),
      }),
      on: {
        keydown: (event, { el, api, key }) => {
          if (event.target !== el || api.edit.keydown(event, key)) {
            return
          }
          api.roving.keydown(event, el!)
        },
        dblclick: (event, { api, key }) => api.edit.dblclick(event, key),
      },
      bind: (el, part) => part.api.roving.bind(el, part),
    }),
    input: slot({
      attrs: () => ({ "aria-label": "Playlist name" }),
      on: {
        keydown: (event, { api }) => api.edit.inputKeydown(event),
        focusout: (event, { api }) => api.edit.end("focus-out", event),
      },
      bind: (el, { api }) => api.edit.bindInput(el as HTMLInputElement),
    }),
  }),
})

const parts = createParts(playlists, {
  root: "div",
  item: "div",
  input: "input",
})

const Playlists = parts.Root

const PlaylistsItem = parts.Item

const PlaylistsInput = parts.Input

const mixes = [
  { id: "a", name: "Morning run" },
  { id: "b", name: "Deep focus" },
  { id: "c", name: "Road trip" },
  { id: "d", name: "Rainy Sunday" },
]

function Example() {
  return (
    <Playlists aria-label="Playlists" defaultItems={mixes}>
      {mixes.map((mix) => (
        <PlaylistsItem key={mix.id} value={mix.id}>
          {({ name, editing }) => (editing ? <PlaylistsInput /> : name)}
        </PlaylistsItem>
      ))}
    </Playlists>
  )
}

export {
  Example,
  playlists,
  Playlists,
  PlaylistsInput,
  PlaylistsItem,
  type Playlist,
  type PlaylistsItemState,
  type PlaylistsOptions,
  type PlaylistsState,
}
Focus a playlist and press Enter or F2, or double-click it. Enter saves the name; Escape cancels. Typing a letter starts a replacement.

On the item, pass keydown to edit.keydown(event, key) and dblclick to edit.dblclick(event, key). Render an input for the id returned by edit.editing(state), then connect it:

input: slot({
  on: {
    keydown: (event, { api }) => api.edit.inputKeydown(event),
    focusout: (event, { api }) => api.edit.end("focus-out", event),
  },
  bind: (el, { api }) => api.edit.bindInput(el as HTMLInputElement),
}),

bindInput fills and focuses the input. Enter saves; Escape cancels. The focusout handler saves when focus leaves. After editing ends, the helper restores focus if removing the input would otherwise lose it.

Moving on and Starting from Code

Tab also saves, but your component decides where focus goes next. inputKeydown returns true when it handled the key successfully:

if (event.key === "Tab" && edit.inputKeydown(event)) {
  nextField.focus()
}

Keep validation in commit. Use disabled(id) for values that can't be edited, and edit.start(id) to begin from a separate button or menu. If that button or menu is gone when editing ends, focus goes to the closest focusable element around the input instead, such as its row.

import * as React from "react"
import { createChange, createInlineEdit, definePrimitive } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type Product = { name: string; price: string; sku: string }

type ProductState = { product: Product; editing: string | null }

const order = ["name", "price", "sku"] as const

const product = definePrimitive({
  name: "product",
  state: (): ProductState => ({
    product: { name: "Desk lamp", price: "49", sku: "LMP-204" },
    editing: null,
  }),
  api: (context) => {
    const { store } = context
    const change = createChange(context)
    const fields = new Map<string, HTMLElement>()
    const edit = createInlineEdit<ProductState>(context, {
      key: "editing",
      value: (id) => store.get().product[id as keyof Product],
      commit: (id, text, details) => {
        if (id === "price" && !Number.isFinite(Number(text))) {
          return false
        }
        return change(
          "product",
          { ...store.get().product, [id]: text.trim() },
          details
        )
      },
      disabled: (id) => id === "sku",
    })
    return {
      edit,
      bindField(el: HTMLElement, id: string) {
        fields.set(id, el)
        return () => void fields.delete(id)
      },
      inputKeydown(event: KeyboardEvent) {
        const id = store.get().editing
        if (event.key === "Tab" && id && edit.inputKeydown(event)) {
          const next =
            order[
              order.indexOf(id as (typeof order)[number]) +
                (event.shiftKey ? -1 : 1)
            ]
          if (next) {
            fields.get(next)?.focus()
          }
          return
        }
        edit.inputKeydown(event)
      },
    }
  },
  slots: (slot) => ({
    root: slot({}),
    list: slot({ attrs: () => ({ role: "list" }) }),
    field: slot<{ value: string }, { text: string; editing: boolean }>({
      props: { value: "string" },
      state: (state, { key }) => ({
        text: state.product[key as keyof Product],
        editing: state.editing === key,
      }),
      attrs: (_state, { key }) => ({
        role: "listitem",
        tabindex: "0",
        "aria-readonly": key === "sku" ? "true" : undefined,
      }),
      on: {
        keydown: (event, { el, api, key }) =>
          event.target === el && api.edit.keydown(event, key),
        dblclick: (event, { api, key }) => api.edit.dblclick(event, key),
      },
      bind: (el, { api, key }) => api.bindField(el, key),
    }),
    input: slot({
      attrs: () => ({ "aria-label": "Value" }),
      on: {
        keydown: (event, { api }) => api.inputKeydown(event),
        focusout: (event, { api }) => api.edit.end("focus-out", event),
      },
      bind: (el, { api }) => api.edit.bindInput(el as HTMLInputElement),
    }),
    rename: slot({
      attrs: () => ({ type: "button" }),
      press: ({ api }) => api.edit.start("name"),
    }),
  }),
})

const parts = createParts(product, {
  root: "div",
  list: "div",
  field: "div",
  input: "input",
  rename: "button",
})

const ProductDetails = parts.Root

const ProductList = parts.List

const ProductField = parts.Field

const ProductInput = parts.Input

const ProductRename = parts.Rename

const labels: Record<string, string> = {
  name: "Name",
  price: "Price",
  sku: "SKU",
}

function Example() {
  return (
    <ProductDetails>
      <div data-slot="stack">
        <div data-slot="controls">
          <ProductRename data-size="sm">Rename</ProductRename>
        </div>
        <ProductList aria-label="Product">
          {order.map((id) => (
            <ProductField key={id} value={id} aria-label={labels[id]}>
              {({ text, editing }) => (
                <>
                  <span data-slot="inline-edit-label">{labels[id]}</span>
                  {editing ? (
                    <ProductInput />
                  ) : (
                    <span data-value>{id === "price" ? `$${text}` : text}</span>
                  )}
                </>
              )}
            </ProductField>
          ))}
        </ProductList>
      </div>
    </ProductDetails>
  )
}

export {
  Example,
  product,
  ProductDetails,
  ProductField,
  ProductInput,
  ProductList,
  ProductRename,
  type Product,
  type ProductState,
}
Edit a product value and press Tab to save and move on. Price requires a number, SKU stays read-only, and Rename starts editing the name.

Options

keykeyof State & string
The state key that holds the id being edited, or null.
Example: Rename Where It Shows
value(id: string) => string
The text an id shows now, put in the input when editing starts.
Example: Rename Where It Shows
commit(id: string, text: string, details: { reason: InlineEditReason; event?: Event; }) => boolean
Saves the text and returns whether it went through. Return false to keep editing, like when the text isn't valid.
Example: Rename Where It Shows
disabled?((id: string) => boolean)
Whether an id can't be edited.
Example: Moving on and Starting from Code