# 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:

```ts title="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](/docs/core/kernel/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)`:

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`:

```ts
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](/docs/core/kernel/define-a-primitive#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.

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.

**React**

```tsx
"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 }
```

**Vue**

```vue
<script setup lang="ts">
import { createParts } from "@ui-sdk/vue/primitive"
import { rating } from "./rating"

const { Root: Rating, Star: RatingStar } = createParts(rating, {
  root: "div",
  star: "button",
})
</script>

<template>
  <Rating aria-label="Rating">
    <RatingStar
      v-for="star in 5"
      :key="star"
      :value="String(star)"
      :aria-label="`${star} ${star === 1 ? 'star' : 'stars'}`"
    >
      ★
    </RatingStar>
  </Rating>
</template>
```

**Svelte**

```svelte
<script lang="ts">
  import { createParts } from "@ui-sdk/svelte/primitive"
  import { rating } from "./rating"

  const { Root: Rating, Star: RatingStar } = createParts(rating, {
    root: "div",
    star: "button",
  })
</script>

<Rating aria-label="Rating">
  {#each Array.from({ length: 5 }, (_, index) => index + 1) as star (star)}
    <RatingStar
      value={String(star)}
      aria-label={`${star} ${star === 1 ? "star" : "stars"}`}
    >
      ★
    </RatingStar>
  {/each}
</Rating>
```

**Solid**

```tsx
import { createParts } from "@ui-sdk/solid/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
          value={String(index + 1)}
          aria-label={`${index + 1} ${index === 0 ? "star" : "stars"}`}
        >
          ★
        </RatingStar>
      ))}
    </Rating>
  )
}

export { Example }
```

**Web Components**

Register the parts from your app's browser entry point:

```ts
import { createParts, wrappedButton } from "@ui-sdk/web"
import { rating } from "./rating"

createParts(rating, {
  root: "my-rating",
  star: { tag: "my-rating-star", target: wrappedButton },
})
```

Then add the stars to your HTML. Each star's behavior connects to the native button inside it:

```html
<my-rating aria-label="Rating">
  <my-rating-star value="1"
    ><button aria-label="1 star">★</button></my-rating-star
  >
  <my-rating-star value="2"
    ><button aria-label="2 stars">★</button></my-rating-star
  >
  <my-rating-star value="3"
    ><button aria-label="3 stars">★</button></my-rating-star
  >
  <my-rating-star value="4"
    ><button aria-label="4 stars">★</button></my-rating-star
  >
  <my-rating-star value="5"
    ><button aria-label="5 stars">★</button></my-rating-star
  >
</my-rating>
```

Use `data-filled` to color the stars, the way [Style a Component](/docs/core/kernel/style-a-component#style-its-state) styled the copy button from `data-copied`:

```css
[data-slot="rating"] {
  display: flex;
  gap: 0.25rem;
}

[data-slot="rating-star"][data-filled],
[data-slot="rating-star"]:hover {
  color: #b45309;
}
```

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:

```ts
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`:

```ts {3-9,12}
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`:

```ts {7,11}
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`.

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:

```ts
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`:

```ts
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:

```ts
on: {
  keydown: (event, { el, api }) => {
    if (el) {
      api.keydown(event, el)
    }
  },
},
```

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:

```ts
// 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](/docs/core/testing) shows how.

## Complete Primitive

Here's everything we added, in one file:

**Show rating.ts**

```ts
// 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](/docs/core/kernel/handle-changes) goes further into the change pattern the rating uses: callbacks, controlled state, commits and undo.

- [Focus](/docs/core/behaviors/focus) covers grids and other ways to move through a group.
- [Testing](/docs/core/testing) checks the component's keyboard behavior and accessibility.
