Write a part with its own Vue logic and connect it to a primitive.

The parts from Rendering Primitives are enough until one needs its own logic. You can replace just that part and keep the same rating.ts definition from Compose Behaviors.

Write a Root Part

Here's a replacement for the generated RatingRoot. usePrimitive creates its instance, and usePart connects the root slot:

rating-root.ts
import { usePart, usePrimitive, type PartSlots } from "@ui-sdk/vue/primitive"
import { defineComponent, type HTMLAttributes } from "vue"
import { rating } from "./rating"

const RatingRoot = defineComponent(
  (
    props: { value?: number; defaultValue?: number } & HTMLAttributes,
    { emit }
  ) => {
    const handle = usePrimitive(rating, () => ({
      value: props.value,
      defaultValue: props.defaultValue,
      onValueChange(value, details) {
        emit("value-change", value, details)
        if (!details.canceled) {
          emit("update:value", value)
        }
      },
    }))
    return usePart(handle, "root", undefined, undefined, "div")
  },
  {
    inheritAttrs: false,
    props: ["value", "defaultValue"],
    emits: ["value-change", "update:value"],
    slots: Object as PartSlots<typeof rating, "root">,
  }
)

export { RatingRoot }

Call both helpers in setup. The options getter keeps values current; usePrimitive also shares the instance with child parts. usePart returns a render function that forwards attrs and slots. inheritAttrs: false avoids applying attrs twice.

HTMLAttributes makes native attrs available to TypeScript callers; only value and defaultValue are declared component props, so the other attrs still reach usePart.

This root supports v-model:value by emitting update:value after the change callback has had a chance to cancel. PartSlots types the default and #render slots. The adapter manages the instance's lifetime.

Connect a Child Part

Pass the primitive itself to usePart to find the instance shared by the root above it. This RatingStar works inside the manual root above or the generated RatingRoot:

rating-star.ts
import { usePart, type PartSlots } from "@ui-sdk/vue/primitive"
import { defineComponent, type ButtonHTMLAttributes } from "vue"
import { rating } from "./rating"

const RatingStar = defineComponent(
  (props: { value: string } & Omit<ButtonHTMLAttributes, "value">) => {
    return usePart(
      rating,
      "star",
      () => ({ value: props.value }),
      undefined,
      "button"
    )
  },
  {
    inheritAttrs: false,
    props: ["value"],
    slots: Object as PartSlots<typeof rating, "star">,
  }
)

export { RatingStar }

Use it in place of the generated star in Render the Rating. ButtonHTMLAttributes types its native attrs. The value getter identifies the star; the remaining props go to its button. Keep it inside RatingRoot and give it an aria-label.

Merge Props

usePart merges the component's attrs with the slot's. Slot attributes win, classes and styles combine, and your listeners run first. An id, or a name or typing hint you pass, like aria-label or autocomplete, replaces the slot's. Call preventDefault() in a listener to skip the built-in handler.

The last argument can be a tag, or an object with tag, children, and extend. Use extend for extra props after the slot's props, such as attributes supplied by a Field. Keep the slot's roles, IDs, and ARIA state intact.

Compose a Popup

A custom component can also use an existing Popover or Dialog. Compose Ours shows that connection with an emoji picker.