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.
Options You Search
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:
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,
}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 pointsaria-activedescendantatactiveId(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
querynarrows 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,
}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,
}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,
}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 }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 }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.
- 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.
- A match fires the stand-in's
beforematchbefore 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. - On the next frame, the matched text moves into the row's
findableTextelements, 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. - 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
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
endThresholdrows of the last, to load more. Runs once percount, so it doesn't ask again before the list grows. - 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,
}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()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,
}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
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:
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 }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:
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 }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,
}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,
}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
falseto 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