Give your primitive options, expose state to its parts, and work with elements.

definePrimitive brings a component's options, state, methods, and parts together. Use its slots to expose attributes and state, handle events, and work with mounted elements.

In Build a Component, you defined a mute button. This page goes through each field of a definition, using a copy button. Each demo's Code tab has the full example.

Here's the copy button this page builds, as one definition. Each section below covers one of its fields in more detail.

copy-button.ts
import { definePrimitive } from "@ui-sdk/core"

type CopyButtonOptions = { text?: string; onCopy?: (text: string) => void }

const copyButton = definePrimitive({
  name: "copy-button",
  options: { text: "string", onCopy: "callback" },
  state: (options: CopyButtonOptions) => ({ copied: false }),
  api: (context) => ({
    async copy() {
      const { text = "", onCopy } = context.options()
      await navigator.clipboard.writeText(text)
      context.store.set({ copied: true })
      onCopy?.(text)
    },
  }),
  slots: (slot) => ({
    root: slot<{}, { copied: boolean }>({
      state: (state) => ({ copied: state.copied }),
      attrs: () => ({ type: "button" }),
      press: ({ api }) => api.copy(),
    }),
  }),
})

export { copyButton }

Options

Let's give a copy button some text to copy. Describe the options as a type, list each one's kind in options, and type state's parameter with it:

type CopyButtonOptions = { text?: string; onCopy?: (text: string) => void }
options: { text: "string", onCopy: "callback" },
state: (options: CopyButtonOptions) => ({ copied: false }),

Typing state's options is what types context.options() in api, so the type is written once. The kinds tell adapters how to handle each value. For example, web components read text from an attribute and turn onCopy into an event. TypeScript checks that every option has a kind. If your primitive has no options, leave options out.

Read the current values with context.options(). Here, copy reads the text when the button is pressed:

api: (context) => {
  return {
    async copy() {
      const { text = "", onCopy } = context.options()
      await navigator.clipboard.writeText(text)
      context.store.set({ copied: true })
      onCopy?.(text)
    },
  }
},

api runs once per instance, so reading options inside the method avoids holding on to an old value.

When an Option Changes

Use context.onOptions when a change needs some extra work. For the copy button, new text should clear the previous "Copied" state. Add this inside api, before returning its methods:

let text = context.options().text

context.onOptions((options) => {
  if (options.text === text) return
  text = options.text
  context.store.set({ copied: false })
})

To keep a controlled value in sync instead, use sync, as shown in Handle Changes.

Option Kinds

The same kinds work in a slot's props schema, which gives each part values of its own, like a star's value in Compose Behaviors.

KindUse it for
"string", "number", "boolean"Values that can be read from attributes.
"list"A string or a list of strings. An attribute uses commas between values.
"value"Other values, such as objects, elements or functions. Set these as properties on web components.
"callback"A callback that reports something. Vue treats it as an emit; web components fire an event.
"action"A function whose return value matters, such as a Promise. Vue keeps it as a function prop.
"id"A string the adapter generates when it's omitted.
"key"A list item's value, of any type. The part is keyed by it, and the parts inside see the key.

For a part nested inside another part, { kind: "string", from: "item" } inherits the same prop from the enclosing item when it's omitted. Use from: "group.id" to inherit a different prop, and add required: true when the part only makes sense inside its parent, so using it anywhere else throws. A function (attribute) => value can parse a web component attribute when none of the built-in kinds fit.

What a Part Shows

Attributes are enough for styling. To let the component's content read state too, add state to its slot:

root: slot<{}, { copied: boolean }>({
  state: (state) => ({ copied: state.copied }),
  attrs: () => ({ type: "button" }),
  press: ({ api }) => api.copy(),
}),

The first type argument describes the part's props; the second describes what its state returns. In React, the copy button's children can now read copied:

<CopyButton text="npm add @ui-sdk/core">
  {({ copied }) => (copied ? "Copied" : "Copy")}
</CopyButton>

Try it. This version also resets after a short timeout.

import { definePrimitive } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type CopyButtonOptions = {
  text?: string
  timeout?: number
  onCopy?: (text: string) => void
}

type CopyButtonState = { copied: boolean }

const copyButton = definePrimitive({
  name: "copy-button",
  options: { text: "string", timeout: "number", onCopy: "callback" },
  state: (options: CopyButtonOptions): CopyButtonState => ({ copied: false }),
  api: (context) => {
    let timer: ReturnType<typeof setTimeout> | undefined
    let text = context.options().text
    context.onOptions((options) => {
      if (options.text === text) {
        return
      }
      text = options.text
      clearTimeout(timer)
      context.store.set({ copied: false })
    })
    context.onDestroy(() => clearTimeout(timer))
    return {
      async copy() {
        const { text = "", timeout = 1500, onCopy } = context.options()
        await navigator.clipboard?.writeText(text).catch(() => {})
        context.store.set({ copied: true })
        onCopy?.(text)
        clearTimeout(timer)
        timer = setTimeout(() => context.store.set({ copied: false }), timeout)
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}, { copied: boolean }>({
      state: (state) => ({ copied: state.copied }),
      attrs: (state) => ({
        type: "button",
        "data-copied": state.copied ? "" : undefined,
      }),
      press: ({ api }) => api.copy(),
    }),
  }),
})

const parts = createParts(copyButton, { root: "button" })

const CopyButton = parts.Root

function Example() {
  return (
    <div data-slot="row">
      <code data-slot="copy-command">npm install @ui-sdk/core</code>
      <CopyButton text="npm install @ui-sdk/core">
        {({ copied }) => (copied ? "Copied" : "Copy")}
      </CopyButton>
    </div>
  )
}

export {
  copyButton,
  CopyButton,
  type CopyButtonOptions,
  type CopyButtonState,
  Example,
}
Press Copy. The button reads Copied while the command is copied, then goes back to Copy.

Parts That Come and Go

Use present when a part should only appear in some states. This panel is present while open is true:

panel: slot({
  present: (state) => state.open,
  attrs: (_state, part) => ({ id: `${part.id}-panel` }),
}),

The trigger points to the panel with aria-controls:

trigger: slot({
  attrs: (state, part) => ({
    type: "button",
    "aria-expanded": String(state.open),
    "aria-controls": `${part.id}-panel`,
  }),
  press: ({ api }) => api.toggle(),
}),

part.id is the instance's unique id. Both parts use it to build the same panel id. If an id includes an item value, use idPart(value) from @ui-sdk/core to escape spaces and percent signs.

An id the author writes on a part's element wins over the one its slot makes, in every framework and on web components. The parts that render after it point at the author's id from their first render, on a server too, and once the element binds, every part does: aria-controls, aria-labelledby, aria-describedby, aria-activedescendant, for and the other references to the made id point at it, so build references from part.id and the core keeps them right.

Leave present out for parts that are always there. Framework adapters remove absent parts; web components keep the markup and bind or unbind its behavior as presence changes.

Events

Use press for button activation. For other DOM events, use on. Here's a search field that clears on Escape:

input: slot({
  attrs: (state) => ({ type: "search", value: state.query }),
  on: {
    input: (event, { api }) => api.search((event.target as HTMLInputElement).value),
    keydown: (event, { api }) => {
      if (event.key !== "Escape") return
      event.preventDefault()
      api.search("")
    },
  },
}),

Each handler receives the event and the part's context: el, id, key, props, store and api. A handler passed by the component's user runs first. It can call event.preventDefault() to skip the primitive's handler.

import * as React from "react"
import { definePrimitive } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { XIcon } from "lucide-react"

type SearchFieldOptions = {
  onQueryChange?: (query: string) => void
}

type SearchFieldState = { query: string }

const searchField = definePrimitive({
  name: "search-field",
  options: { onQueryChange: "callback" },
  state: (_options: SearchFieldOptions): SearchFieldState => ({ query: "" }),
  api: (context) => ({
    search(query: string) {
      context.store.set({ query })
      context.options().onQueryChange?.(query)
    },
  }),
  slots: (slot) => ({
    root: slot({ attrs: () => ({ role: "search" }) }),
    input: slot({
      attrs: (state) => ({ type: "search", value: state.query }),
      on: {
        input: (event, { api }) =>
          api.search((event.target as HTMLInputElement).value),
        keydown: (event, { api }) => {
          if (event.key !== "Escape") {
            return
          }
          event.preventDefault()
          api.search("")
        },
      },
    }),
    clear: slot({
      present: (state) => state.query !== "",
      attrs: () => ({ type: "button", "aria-label": "Clear" }),
      press: ({ api }) => api.search(""),
    }),
  }),
})

const parts = createParts(searchField, {
  root: "div",
  input: "input",
  clear: "button",
})

const SearchField = parts.Root

const SearchFieldInput = parts.Input

const SearchFieldClear = parts.Clear

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

function Example() {
  const [query, setQuery] = React.useState("")
  const matches = fruits.filter((fruit) =>
    fruit.toLowerCase().includes(query.toLowerCase())
  )
  return (
    <div data-slot="stack">
      <SearchField onQueryChange={setQuery}>
        <SearchFieldInput
          aria-label="Search fruit"
          placeholder="Search fruit…"
        />
        <SearchFieldClear>
          <XIcon aria-hidden="true" />
        </SearchFieldClear>
      </SearchField>
      <p data-slot="hint">{matches.join(", ") || "No fruit matches."}</p>
    </div>
  )
}

export {
  Example,
  searchField,
  SearchField,
  SearchFieldClear,
  SearchFieldInput,
  type SearchFieldOptions,
  type SearchFieldState,
}

Roles, Focus and Keys

Declare each part's role on its slot, not in attrs. Core writes it, and a role that takes focus, activates or moves with keys makes the slot say how. Here's an option:

item: slot({
  role: "option",
  focus: "roving",
  press: ({ api, key }) => api.choose(key),
}),

Leave one out and TypeScript names it: an option without focus fails with "Property 'focus' is missing". The role decides what's required:

  • focus for roles that take focus, like option, tab or menuitem: "roving", "tab-stop", "active-descendant" or "native". Development builds check the bound element can take focus that way, like a "native" part that renders a <div>.
  • press for roles that activate, like button, option or tab.
  • keys for roles whose pattern moves with keys, like listbox, menu or slider: the keys its on.keydown handles, or a function of state when they depend on it.
list: slot({
  role: "listbox",
  keys: (state) => [...(state.orientation === "horizontal" ? ["ArrowLeft", "ArrowRight"] : ["ArrowUp", "ArrowDown"]), "Home", "End", "typeahead"],
  on: { keydown: (event, { api }) => api.navigate(event) },
}),

Listing keys without an on.keydown to handle them fails to compile too, on any slot. When another element meets a need, say which with { none: "why" }, like keys: { none: "The combobox's input handles them." }. A role that changes with state is a function, with every role it can pick in roles:

root: slot({
  role: (state) => (state.decorative ? "none" : "separator"),
  roles: ["none", "separator"],
}),

The role decides the part's ARIA too. Its attrs returns the states the role can't work without, like aria-checked on a checkbox or aria-selected on an option, and nothing the role doesn't take: aria-orientation on a group fails with "aria-orientation isn't allowed on a group".

box: slot({
  role: "checkbox",
  focus: "native",
  press: ({ api }) => api.toggle(),
  attrs: (state) => ({ "aria-checked": String(state.checked) }),
}),

A state that live keeps current is undefined in attrs, so attrs still names it. Development builds warn about anything the types can't see, like a spread slot or JavaScript, and about a required state that renders without a value. Set globalThis.__UI_SDK_STRICT__ = true in your test setup to turn every warning into an error.

Working with the Element

Use bind for work that needs an element, such as measuring it or attaching an observer. It runs in the browser once the part is bound. Return a cleanup function:

root: slot({
  attrs: () => ({ rows: "1" }),
  bind: (el) => {
    const fit = () => {
      el.style.height = "auto"
      el.style.height = `${el.scrollHeight}px`
    }
    fit()
    el.addEventListener("input", fit)
    return () => el.removeEventListener("input", fit)
  },
}),

This textarea measures its content after each input and adjusts its height.

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

const autosize = definePrimitive({
  name: "autosize",
  state: () => ({}),
  api: () => ({}),
  slots: (slot) => ({
    root: slot({
      attrs: () => ({ rows: "1" }),
      bind: (el) => {
        const fit = () => {
          el.style.height = "auto"
          el.style.height = `${el.scrollHeight}px`
        }
        fit()
        el.addEventListener("input", fit)
        return () => el.removeEventListener("input", fit)
      },
    }),
  }),
})

const parts = createParts(autosize, { root: "textarea" })

const Autosize = parts.Root

function Example() {
  return (
    <Autosize
      aria-label="Message"
      placeholder="Write a message. Press Enter for a new line."
    />
  )
}

export { autosize, Autosize, Example }

Keep attrs and slot state pure: they also run on the server. Read from state and the part's props; do DOM work in bind. Slot state can return new objects and arrays on every call, since parts compare them by value, but other values, like a Date or a Map, should stay the same object until they change.

bind also receives the part as its second argument. Its props stay current, and part.store.subscribe lets you follow state changes. Use connect for work that must stay active while an element remains in the page, even when it isn't present, as with a hidden custom element.

Cleaning up the Instance

Work shared by all parts belongs in api. Use context.onDestroy to clean it up when the instance is destroyed. For example, the copy button clears its reset timer:

let timer: ReturnType<typeof setTimeout> | undefined

context.onDestroy(() => clearTimeout(timer))

Clear the previous timer before starting another one, too.

Reading Parts in the Api

Sometimes a method needs to find a part. elements(context, slot) returns the bound parts in page order, with their elements, keys and props:

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

api: (context) => ({
  next() {
    const fields = elements<{ value: string }>(context, "field")
    const empty = fields.find((field) => (field.el as HTMLInputElement).value === "")
    empty?.el.focus()
  },
}),

Try leaving a field empty. The button finds it from the current page order, so there's no separate index to keep up to date.

import * as React from "react"
import { definePrimitive, elements } from "@ui-sdk/core"
import { Field, FieldControl, FieldLabel } from "@ui-sdk/react/field"
import { createParts } from "@ui-sdk/react/primitive"

type CheckoutState = { missing: string | null }

const checkout = definePrimitive({
  name: "checkout",
  state: (): CheckoutState => ({ missing: null }),
  api: (context) => ({
    next() {
      const fields = elements<{ value: string }>(context, "field")
      const empty = fields.find(
        (field) => (field.el as HTMLInputElement).value === ""
      )
      empty?.el.focus()
      context.store.set({ missing: empty ? empty.props.value : null })
    },
  }),
  slots: (slot) => ({
    field: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (_state, part) => ({ name: part.props.value }),
    }),
    next: slot<{}, CheckoutState>({
      state: (state) => ({ missing: state.missing }),
      attrs: () => ({ type: "button" }),
      press: ({ api }) => api.next(),
    }),
  }),
})

const parts = createParts(checkout, { field: "input", next: "button" })

const Checkout = parts.Root

const CheckoutField = parts.Field

const CheckoutNext = parts.Next

const fields = [
  { value: "name", label: "Name", defaultValue: "Hollis Grant" },
  { value: "email", label: "Email" },
  { value: "address", label: "Address" },
]

function Example() {
  return (
    <Checkout>
      <div data-slot="stack">
        {fields.map(({ value, label, defaultValue }) => (
          <Field key={value}>
            <FieldLabel>{label}</FieldLabel>
            <FieldControl
              render={<CheckoutField value={value} />}
              defaultValue={defaultValue}
            />
          </Field>
        ))}
        <CheckoutNext data-variant="solid">
          {({ missing }) =>
            missing
              ? `Fill In ${missing[0].toUpperCase()}${missing.slice(1)}`
              : "Find Empty Field"
          }
        </CheckoutNext>
      </div>
    </Checkout>
  )
}

export {
  checkout,
  Checkout,
  CheckoutField,
  CheckoutNext,
  type CheckoutState,
  Example,
}

For one part, use context.element(slot, key). To read its latest props, use context.props(slot, key), naming them the same way: context.props<{ title: string }>("window", key). The key is the part's id prop, or its value, as with the stars in Compose Behaviors.

What Agents Can Do

The agent key tells the agent bridge which api methods an agent can call, and a little about the component, like whether its value must be one of its items:

agent: { actions: ["setValue", "clear"], closed: true },

Put it last, after slots, so TypeScript has read the api by then. Without it, the bridge picks methods named like changes, such as setValue or toggle. Your Own Components shows a rating an agent can use, and every part of the key.

Naming

A primitive's name prefixes each part's data-slot, like tabs-list for the tabs' list slot. The root slot gets the name alone, so name: "copy-button" gives its root data-slot="copy-button".

Warnings and devtools use this name too. Set label when they should show a different name; it doesn't change data-slot.

For state attributes, openAttrs(open) returns data-open or data-closed. checkedAttrs(checked) returns data-checked, data-unchecked or data-indeterminate. Both are helpers from @ui-sdk/core.

API Reference

definePrimitive

namestring
Prefixes data-slot, and names the component in warnings and the devtools. Kebab case.
Example: Naming
state(options: Options) => State
The starting state, from the first options. Read default<Key> here.
Example: Define a Primitive
live?(keyof State)[]
State keys that change often and never affect rendering, like a highlighted item.
Example: Live State
asleep?((options: object) => boolean)
Whether an instance with these options starts asleep: its parts render and run asleep, while its api and each part's bind wait for the first event a part handles or the first read of instance.api. Like a closed tooltip, which costs little until its trigger is hovered or focused.
sync?(options: Options) => Partial<State>
Maps controlled options onto state. Runs whenever options change.
Example: State a User Controls
controlled?(keyof State)[]
State keys a user can control with <key>, default<Key> and on<Key>Change. Development builds warn when one starts with both <key> and default<Key>, or was controlled and becomes undefined. Defaults to whichever of open, value and checked the state has.
Example: State a User Controls
commits?(keyof State)[]
State keys an interaction changes in steps and then commits, through createCommit, like a slider's value while it's dragged. observe reports each of them once per commit rather than per step.
Example: Commits
api(context: PrimitiveContext) => Api
Runs once per instance and returns its api, which slots, adapters and a controller call.
Example: Define a Primitive
slots(slot: SlotFactory) => Record<string, SlotDefinition>
The parts, built with slot. slot<Props, State>({ ... }) types a slot's props and the state its render functions get.
Example: Define a Primitive
label?string
What warnings, debug and the devtools call it, when that isn't name, which prefixes data-slot. Like a checkbox group's parts in checkbox.
Example: Naming
agent?object: actions, closed, items, reveals, shown, secret
What @ui-sdk/core/agent offers an agent. Without it, the agent takes the api's methods that change something by their names, like setValue.
Example: What Agents Can Do
options?PropSchema<Options>
Each option, with its kind, for renderers to read. A primitive without options leaves it out.
Example: Options

Slots

present?((state: State, context: { id: string; key: string; props: Props; api: Api; }) => boolean)
Whether the slot's element should exist. Adapters that cannot unmount markup, like custom elements, bind and unbind on it. Defaults to always. Keyed parts, like one panel among many, decide from their own props. May read live state: adapters check presence on every change.
Example: Parts That Come and Go
attrs?((state: State, context: { id: string; key: string; props: Props; api: Api; }) => Attrs)
Pure. Runs on the server and on every render. Never reads live state.
Example: Define a Primitive
state?((state: State, context: { id: string; key: string; props: Props; api: Api; }) => Public)
What render functions and state-based class names see, like { highlighted, disabled }. May read live state: adapters only re-render a part for live changes when its render function actually read them.
Example: What a Part Shows
live?Live<State, Api, Props>
Attributes written straight to the element on every change, built by live().
Example: Live State
direct?{ names: readonly string[]; attach: (part: Part, el: HTMLElement) => () => void; }
Attributes of attrs core keeps current on a framework's element, built by direct().
Example: Live State
selects?Selects<State, Api, Props>
Narrows which changes reach the slot's parts, built by selects().
Example: Live State
press?(context: { el, id, key, props, store, api }, event?: Event) => void
Activation: a click, which keys and screen readers send too. { none: "why" } for a role that activates when another handler does it, like the author's own click.
Example: Define a Primitive
on?Partial<Record<keyof HTMLElementEventMap, (event: Event, context: { el, id, key, props, store, api }) => void>>
DOM events. Adapters attach these so user handlers run first and can call preventDefault() to skip them.
Example: Events
bind?((el: HTMLElement, part: Part<State, Api, Props>) => (() => void) | void)
Client only, web only. Focus, positioning, observers. Gets the part, whose props are its newest render's. Return a cleanup. Follow state with part.store.subscribe, or write attributes that change often with direct.
Example: Working with the Element
asleep?((el: HTMLElement, part: Part<State, Api, Props>) => (() => void) | void)
Client only, web only. Like bind, but runs at once while the instance is asleep, for what a part can't wait for, like naming its trigger. Its part.api is undefined until the instance wakes.
connect?((el: HTMLElement) => () => void)
Client only. Runs while the element is in the page, present or not, for adapters that keep parts that aren't present, like custom elements. A popup's content registers here, so shortcuts inside a closed one don't run.
Example: Working with the Element
props?PropSchema<Props>
Each prop the slot's parts take, with its kind, like { value: "string" }.
Example: Add a Star

Roles

role?Role | ((state, context) => Role | undefined)
The part's WAI-ARIA role, which core writes. A role that takes focus, activates or moves with keys makes the slot say how, with focus, press and keys. A function picks the role from state, with every role it can pick in roles. Never put role in attrs.
Example: Roles, Focus and Keys
roles?array
Every role a role function can pick. Development builds check what each one needs.
Example: Roles, Focus and Keys
focus?"roving" | "tab-stop" | "active-descendant" | "native" | { none: string }
How the part gets focus, for a role that takes it, like an option or a tab. { none: "why" } when another element takes it instead.
Example: Roles, Focus and Keys
keys?HandledKey[] | ((state) => HandledKey[]) | { none: string }
The keys the part's on.keydown handles, for a role whose pattern moves with keys, or a function of state when they depend on it, like the arrows of an orientation. { none: "why" } when another element handles them, like a combobox's input for its listbox.
Example: Roles, Focus and Keys

Next

The copy button has everything it needs. Next, Render a Primitive puts it on the page in your framework.