Compose Behaviors
Build a star rating with shared state and keyboard navigation.
So far, each primitive has had one behavior of its own. Now let's combine behaviors from @ui-sdk/core to build a five-star rating: createChange reports each new rating, and createRovingFocus manages the group's Tab stop and arrow keys. We'll add one at a time.
Start with Shared State
The rating's value is a number: 0 means no rating yet. defaultValue sets the starting rating.
Create rating.ts. For now, it has a root to hold the stars and a rate function to choose a value:
import { createChange, definePrimitive, type ChangeDetails } from "@ui-sdk/core"
type RatingOptions = {
value?: number
defaultValue?: number
onValueChange?: (
value: number,
details: ChangeDetails<"item-press" | "list-navigation">
) => void
}
const rating = definePrimitive({
name: "rating",
options: {
value: "number",
defaultValue: "number",
onValueChange: "callback",
},
controlled: ["value"],
state: (options: RatingOptions) => ({
value: options.value ?? options.defaultValue ?? 0,
}),
sync: (options) =>
options.value === undefined ? {} : { value: options.value },
api: (context) => {
const change = createChange(context)
return {
rate: (value: number, event?: Event) =>
change("value", value, { reason: "item-press", event }),
}
},
slots: (slot) => ({
root: slot({
attrs: () => ({ role: "radiogroup" }),
}),
}),
})
export { rating, type RatingOptions }createChange(context)returnschange.ratecalls it with the new value and a reason. It callsonValueChangefirst, then updatesvalueunless the callback cancels or the app controls it.controlledandsynclet a parent ownvalue.synccopies a newvalueoption into state.
Handle Changes covers the change pattern in depth.
Before we add stars, let's try changing the value. These buttons call rate(3) and rate(0):
import * as React from "react"
import { createChange, definePrimitive, type ChangeDetails } from "@ui-sdk/core"
import { usePrimitive } from "@ui-sdk/react/primitive"
type RatingOptions = {
value?: number
defaultValue?: number
onValueChange?: (
value: number,
details: ChangeDetails<"item-press" | "list-navigation">
) => void
}
const rating = definePrimitive({
name: "rating",
options: {
value: "number",
defaultValue: "number",
onValueChange: "callback",
},
controlled: ["value"],
state: (options: RatingOptions) => ({
value: options.value ?? options.defaultValue ?? 0,
}),
sync: (options) =>
options.value === undefined ? {} : { value: options.value },
api: (context) => {
const change = createChange(context)
return {
rate: (value: number, event?: Event) =>
change("value", value, { reason: "item-press", event }),
}
},
slots: (slot) => ({
root: slot({
attrs: () => ({ role: "radiogroup" }),
}),
}),
})
function Example() {
const handle = usePrimitive(rating, {})
const state = React.useSyncExternalStore(
handle.instance.subscribe,
handle.instance.get,
handle.instance.get
)
return (
<div data-slot="stack">
<div data-slot="controls">
<button
type="button"
data-size="sm"
onClick={(event) => handle.api.rate(3, event.nativeEvent)}
>
Set to 3
</button>
<button
type="button"
data-size="sm"
onClick={(event) => handle.api.rate(0, event.nativeEvent)}
>
Reset
</button>
</div>
<div data-slot="rating-readout">
<output data-slot="rating-value" data-value aria-label="Rating">
{state.value}
</output>
<span data-slot="hint">Rating</span>
</div>
</div>
)
}
export { Example, rating }Add a Star
Add a star part. Pressing it sets the rating to that star's value.
Add star beside root in slots:
star: slot<{ value: string }>({
props: { value: "string" },
attrs: (state, part) => ({
type: "button",
role: "radio",
"aria-checked": String(Number(part.key) === state.value),
"data-filled": Number(part.key) <= state.value ? "" : undefined,
}),
press: ({ api, key }, event) => api.rate(Number(key), event),
}),propsgives each star avalue, such as"3". That becomes its key. We useNumber(key)to pass it torateas a number. Option Kinds lists the kinds a prop can take.aria-checkedmarks the selected star. The root and stars form a radio group: one choice from several.data-filledmarks every star up to the selection, so choosing three can fill the first three stars.
import * as React from "react"
import { StarIcon } from "lucide-react"
import { createParts } from "@ui-sdk/react/primitive"
import { rating } from "../rating-stages/star"
const { Root: Rating, Star: RatingStar } = createParts(rating, {
root: "div",
star: "button",
})
function Example() {
const [value, setValue] = React.useState(0)
return (
<div data-slot="stack">
<div data-slot="rating-readout">
<output data-slot="rating-value" data-value aria-label="Rating">
{value}
</output>
<span data-slot="hint">Rating</span>
</div>
<Rating onValueChange={setValue} aria-label="Rating">
<RatingStar value="1" aria-label="1 star">
<StarIcon aria-hidden="true" />
</RatingStar>
</Rating>
</div>
)
}
export { Example, rating }Put Five Stars in the Group
Now render five stars using the same part. They share one rating, and each gets its own value and accessible name.
"use client"
import { createParts } from "@ui-sdk/react/primitive"
import { rating } from "./rating"
const { Root: Rating, Star: RatingStar } = createParts(rating, {
root: "div",
star: "button",
})
function Example() {
return (
<Rating aria-label="Rating">
{Array.from({ length: 5 }, (_, index) => (
<RatingStar
key={index}
value={String(index + 1)}
aria-label={`${index + 1} ${index === 0 ? "star" : "stars"}`}
>
โ
</RatingStar>
))}
</Rating>
)
}
export { Example }Use data-filled to color the stars, the way Style a Component styled the copy button from data-copied:
[data-slot="rating"] {
display: flex;
gap: 0.25rem;
}
[data-slot="rating-star"][data-filled],
[data-slot="rating-star"]:hover {
color: #b45309;
}import * as React from "react"
import { StarIcon } from "lucide-react"
import { createParts } from "@ui-sdk/react/primitive"
import { rating } from "../rating-stages/star"
const { Root: Rating, Star: RatingStar } = createParts(rating, {
root: "div",
star: "button",
})
function Example() {
const [value, setValue] = React.useState(0)
return (
<div data-slot="stack">
<div data-slot="rating-readout">
<output
data-slot="rating-value"
data-value
aria-label="Rating out of 5"
>
{value}
</output>
<span data-slot="hint">out of 5</span>
</div>
<Rating onValueChange={setValue} aria-label="Rating">
{Array.from({ length: 5 }, (_, index) => (
<RatingStar
key={index}
value={String(index + 1)}
aria-label={`${index + 1} ${index === 0 ? "star" : "stars"}`}
>
<StarIcon aria-hidden="true" />
</RatingStar>
))}
</Rating>
</div>
)
}
export { Example, rating }Give the Group One Tab Stop
Let's make Tab enter the group once, on the selected star. If nothing is selected, it enters on the first star.
createRovingFocus from @ui-sdk/core manages this. Add it to the import, then add focused to the state:
state: (options: RatingOptions) => ({
value: options.value ?? options.defaultValue ?? 0,
focused: null as string | null,
}),value is the rating. focused is the key of the star with the tab stop.
Update api to create and return the behavior alongside rate:
api: (context) => {
const change = createChange(context)
const roving = createRovingFocus(context, {
slot: "star",
key: "focused",
remember: false,
preferred: (star) => Number(star.key) === context.store.get().value,
claims: (state, star) => (state.value ? Number(star.key) === state.value : star.key === "1"),
})
return {
roving,
rate: (value: number, event?: Event) => change("value", value, { reason: "item-press", event }),
}
},slotnames the parts to track;keynames the state field to update.preferredchooses the selected star.remember: falsereturns to that selection when focus comes back.claimschooses the tab stop before elements mount, including on the server. It picks the selected star, or the first if there isn't a selection.
Now connect the star slot to the behavior. Add tabindex to attrs and bind beside press:
star: slot<{ value: string }>({
props: { value: "string" },
attrs: (state, part) => ({
type: "button",
role: "radio",
"aria-checked": String(Number(part.key) === state.value),
tabindex: part.api.roving.tabIndex(state, part),
"data-filled": Number(part.key) <= state.value ? "" : undefined,
}),
press: ({ api, key }, event) => api.rate(Number(key), event),
bind: (el, part) => part.api.roving.bind(el, part),
}),bind registers each button and cleans up when it unmounts. tabIndex gives one star 0 and the others -1.
import * as React from "react"
import { StarIcon } from "lucide-react"
import {
createChange,
createRovingFocus,
definePrimitive,
type ChangeDetails,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type RatingOptions = {
value?: number
defaultValue?: number
onValueChange?: (
value: number,
details: ChangeDetails<"item-press" | "list-navigation">
) => void
}
const rating = definePrimitive({
name: "rating",
options: {
value: "number",
defaultValue: "number",
onValueChange: "callback",
},
controlled: ["value"],
state: (options: RatingOptions) => ({
value: options.value ?? options.defaultValue ?? 0,
focused: null as string | null,
}),
sync: (options) =>
options.value === undefined ? {} : { value: options.value },
api: (context) => {
const change = createChange(context)
const roving = createRovingFocus(context, {
slot: "star",
key: "focused",
remember: false,
preferred: (star) => Number(star.key) === context.store.get().value,
claims: (state, star) =>
state.value ? Number(star.key) === state.value : star.key === "1",
})
return {
roving,
rate: (value: number, event?: Event) =>
change("value", value, { reason: "item-press", event }),
}
},
slots: (slot) => ({
root: slot({
attrs: () => ({ role: "radiogroup" }),
}),
star: slot<{ value: string }>({
props: { value: "string" },
attrs: (state, part) => ({
type: "button",
role: "radio",
"aria-checked": String(Number(part.key) === state.value),
tabindex: part.api.roving.tabIndex(state, part),
"data-filled": Number(part.key) <= state.value ? "" : undefined,
}),
press: ({ api, key }, event) => api.rate(Number(key), event),
bind: (el, part) => part.api.roving.bind(el, part),
}),
}),
})
const { Root: Rating, Star: RatingStar } = createParts(rating, {
root: "div",
star: "button",
})
function Example() {
const [value, setValue] = React.useState(0)
return (
<div data-slot="stack">
<div data-slot="rating-readout">
<output
data-slot="rating-value"
data-value
aria-label="Rating out of 5"
>
{value}
</output>
<span data-slot="hint">out of 5</span>
</div>
<Rating onValueChange={setValue} aria-label="Rating">
{Array.from({ length: 5 }, (_, index) => (
<RatingStar
key={index}
value={String(index + 1)}
aria-label={`${index + 1} ${index === 0 ? "star" : "stars"}`}
>
<StarIcon aria-hidden="true" />
</RatingStar>
))}
</Rating>
</div>
)
}
export { Example, rating }Move with the Arrow Keys
createRovingFocus handles the arrow keys, Home and End, and right-to-left layouts. By default it uses Up and Down, like a vertical list.
For this rating, we want all four arrows, and the last star should wrap around to the first. Add orientation and loop to the createRovingFocus options:
orientation: () => "both",
loop: () => true,Down and Right move to the next star, and Up and Left move to the previous one. Left and Right swap in right-to-left layouts. Home and End go to the first and last star.
The behavior moves focus; the rating decides what to select. Add keydown beside rate in the object returned from api:
keydown(event: KeyboardEvent, el: HTMLElement) {
const moved = roving.keydown(event, el)
if (moved) {
change("value", Number(moved.key), { reason: "list-navigation", event })
}
},Both clicks and arrow keys use createChange. The onValueChange callback receives the new value and a reason: item-press or list-navigation.
Finally, connect the star's key events. Add on beside its press and bind fields:
on: {
keydown: (event, { el, api }) => {
if (el) {
api.keydown(event, el)
}
},
},import * as React from "react"
import { StarIcon } from "lucide-react"
import {
createChange,
createRovingFocus,
definePrimitive,
type ChangeDetails,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
type RatingOptions = {
value?: number
defaultValue?: number
onValueChange?: (
value: number,
details: ChangeDetails<"item-press" | "list-navigation">
) => void
}
const rating = definePrimitive({
name: "rating",
options: {
value: "number",
defaultValue: "number",
onValueChange: "callback",
},
controlled: ["value"],
state: (options: RatingOptions) => ({
value: options.value ?? options.defaultValue ?? 0,
focused: null as string | null,
}),
sync: (options) =>
options.value === undefined ? {} : { value: options.value },
api: (context) => {
const change = createChange(context)
const roving = createRovingFocus(context, {
slot: "star",
key: "focused",
orientation: () => "both",
loop: () => true,
remember: false,
preferred: (star) => Number(star.key) === context.store.get().value,
claims: (state, star) =>
state.value ? Number(star.key) === state.value : star.key === "1",
})
return {
roving,
rate: (value: number, event?: Event) =>
change("value", value, { reason: "item-press", event }),
keydown(event: KeyboardEvent, el: HTMLElement) {
const moved = roving.keydown(event, el)
if (moved) {
change("value", Number(moved.key), {
reason: "list-navigation",
event,
})
}
},
}
},
slots: (slot) => ({
root: slot({
attrs: () => ({ role: "radiogroup" }),
}),
star: slot<{ value: string }>({
props: { value: "string" },
attrs: (state, part) => ({
type: "button",
role: "radio",
"aria-checked": String(Number(part.key) === state.value),
tabindex: part.api.roving.tabIndex(state, part),
"data-filled": Number(part.key) <= state.value ? "" : undefined,
}),
press: ({ api, key }, event) => api.rate(Number(key), event),
on: {
keydown: (event, { el, api }) => {
if (el) {
api.keydown(event, el)
}
},
},
bind: (el, part) => part.api.roving.bind(el, part),
}),
}),
})
const parts = createParts(rating, { root: "div", star: "button" })
const Rating = parts.Root
const RatingStar = parts.Star
function Example() {
const [value, setValue] = React.useState(3)
return (
<div data-slot="stack">
<div data-slot="rating-readout">
<output
data-slot="rating-value"
data-value
aria-label="Rating out of 5"
>
{value}
</output>
<span data-slot="hint">out of 5</span>
</div>
<Rating value={value} onValueChange={setValue} aria-label="Rating">
{Array.from({ length: 5 }, (_, index) => (
<RatingStar
key={index}
value={String(index + 1)}
aria-label={`${index + 1} ${index === 0 ? "star" : "stars"}`}
>
<StarIcon aria-hidden="true" />
</RatingStar>
))}
</Rating>
</div>
)
}
export { Example, rating, Rating, RatingStar, type RatingOptions }Check the Behavior
We can test the shared primitive directly. Here's a small Vitest test that chooses three stars and checks the value:
// rating.test.ts
import { expect, test } from "vitest"
import { rating } from "./rating"
test("choosing a star changes the rating", () => {
const instance = rating({ id: "rating" })
instance.api.rate(3)
expect(instance.get().value).toBe(3)
})For focus and keyboard checks, render the component and run checkPattern(root, "radio-group") from @ui-sdk/core/testing. Testing shows how.
Complete Primitive
Here's everything we added, in one file:
// rating.ts
import {
createChange,
createRovingFocus,
definePrimitive,
type ChangeDetails,
} from "@ui-sdk/core"
type RatingOptions = {
value?: number
defaultValue?: number
onValueChange?: (
value: number,
details: ChangeDetails<"item-press" | "list-navigation">
) => void
}
const rating = definePrimitive({
name: "rating",
options: {
value: "number",
defaultValue: "number",
onValueChange: "callback",
},
controlled: ["value"],
state: (options: RatingOptions) => ({
value: options.value ?? options.defaultValue ?? 0,
focused: null as string | null,
}),
sync: (options) =>
options.value === undefined ? {} : { value: options.value },
api: (context) => {
const change = createChange(context)
const roving = createRovingFocus(context, {
slot: "star",
key: "focused",
remember: false,
preferred: (star) => Number(star.key) === context.store.get().value,
claims: (state, star) =>
state.value ? Number(star.key) === state.value : star.key === "1",
orientation: () => "both",
loop: () => true,
})
return {
roving,
rate: (value: number, event?: Event) =>
change("value", value, { reason: "item-press", event }),
keydown(event: KeyboardEvent, el: HTMLElement) {
const moved = roving.keydown(event, el)
if (moved) {
change("value", Number(moved.key), {
reason: "list-navigation",
event,
})
}
},
}
},
slots: (slot) => ({
root: slot({
attrs: () => ({ role: "radiogroup" }),
}),
star: slot<{ value: string }>({
props: { value: "string" },
attrs: (state, part) => ({
type: "button",
role: "radio",
"aria-checked": String(Number(part.key) === state.value),
tabindex: part.api.roving.tabIndex(state, part),
"data-filled": Number(part.key) <= state.value ? "" : undefined,
}),
press: ({ api, key }, event) => api.rate(Number(key), event),
on: {
keydown: (event, { el, api }) => {
if (el) {
api.keydown(event, el)
}
},
},
bind: (el, part) => part.api.roving.bind(el, part),
}),
}),
})
export { rating, type RatingOptions }Keep Building
Next, Handle Changes goes further into the change pattern the rating uses: callbacks, controlled state, commits and undo.