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:

rating.ts
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) returns change. rate calls it with the new value and a reason. It calls onValueChange first, then updates value unless the callback cancels or the app controls it.
  • controlled and sync let a parent own value. sync copies a new value option 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 }
Press Set to 3, then Reset to return to 0.

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),
}),
  • props gives each star a value, such as "3". That becomes its key. We use Number(key) to pass it to rate as a number. Option Kinds lists the kinds a prop can take.
  • aria-checked marks the selected star. The root and stars form a radio group: one choice from several.
  • data-filled marks 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 }
Press the star. The rating becomes 1 and the star fills in.

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 }
Hover to preview a rating, then click to choose it. Try Tab: each star still has its own Tab stop.

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 }),
  }
},
  • slot names the parts to track; key names the state field to update.
  • preferred chooses the selected star. remember: false returns to that selection when focus comes back.
  • claims chooses 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 }
Choose a star. Tab leaves the group; Shift+Tab returns to your selection.

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 }
Use the arrows to change the rating. Home and End jump to the first and last star.

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:

Keep Building

Next, Handle Changes goes further into the change pattern the rating uses: callbacks, controlled state, commits and undo.

  • Focus covers grids and other ways to move through a group.
  • Testing checks the component's keyboard behavior and accessibility.