Define a Primitive
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.
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.
| Kind | Use 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,
}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:
focusfor roles that take focus, likeoption,tabormenuitem:"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>.pressfor roles that activate, likebutton,optionortab.keysfor roles whose pattern moves with keys, likelistbox,menuorslider: the keys itson.keydownhandles, 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'sbindwait for the first event a part handles or the first read ofinstance.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>andon<Key>Change. Development builds warn when one starts with both<key>anddefault<Key>, or was controlled and becomes undefined. Defaults to whichever ofopen,valueandcheckedthe 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'svaluewhile it's dragged.observereports 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,
debugand the devtools call it, when that isn'tname, which prefixesdata-slot. Like a checkbox group's parts incheckbox. - Example: Naming
agent?object: actions, closed, items, reveals, shown, secret- What
@ui-sdk/core/agentoffers an agent. Without it, the agent takes the api's methods that change something by their names, likesetValue. - 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
renderfunctions 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
attrscore keeps current on a framework's element, built bydirect(). - 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
propsare its newest render's. Return a cleanup. Follow state withpart.store.subscribe, or write attributes that change often withdirect. - 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. Itspart.apiis 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,pressandkeys. A function picks the role from state, with every role it can pick inroles. Never putroleinattrs. - Example: Roles, Focus and Keys
roles?array- Every role a
rolefunction 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.keydownhandles, 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.