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:

lib/mute-button.ts
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 }
  • options adds the value and callback. controlled marks muted as a state value that can be controlled.
  • state sets the initial value. sync keeps it up to date when a new muted value comes in.
  • createChange calls onMutedChange with the next value and waits for it to come back through muted. If you leave out muted, 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:

pages/index.tsx
"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 }
Click to switch between Mute and Unmute. The parent component controls the value, icon and label.

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.

Console
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.

Console
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()
  }
},
  • reason says what happened, such as "press" or "timeout".
  • event is the DOM event behind it, when there is one.
  • source says how it happened: "mouse", "touch", "pen", "keyboard", "virtual", "programmatic" or "agent".
  • cancel() refuses the request; canceled records 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.

Console
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 defaultOpen when 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 open and defaultOpen.
  • Those checks cover open, value and checked by default. Use controlled: ["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.

Console
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.

Console
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.

Console
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

reasonstring
Why it changed, like "escape-key".
Example: 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
canceledboolean
Whether cancel() was called.
Example: What a Change Reports

What a Commit Reports

reasonstring
What changed the value, like "drag" or "step-key".
Example: Commits
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
sourceChangeSource
How the user caused it, like "mouse" or "keyboard".
Example: Commits

Undo

limit?number
How many entries are kept.
Example: Undo for Typing
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 within groupDelay of 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 undefined at the first entry.
Example: Undo
redo() => unknown
Steps forward again, and returns the state to show, or undefined at the last entry.
Example: Undo
canUndo() => boolean
Whether there's an entry to step back to.
Example: Undo
canRedo() => boolean
Whether there's an entry to step forward to.
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.