Handle Changes
Report changes, let the app accept or cancel them, and choose how state updates reach the page.
Compose Behaviors used createChange to report each new rating. This page goes further: callbacks that accept or cancel a change, state a parent controls, commits, frequent updates and undo. These helpers are exported from @ui-sdk/core.
Control the State
The mute button from Build a Component manages its own state. Let's control it from a parent component and use that state to change its icon and label.
We'll use createChange. Update lib/mute-button.ts with the highlighted changes:
import { createChange, definePrimitive } from "@ui-sdk/core"
const muteButton = definePrimitive({
name: "mute-button",
options: { muted: "boolean", onMutedChange: "callback" },
controlled: ["muted"],
state: (options: {
muted?: boolean
onMutedChange?: (muted: boolean) => void
}) => ({
muted: options.muted ?? false,
}),
sync: (options) =>
options.muted === undefined ? {} : { muted: options.muted },
api: (context) => {
const change = createChange(context)
return {
toggle: (event?: Event) => {
change("muted", !context.store.get().muted, { reason: "press", event })
},
}
},
slots: (slot) => ({
root: slot({
attrs: () => ({ type: "button" }),
press: ({ api }, event) => api.toggle(event),
}),
}),
})
export { muteButton }optionsadds the value and callback.controlledmarksmutedas a state value that can be controlled.statesets the initial value.synckeeps it up to date when a newmutedvalue comes in.createChangecallsonMutedChangewith the next value and waits for it to come back throughmuted. If you leave outmuted, it updates the button's own state.
The label now describes the next action, so this version doesn't need aria-pressed.
Connect the State
Now use muted for the button, its icon and its label:
"use client"
import * as React from "react"
import { Volume2Icon, VolumeXIcon } from "lucide-react"
import { MuteButton } from "@/components/ui/mute-button"
function Example() {
const [muted, setMuted] = React.useState(false)
return (
<MuteButton muted={muted} onMutedChange={setMuted}>
{muted ? (
<VolumeXIcon aria-hidden="true" />
) : (
<Volume2Icon aria-hidden="true" />
)}
{muted ? "Unmute" : "Mute"}
</MuteButton>
)
}
export { Example }import * as React from "react"
import { Volume2Icon, VolumeOffIcon } from "lucide-react"
import { createChange, definePrimitive } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
const muteButton = definePrimitive({
name: "mute-button",
options: { muted: "boolean", onMutedChange: "callback" },
controlled: ["muted"],
state: (options: {
muted?: boolean
onMutedChange?: (muted: boolean) => void
}) => ({
muted: options.muted ?? false,
}),
sync: (options) =>
options.muted === undefined ? {} : { muted: options.muted },
api: (context) => {
const change = createChange(context)
return {
toggle: (event?: Event) => {
change("muted", !context.store.get().muted, { reason: "press", event })
},
}
},
slots: (slot) => ({
root: slot({
attrs: (state) => ({
type: "button",
"aria-pressed": String(state.muted),
}),
press: ({ api }, event) => api.toggle(event),
}),
}),
})
const { Root: MuteButton } = createParts(muteButton, { root: "button" })
function Example() {
const [muted, setMuted] = React.useState(false)
return (
<MuteButton muted={muted} onMutedChange={setMuted}>
{muted ? (
<VolumeOffIcon aria-hidden="true" />
) : (
<Volume2Icon aria-hidden="true" />
)}
{muted ? "Unmute" : "Mute"}
</MuteButton>
)
}
export { Example }Pressing Mute updates muted in the parent component. The button receives the new value, and the icon and label update with it.
Changes
This button needs two presses to confirm. The first arms it; a timer disarms it if you don't press again.
import { createChange, definePrimitive, type ChangeDetails } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"
type ConfirmButtonOptions = {
onConfirm?: () => void
onArmedChange?: (
armed: boolean,
details: ChangeDetails<"press" | "timeout">
) => void
timeout?: number
}
type ConfirmButtonState = { armed: boolean }
const confirmButton = definePrimitive({
name: "confirm-button",
options: {
onConfirm: "callback",
onArmedChange: "callback",
timeout: "number",
},
state: (_options: ConfirmButtonOptions): ConfirmButtonState => ({
armed: false,
}),
api: (context) => {
const { store, options, onDestroy } = context
const change = createChange(context)
let timer: ReturnType<typeof setTimeout> | undefined
onDestroy(() => clearTimeout(timer))
return {
press(event?: Event) {
clearTimeout(timer)
if (store.get().armed) {
change("armed", false, { reason: "press", event })
options().onConfirm?.()
} else if (change("armed", true, { reason: "press", event })) {
timer = setTimeout(
() => change("armed", false, { reason: "timeout" }),
options().timeout ?? 3000
)
}
},
}
},
slots: (slot) => ({
root: slot<{}, ConfirmButtonState>({
state: (state) => ({ armed: state.armed }),
attrs: (state) => ({
type: "button",
"data-armed": state.armed ? "" : undefined,
}),
press: ({ api }, event) => api.press(event),
}),
}),
})
const parts = createParts(confirmButton, { root: "button" })
const ConfirmButton = parts.Root
function Example() {
const log = useLog()
return (
<ConfirmButton onConfirm={() => log("Deleted")}>
{({ armed }) => (armed ? "Press Again to Delete" : "Delete")}
</ConfirmButton>
)
}
export {
confirmButton,
ConfirmButton,
Example,
type ConfirmButtonOptions,
type ConfirmButtonState,
}Create change once in api, then call it from a method when an interaction happens:
api: (context) => {
const change = createChange(context)
return {
arm: (event?: Event) => change("armed", true, { reason: "press", event }),
}
},Calling arm asks for armed: true. It calls onArmedChange first, then writes the value if the callback allows it and the app isn't controlling armed.
The return value tells you whether the request was accepted. It's false if the value was already the same or the callback canceled it. For controlled state, true means the request was accepted; the app still needs to pass the value back.
Use context.store.set for internal bookkeeping that needs no callback, such as the item with the tab stop:
context.store.set({ pending: true })What a Change Reports
Try a click, a keyboard press, and the timeout. The callback can tell them apart. Turn on Lock to refuse changes.
import * as React from "react"
import { useLog } from "./log"
import { ConfirmButton } from "./confirm-button"
function Example() {
const log = useLog()
const [locked, setLocked] = React.useState(false)
return (
<div data-slot="stack">
<div data-slot="controls">
<label data-slot="label">
<input
type="checkbox"
checked={locked}
onChange={(event) => setLocked(event.target.checked)}
/>
Lock
</label>
</div>
<ConfirmButton
timeout={2000}
onArmedChange={(armed, details) => {
if (locked) {
details.cancel()
}
log(
`${armed ? "Arm" : "Disarm"}: ${details.reason}, from ${details.source}${details.canceled ? ", canceled" : ""}`
)
}}
>
{({ armed }) => (armed ? "Press Again to Delete" : "Delete")}
</ConfirmButton>
</div>
)
}
export { Example }A callback gets the requested value and a details object. For example, the app can refuse to arm the button:
onArmedChange: (armed, details) => {
if (armed && locked) {
details.cancel()
}
},reasonsays what happened, such as"press"or"timeout".eventis the DOM event behind it, when there is one.sourcesays how it happened:"mouse","touch","pen","keyboard","virtual","programmatic"or"agent".cancel()refuses the request;canceledrecords that decision.
A virtual click has no pointer or keyboard press behind it and may come from assistive technology. Agent calls through @ui-sdk/core/agent report "agent". Use the source for things like analytics and focus choices, without changing which actions are available.
Call details.cancel() to refuse an uncontrolled change. Calling event.preventDefault() alone doesn't cancel it. With controlled state, the app decides by passing a new value or keeping the old one.
Reasons
A reason describes the interaction, rather than just saying that something changed. Here, the app lets the Close button work but can refuse Escape.
import * as React from "react"
import {
createChange,
definePrimitive,
isDismissal,
type ChangeDetails,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"
type NoticeOptions = {
defaultOpen?: boolean
onOpenChange?: (
open: boolean,
details: ChangeDetails<"close-press" | "escape-key">
) => void
}
type NoticeState = { open: boolean }
const notice = definePrimitive({
name: "notice",
options: { defaultOpen: "boolean", onOpenChange: "callback" },
state: (options: NoticeOptions): NoticeState => ({
open: options.defaultOpen ?? true,
}),
api: (context) => {
const change = createChange(context)
return {
close: (
details:
| { reason: "close-press"; event?: Event }
| { reason: "escape-key"; event: KeyboardEvent }
) => change("open", false, details),
}
},
slots: (slot) => ({
root: slot<{}>({
present: (state) => state.open,
attrs: () => ({ role: "region" }),
on: {
keydown: (event, { api }) => {
if (event.key === "Escape") {
api.close({ reason: "escape-key", event })
}
},
},
}),
close: slot({
attrs: () => ({ type: "button" }),
press: ({ api }, event) => api.close({ reason: "close-press", event }),
}),
}),
})
const parts = createParts(notice, { root: "div", close: "button" })
const Notice = parts.Root
const NoticeClose = parts.Close
function Example() {
const log = useLog()
const [round, setRound] = React.useState(0)
const [strict, setStrict] = React.useState(false)
return (
<div data-slot="stack">
<div data-slot="controls">
<label data-slot="label">
<input
type="checkbox"
checked={strict}
onChange={(event) => setStrict(event.target.checked)}
/>
Close only with the button
</label>
<button type="button" onClick={() => setRound((count) => count + 1)}>
Show Tip Again
</button>
</div>
<Notice
key={round}
aria-label="Tip"
onOpenChange={(_open, details) => {
if (strict && isDismissal(details.reason)) {
details.cancel()
}
log(`Close: ${details.reason}${details.canceled ? ", canceled" : ""}`)
}}
>
<p>Press Escape here, or use the button, to close this tip.</p>
<NoticeClose data-size="sm">Got It</NoticeClose>
</Notice>
</div>
)
}
export {
Example,
notice,
Notice,
NoticeClose,
type NoticeOptions,
type NoticeState,
}Declare the reasons your callback can receive:
type NoticeOptions = {
onOpenChange?: (
open: boolean,
details: ChangeDetails<"close-press" | "escape-key">
) => void
}Use a shared reason when one fits. ReasonVocabulary lists them, and checking details.reason narrows details.event: "escape-key", for example, gives you a KeyboardEvent. Development builds also warn when an event doesn't match its reason.
Custom reasons work too. A reason outside the vocabulary, such as "validation", has Event as its event type.
isDismissal groups reasons such as Escape, a press outside and a swipe. Use it when the same rule applies to several ways of closing:
onOpenChange: (open, details) => {
if (!open && isDismissal(details.reason) && hasUnsavedChanges) {
details.cancel()
}
},State a User Controls
The checkbox and the panel's trigger below share one value held by the app.
import * as React from "react"
import { createChange, definePrimitive, type ChangeDetails } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type DisclosureOptions = {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (
open: boolean,
details: ChangeDetails<"trigger-press">
) => void
}
type DisclosureState = { open: boolean }
const disclosure = definePrimitive({
name: "disclosure",
options: {
open: "boolean",
defaultOpen: "boolean",
onOpenChange: "callback",
},
state: (options: DisclosureOptions): DisclosureState => ({
open: options.open ?? options.defaultOpen ?? false,
}),
sync: (options) => (options.open === undefined ? {} : { open: options.open }),
api: (context) => {
const change = createChange(context)
return {
toggle: (event?: Event) =>
change("open", !context.store.get().open, {
reason: "trigger-press",
event,
}),
}
},
slots: (slot) => ({
root: slot({}),
trigger: slot({
attrs: (state, { id }) => ({
type: "button",
"aria-expanded": String(state.open),
"aria-controls": `${id}-panel`,
}),
press: ({ api }, event) => api.toggle(event),
}),
panel: slot({
present: (state) => state.open,
attrs: (_state, { id }) => ({ id: `${id}-panel` }),
}),
}),
})
const parts = createParts(disclosure, {
root: "div",
trigger: "button",
panel: "div",
})
const Disclosure = parts.Root
const DisclosureTrigger = parts.Trigger
const DisclosurePanel = parts.Panel
function Example() {
const [open, setOpen] = React.useState(false)
return (
<div data-slot="stack">
<div data-slot="controls">
<label data-slot="label">
<input
type="checkbox"
checked={open}
onChange={(event) => setOpen(event.target.checked)}
/>
Open from outside
</label>
</div>
<Disclosure open={open} onOpenChange={setOpen}>
<DisclosureTrigger>Shipping details</DisclosureTrigger>
<DisclosurePanel>Ships in two days, free over $50.</DisclosurePanel>
</Disclosure>
</div>
)
}
export {
disclosure,
Disclosure,
DisclosurePanel,
DisclosureTrigger,
Example,
type DisclosureOptions,
type DisclosureState,
}Control the State shows how to connect the value, callback and sync. A few details matter when you make this reusable:
- Read
defaultOpenwhen creating the state. Changing that option later doesn't reset the panel. - Return only controlled values from
sync.{}leaves state alone;{ open: undefined }overwrites it. - Keep each key controlled or uncontrolled for the instance's lifetime. Development builds warn about switching, or passing both
openanddefaultOpen. - Those checks cover
open,valueandcheckedby default. Usecontrolled: ["expanded"]to enable them for another key.
Events That Aren't State
Sometimes a callback asks for an action, such as deleting a file. There's no state key named delete to update.
import * as React from "react"
import { definePrimitive, dispatch, type ChangeDetails } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"
type FileListState = { files: string[] }
type FileListOptions = {
defaultFiles: string[]
onDelete?: (file: string, details: ChangeDetails<"delete-press">) => void
}
const fileList = definePrimitive({
name: "file-list",
options: { defaultFiles: "list", onDelete: "callback" },
state: (options: FileListOptions): FileListState => ({
files: options.defaultFiles,
}),
api: (context) => {
const { store } = context
return {
remove(file: string, event?: Event) {
if (
dispatch(context, "onDelete", file, { reason: "delete-press", event })
) {
return
}
store.set({
files: store.get().files.filter((other) => other !== file),
})
},
}
},
slots: (slot) => ({
root: slot<{}, { files: string[] }>({
state: (state) => ({ files: state.files }),
attrs: () => ({ role: "list" }),
}),
file: slot<{ value: string }>({
props: { value: "string" },
attrs: () => ({ role: "listitem" }),
}),
delete: slot<{ value: string }>({
props: { value: "string" },
attrs: (_state, { key }) => ({
type: "button",
"aria-label": `Delete ${key}`,
}),
press: ({ api, key }, event) => api.remove(key, event),
}),
}),
})
const parts = createParts(fileList, {
root: "div",
file: "div",
delete: "button",
})
const FileList = parts.Root
const FileListItem = parts.File
const FileListDelete = parts.Delete
function Example() {
const log = useLog()
return (
<FileList
aria-label="Files"
defaultFiles={["README.md", "notes.txt", "draft.md"]}
onDelete={(file, details) => {
if (file === "README.md") {
details.cancel()
log("Canceled: the readme stays")
} else {
log(`Deleted ${file}`)
}
}}
>
{({ files }) =>
files.map((file) => (
<FileListItem key={file} value={file}>
{file}
<FileListDelete value={file}>Delete</FileListDelete>
</FileListItem>
))
}
</FileList>
)
}
export {
Example,
fileList,
FileList,
FileListDelete,
FileListItem,
type FileListOptions,
type FileListState,
}Use dispatch to call an option with the same details a change receives:
remove(file: string, event?: Event) {
const canceled = dispatch(context, "onDelete", file, { reason: "delete-press", event })
if (canceled) {
return
}
context.store.set({ files: context.store.get().files.filter((name) => name !== file) })
},dispatch returns true when the callback canceled the action. In this example, the app uses it to protect the readme.
Commits
Drag the volume slider. The value changes throughout the drag, but Saved changes when you let go. Each arrow-key step saves immediately.
import * as React from "react"
import {
createChange,
createCommit,
css,
definePrimitive,
type ChangeDetails,
type CommitDetails,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"
type Reason = "track-press" | "step-key"
type VolumeOptions = {
defaultValue?: number
onValueChange?: (value: number, details: ChangeDetails<Reason>) => void
onValueCommit?: (value: number, details: CommitDetails<Reason>) => void
}
type VolumeState = { value: number }
const clamp = (value: number) => Math.round(Math.min(100, Math.max(0, value)))
const volume = definePrimitive({
name: "volume",
options: {
defaultValue: "number",
onValueChange: "callback",
onValueCommit: "callback",
},
state: (options: VolumeOptions): VolumeState => ({
value: options.defaultValue ?? 50,
}),
commits: ["value"],
api: (context) => {
const change = createChange(context)
const commit = createCommit(context)
const valueAt = (event: PointerEvent, track: HTMLElement) => {
const rect = track.getBoundingClientRect()
return clamp(((event.clientX - rect.left) / rect.width) * 100)
}
return {
drag(event: PointerEvent, track: HTMLElement) {
change("value", valueAt(event, track), { reason: "track-press", event })
},
release(event: PointerEvent) {
commit("value", context.store.get().value, {
reason: "track-press",
event,
})
},
step(event: KeyboardEvent) {
const by = { ArrowRight: 5, ArrowUp: 5, ArrowLeft: -5, ArrowDown: -5 }[
event.key
]
if (by === undefined) {
return
}
event.preventDefault()
const value = clamp(context.store.get().value + by)
if (change("value", value, { reason: "step-key", event })) {
commit("value", value, { reason: "step-key", event })
}
},
}
},
slots: (slot) => ({
track: slot({
on: {
pointerdown: (event, { el, api }) => {
if (!el) {
return
}
el.setPointerCapture(event.pointerId)
el.querySelector<HTMLElement>("[role=slider]")?.focus()
api.drag(event, el)
},
pointermove: (event, { el, api }) => {
if (el?.hasPointerCapture(event.pointerId)) {
api.drag(event, el)
}
},
pointerup: (event, { api }) => api.release(event),
},
}),
fill: slot({
attrs: (state) => ({ style: css({ width: `${state.value}%` }) }),
}),
thumb: slot({
attrs: (state) => ({
role: "slider",
tabindex: "0",
"aria-valuemin": "0",
"aria-valuemax": "100",
"aria-valuenow": String(state.value),
style: css({ left: `${state.value}%` }),
}),
on: { keydown: (event, { api }) => api.step(event) },
}),
}),
})
const parts = createParts(volume, { track: "div", fill: "div", thumb: "div" })
const Volume = parts.Root
const VolumeTrack = parts.Track
const VolumeFill = parts.Fill
const VolumeThumb = parts.Thumb
function Example() {
const log = useLog()
const [value, setValue] = React.useState(50)
return (
<div data-slot="stack">
<p aria-hidden="true" data-slot="volume-label">
<span>Volume</span>
<span data-value>{value}</span>
</p>
<Volume
onValueChange={setValue}
onValueCommit={(saved) => log(`Saved ${saved}`)}
>
<VolumeTrack>
<div data-slot="volume-rail">
<VolumeFill />
</div>
<VolumeThumb aria-label="Volume" />
</VolumeTrack>
</Volume>
</div>
)
}
export {
Example,
volume,
Volume,
VolumeFill,
VolumeThumb,
VolumeTrack,
type VolumeOptions,
type VolumeState,
}Use createChange for the ongoing value and createCommit for the end of the interaction. Both are created inside api:
const change = createChange(context)
const commit = createCommit(context)While dragging, report each value:
change("value", value, { reason: "track-press", event })On release, report the final value:
commit("value", context.store.get().value, { reason: "track-press", event })Declare onValueCommit in the options schema and type it with CommitDetails. Add commits: ["value"] to the primitive definition so observe reports one committed change per interaction. A commit reports the value; it doesn't write it and can't be canceled.
The demo positions its thumb with css, which turns declarations into a style attribute and skips undefined values:
style: css({ left: `${state.value}%` }),Live State
Hover over the seats, then choose one. Hover changes often; the selected seat is a separate value.
import * as React from "react"
import { definePrimitive, live, selects } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type SeatsState = { selected: string | null; hovered: string | null }
const seats = definePrimitive({
name: "seats",
state: (): SeatsState => ({ selected: null, hovered: null }),
live: ["hovered"],
api: (context) => ({
choose: (seat: string) => context.store.set({ selected: seat }),
hover: (seat: string | null) => context.store.set({ hovered: seat }),
}),
slots: (slot) => ({
root: slot({ attrs: () => ({ role: "group" }) }),
seat: slot<{ value: string }>({
props: { value: "string" },
selects: selects({ selected: ({ key }) => [key] }),
attrs: (state, part) => ({
type: "button",
"aria-label": `Seat ${part.key}`,
"aria-pressed": String(state.selected === part.key),
}),
live: live((state, part) => ({
"data-hovered": state.hovered === part.key ? "" : undefined,
})),
press: ({ api, key }) => api.choose(key),
on: {
pointerenter: (_event, { api, key }) => api.hover(key),
pointerleave: (_event, { api }) => api.hover(null),
},
}),
label: slot<{}, { seat: string | null }>({
state: (state) => ({ seat: state.hovered ?? state.selected }),
}),
}),
})
const parts = createParts(seats, { root: "div", seat: "button", label: "p" })
const Seats = parts.Root
const Seat = parts.Seat
const SeatsLabel = parts.Label
const rows = ["A", "B", "C", "D"]
function Example() {
return (
<Seats aria-label="Seats">
<div data-slot="seats-grid">
{rows.flatMap((row) =>
Array.from({ length: 8 }, (_, index) => `${row}${index + 1}`).map(
(value) => <Seat key={value} value={value} />
)
)}
</div>
<SeatsLabel data-seat>
{({ seat }) => (seat ? `Seat ${seat}` : "Choose a seat")}
</SeatsLabel>
</Seats>
)
}
export { Example, Seat, seats, Seats, SeatsLabel, type SeatsState }List frequently updated keys in the primitive's live field:
live: ["hovered"],Then use a slot's live field to write attributes directly to its element:
live: live((state, part) => ({
"data-hovered": state.hovered === part.key ? "" : undefined,
})),This updates the hover attribute without rendering the seat again. A render function that explicitly reads a live key still subscribes to it, so a label can show the hovered seat.
selects answers a different question: which parts need to hear about a state change? For the selected seat, each part only compares the value with its own key:
selects: selects({ selected: ({ key }) => [key] }),When selection moves, the old and new seats hear about it. Array values are matched by membership; plain objects by entry. Only list keys read in these ways. A range comparison, such as filling every star up to a rating, needs updates for more than the old and new values.
Attributes Kept Current
Typing in this filter updates each item's hidden attribute without rendering the items again.
import * as React from "react"
import { definePrimitive, direct } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type FilterListState = { query: string }
const filterList = definePrimitive({
name: "filter-list",
state: (): FilterListState => ({ query: "" }),
api: (context) => ({
filter: (query: string) => context.store.set({ query }),
}),
slots: (slot) => ({
input: slot({
attrs: (state) => ({ type: "search", value: state.query }),
on: {
input: (event, { api }) =>
api.filter((event.target as HTMLInputElement).value),
},
}),
list: slot({ attrs: () => ({ role: "list" }) }),
item: slot<{ value: string }>({
props: { value: "string" },
direct: direct(["hidden"]),
attrs: (state, part) => ({
role: "listitem",
hidden: part.key.toLowerCase().includes(state.query.toLowerCase())
? undefined
: "",
}),
}),
}),
})
const parts = createParts(filterList, {
input: "input",
list: "ul",
item: "li",
})
const FilterList = parts.Root
const FilterListInput = parts.Input
const FilterListList = parts.List
const FilterListItem = parts.Item
const countries = [
"Argentina",
"Australia",
"Austria",
"Belgium",
"Brazil",
"Canada",
"Chile",
"Denmark",
]
function Example() {
return (
<FilterList>
<div data-slot="stack">
<FilterListInput
aria-label="Filter countries"
placeholder="Filter countriesโฆ"
/>
<FilterListList>
{countries.map((country) => (
<FilterListItem key={country} value={country}>
{country}
</FilterListItem>
))}
</FilterListList>
</div>
</FilterList>
)
}
export {
Example,
filterList,
FilterList,
FilterListInput,
FilterListItem,
FilterListList,
type FilterListState,
}Use direct when the attribute already belongs in attrs:
item: slot<{ value: string }>({
props: { value: "string" },
direct: direct(["hidden"]),
attrs: (state, part) => ({
hidden: part.key.toLowerCase().includes(state.query.toLowerCase()) ? undefined : "",
}),
}),Keeping hidden in attrs also puts it in server-rendered HTML. Use live for attributes driven by live state, and direct to keep selected attributes from attrs current between renders.
Controllers
A controller lets code outside a component call its API. These buttons start and stop a countdown through one handle.
import * as React from "react"
import {
createChange,
createController,
definePrimitive,
type ChangeDetails,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
function createCountdownController() {
return createController<{
start: (seconds: number) => void
stop: () => void
left: () => number
}>()
}
type CountdownOptions = {
controller?: ReturnType<typeof createCountdownController>
onLeftChange?: (
left: number,
details: ChangeDetails<"imperative" | "timeout">
) => void
}
type CountdownState = { left: number }
const countdown = definePrimitive({
name: "countdown",
options: { controller: "value", onLeftChange: "callback" },
state: (_options: CountdownOptions): CountdownState => ({ left: 0 }),
api: (context) => {
const change = createChange(context)
let timer: ReturnType<typeof setInterval> | undefined
context.onDestroy(() => clearInterval(timer))
return {
start(seconds: number) {
clearInterval(timer)
change("left", seconds, { reason: "imperative" })
timer = setInterval(() => {
const left = context.store.get().left - 1
change("left", left, { reason: "timeout" })
if (left <= 0) {
clearInterval(timer)
}
}, 1000)
},
stop() {
clearInterval(timer)
change("left", 0, { reason: "imperative" })
},
left: () => context.store.get().left,
}
},
slots: (slot) => ({
root: slot<{}, CountdownState>({
state: (state) => ({ left: state.left }),
attrs: () => ({ role: "timer" }),
}),
}),
})
const parts = createParts(countdown, { root: "div" })
const Countdown = parts.Root
function Example() {
const [controller] = React.useState(createCountdownController)
return (
<div data-slot="stack">
<div data-slot="controls">
<button type="button" onClick={() => controller.start(30)}>
Start 30 Seconds
</button>
<button type="button" onClick={() => controller.stop()}>
Stop
</button>
</div>
<Countdown controller={controller} aria-label="Tea">
{({ left }) => `0:${String(left).padStart(2, "0")}`}
</Countdown>
</div>
)
}
export {
countdown,
Countdown,
type CountdownOptions,
type CountdownState,
createCountdownController,
Example,
}Give the handle the methods it should expose:
function createCountdownController() {
return createController<{
start: (seconds: number) => void
stop: () => void
left: () => number
}>()
}The primitive takes a controller option with schema kind "value", and the handle drives whatever its api returns:
return {
start,
stop,
left: () => context.store.get().left,
}Create the handle once and pass it to the component's controller option. controller.start(30) forwards to the attached instance's start method. Before the component attaches it, a method does nothing and returns undefined. The handle detaches when the instance is destroyed.
The methods choose their own change reasons. For example, the countdown's start method calls change("left", seconds, { reason: "imperative" }); each timer tick uses "timeout".
Undo
Paint a few pixels, then undo and redo. The buttons and keyboard shortcuts share one undo stack.
import * as React from "react"
import { createUndoStack, definePrimitive, matchesShortcut } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type PixelsState = {
painted: string[]
canUndo: boolean
canRedo: boolean
}
const pixels = definePrimitive({
name: "pixels",
state: (): PixelsState => ({ painted: [], canUndo: false, canRedo: false }),
api: (context) => {
const { store } = context
const stack = createUndoStack<string[]>([])
const show = (painted: string[] | undefined) => {
if (painted) {
store.set({ painted })
}
store.set({ canUndo: stack.canUndo(), canRedo: stack.canRedo() })
}
const api = {
toggle(cell: string) {
const { painted } = store.get()
const next = painted.includes(cell)
? painted.filter((other) => other !== cell)
: [...painted, cell]
stack.record(next)
show(next)
},
undo: () => show(stack.undo()),
redo: () => show(stack.redo()),
keydown(event: KeyboardEvent) {
if (matchesShortcut(event, "mod+z")) {
api.undo()
} else if (
matchesShortcut(event, "mod+shift+z") ||
matchesShortcut(event, "control+y")
) {
api.redo()
} else {
return
}
event.preventDefault()
},
}
return api
},
slots: (slot) => ({
root: slot({ on: { keydown: (event, { api }) => api.keydown(event) } }),
cell: slot<{ value: string }, { painted: boolean }>({
props: { value: "string" },
state: (state, { key }) => ({ painted: state.painted.includes(key) }),
attrs: (state, { key }) => ({
type: "button",
"aria-pressed": String(state.painted.includes(key)),
"data-painted": state.painted.includes(key) ? "" : undefined,
}),
press: ({ api, key }) => api.toggle(key),
}),
undo: slot({
attrs: (state) => ({
type: "button",
disabled: state.canUndo ? undefined : "",
}),
press: ({ api }) => api.undo(),
}),
redo: slot({
attrs: (state) => ({
type: "button",
disabled: state.canRedo ? undefined : "",
}),
press: ({ api }) => api.redo(),
}),
}),
})
const parts = createParts(pixels, {
root: "div",
cell: "button",
undo: "button",
redo: "button",
})
const Pixels = parts.Root
const PixelsCell = parts.Cell
const PixelsUndo = parts.Undo
const PixelsRedo = parts.Redo
const cells = Array.from(
{ length: 36 },
(_, index) => `${Math.floor(index / 6)}:${index % 6}`
)
function Example() {
return (
<Pixels>
<div data-slot="controls">
<PixelsUndo data-size="sm">Undo</PixelsUndo>
<PixelsRedo data-size="sm">Redo</PixelsRedo>
</div>
<div role="group" aria-label="Pixels" data-slot="undo-grid">
{cells.map((cell) => (
<PixelsCell
key={cell}
value={cell}
aria-label={`Row ${Number(cell[0]) + 1}, column ${Number(cell[2]) + 1}`}
/>
))}
</div>
</Pixels>
)
}
export {
Example,
pixels,
Pixels,
PixelsCell,
PixelsRedo,
PixelsUndo,
type PixelsState,
}createUndoStack stores snapshots of any value. Record the result after each edit:
const stack = createUndoStack(pixels)
stack.record(nextPixels)
const previous = stack.undo()
if (previous !== undefined) {
show(previous)
}undo() and redo() return the value to show, or undefined when there's no step left. They don't update your component's state themselves. canUndo() and canRedo() tell you when to disable the buttons. Recording a new edit after undoing replaces the redo branch.
For keyboard shortcuts, matchesShortcut(event, "mod+z") recognizes Command on Apple platforms and Control elsewhere. Use "mod+shift+z" for redo.
To record every change on a page, including an agent's, and step back through them, see History and Undo.
Undo for Typing
Typing usually feels better when a run of edits takes one undo step. Try typing, pausing, and typing again. Fix replaces โtehโ in every step; Clear starts over.
import * as React from "react"
import { createUndoStack, definePrimitive, matchesShortcut } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type NotesState = { text: string; canUndo: boolean; canRedo: boolean }
const notes = definePrimitive({
name: "notes",
state: (): NotesState => ({ text: "", canUndo: false, canRedo: false }),
api: (context) => {
const { store } = context
const stack = createUndoStack("", { limit: 10, groupDelay: 800 })
const show = (text: string | undefined) => {
if (text !== undefined) {
store.set({ text })
}
store.set({ canUndo: stack.canUndo(), canRedo: stack.canRedo() })
}
return {
type(text: string) {
stack.record(text, { group: "typing" })
show(text)
},
key(event: KeyboardEvent) {
if (matchesShortcut(event, "mod+shift+z")) {
show(stack.redo())
} else if (matchesShortcut(event, "mod+z")) {
show(stack.undo())
} else {
return
}
event.preventDefault()
},
seal: () => stack.seal(),
undo: () => show(stack.undo()),
redo: () => show(stack.redo()),
fix() {
stack.map((text) => text.replaceAll("teh", "the"))
show(store.get().text.replaceAll("teh", "the"))
},
clear() {
stack.reset("")
show("")
},
}
},
slots: (slot) => ({
input: slot({
attrs: (state) => ({ value: state.text }),
on: {
input: (event, { api }) =>
api.type((event.target as HTMLTextAreaElement).value),
keydown: (event, { api }) => api.key(event),
pointerup: (_event, { api }) => api.seal(),
},
}),
undo: slot({
attrs: (state) => ({
type: "button",
disabled: state.canUndo ? undefined : "",
}),
press: ({ api }) => api.undo(),
}),
redo: slot({
attrs: (state) => ({
type: "button",
disabled: state.canRedo ? undefined : "",
}),
press: ({ api }) => api.redo(),
}),
fix: slot({
attrs: () => ({ type: "button" }),
press: ({ api }) => api.fix(),
}),
clear: slot({
attrs: () => ({ type: "button" }),
press: ({ api }) => api.clear(),
}),
}),
})
const parts = createParts(notes, {
input: "textarea",
undo: "button",
redo: "button",
fix: "button",
clear: "button",
})
function Example() {
return (
<parts.Root>
<div data-slot="stack">
<div data-slot="controls">
<parts.Undo>Undo</parts.Undo>
<parts.Redo>Redo</parts.Redo>
<parts.Fix>Fix โtehโ</parts.Fix>
<parts.Clear>Clear</parts.Clear>
</div>
<parts.Input
aria-label="Notes"
rows={3}
placeholder="Type a few words, pause, and type more. Try โtehโ."
/>
</div>
</parts.Root>
)
}
export { Example, notes, type NotesState }const stack = createUndoStack("", { limit: 10, groupDelay: 800 })
stack.record(text, { group: "typing" })
stack.seal()
stack.map((text) => text.replaceAll("teh", "the"))
stack.reset("")Edits in the same group join a step while the gaps stay below groupDelay. Call seal() when that run should end, such as when the caret moves. limit caps the undo steps, map rewrites every snapshot, and reset starts the stack over from a new value.
How People Are Working
Tab to the save button, then try clicking it. The hint follows how focus arrived.
import * as React from "react"
import { definePrimitive, isFocusFromKeyboard, sourceOf } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"
type SaveBarOptions = {
onSave?: (source: string) => void
}
type SaveBarState = { hint: boolean }
const saveBar = definePrimitive({
name: "save-bar",
options: { onSave: "callback" },
state: (_options: SaveBarOptions): SaveBarState => ({ hint: false }),
api: (context) => ({
save: (event?: Event) => context.options().onSave?.(sourceOf(event)),
focus: () => context.store.set({ hint: isFocusFromKeyboard() }),
blur: () => context.store.set({ hint: false }),
}),
slots: (slot) => ({
save: slot({
attrs: () => ({ type: "button" }),
press: ({ api }, event) => api.save(event),
on: {
focus: (_event, { api }) => api.focus(),
blur: (_event, { api }) => api.blur(),
},
}),
hint: slot({ present: (state) => state.hint }),
}),
})
const parts = createParts(saveBar, { save: "button", hint: "span" })
const SaveBar = parts.Root
const SaveBarButton = parts.Save
const SaveBarHint = parts.Hint
function Example() {
const log = useLog()
return (
<SaveBar onSave={(source) => log(`Saved with ${source}`)}>
<div data-slot="row">
<SaveBarButton data-variant="solid">Save</SaveBarButton>
<SaveBarHint>Enter or Space saves</SaveBarHint>
</div>
</SaveBar>
)
}
export {
Example,
saveBar,
SaveBar,
SaveBarButton,
SaveBarHint,
type SaveBarOptions,
type SaveBarState,
}sourceOf(event) identifies a mouse, touch, pen, keyboard, virtual or programmatic event. Without an event, sourceOf() reports the last keyboard or pointer input. isFocusFromKeyboard() checks whether focus just arrived from the keyboard:
save: slot({
press: ({ api }, event) => api.save(sourceOf(event)),
on: { focus: (_event, { store }) => store.set({ hint: isFocusFromKeyboard() }) },
}),createChange starts input tracking when the primitive is created, so its first change can identify the input behind it.
Changes from Elsewhere
An image load or a timer doesn't need a pointer or key event. Shared reasons such as "image-load" and "timeout" already resolve to a programmatic source. To supply your own source resolver, pass it as the second argument:
const change = createChange(context, () => "programmatic")A controlled drag has one more timing concern: its draft may need to stay visible until the app returns the committed value. settleDraft(store, value, before, clear) handles that handoff. Pass a getter as value, the value from before the commit as before, and a cleanup that checks it's still clearing the same draft. Drag and Drop shows it in a reordering example.
API Reference
What a Change Reports
event?Event- The DOM event behind it, typed by
reason. Undefined for controllers and cascades. - Example: What a Change Reports
sourceChangeSource- How the user caused it:
"mouse","touch","pen","keyboard","virtual"(a click with no pointer or key behind it, which may be assistive technology),"agent"(an AI agent's call through@ui-sdk/core/agent) or"programmatic". For analytics and focus choices, never for behavior. - Example: What a Change Reports
cancel() => void- Stops the change. A controlled component ignores it, since the next value comes from you.
- Example: What a Change Reports
What a Commit Reports
event?Event- The DOM event that ended the interaction, or the one behind the last change when the end was another kind, like a blur after a wheel. Undefined for controllers.
- Example: Commits
Undo
groupDelay?number- Milliseconds of pause after which typing starts a new entry.
- Example: Undo for Typing
record(state: unknown, { group, time }?: { group?: string; time?: number; }) => void- Records the state after an edit. Edits of the same
group, like "insert" or "delete", join the last entry while they come withingroupDelayof each other. Without a group, the edit is its own entry. - Example: Undo
seal() => void- Ends the open group, so the next edit starts an entry, like after a caret jump.
- Example: Undo for Typing
undo() => unknown- Steps back, and returns the state to show, or
undefinedat the first entry. - Example: Undo
redo() => unknown- Steps forward again, and returns the state to show, or
undefinedat the last entry. - Example: Undo
map(update: (state: unknown) => unknown) => void- Rewrites every entry, like a token's status changing everywhere without an entry of its own.
- Example: Undo for Typing
reset(state: unknown) => void- Starts over from
state, like after a controlled value from outside. - Example: Undo for Typing
Next
That covers the Core guides. Reference lists every export by the job it does, and Behaviors has the building blocks for focus, selection, popups and more.