Build a toggle button, then render the same primitive in your framework.

Let's start with a toggle button. Press Mute to turn it on. Press it again to turn it off.

Click Mute, or focus it and press Enter or Space. Its pressed state changes with each press.

We'll define its behavior, connect it to your framework, and add styles.

Install

Start by installing @ui-sdk/core.

npm install @ui-sdk/core

Define a Primitive

A primitive describes a component's behavior: its state, the functions that change it, and the parts that use it. It doesn't render anything on its own.

Ours is the mute button's. It has one piece of state, muted, which is true when mute is on, and one part.

lib/mute-button.ts
import { definePrimitive } from "@ui-sdk/core"

const muteButton = definePrimitive({
  name: "mute-button",
  state: () => ({ muted: false }),
  api: (context) => ({
    toggle: () => context.store.set({ muted: !context.store.get().muted }),
  }),
  slots: (slot) => ({
    root: slot({
      attrs: (state) => ({
        type: "button",
        "aria-pressed": String(state.muted),
      }),
      press: ({ api }) => api.toggle(),
    }),
  }),
})

export { muteButton }
  • state starts muted at false.
  • api defines toggle, which switches muted between true and false.
  • slots describes the component's parts. Our only slot is root, which becomes the button when we render it.
  • attrs turns state into HTML attributes. When muted is true, the root gets aria-pressed="true".
  • press calls toggle when the root is activated.

That's the whole behavior. Define a Primitive goes through each field in more detail. Now let's put it on the page.

Render It

An adapter connects the primitive to your framework. Choose your framework in the page header, then install its package.

npm install @ui-sdk/react

First, create the component from the muteButton primitive.

components/ui/mute-button.tsx
"use client"

import { createParts } from "@ui-sdk/react/primitive"
import { muteButton } from "@/lib/mute-button"

const { Root: MuteButton } = createParts(muteButton, { root: "button" })

export { MuteButton }

{ root: "button" } says the root part of MuteButton renders a <button>. Then import it wherever you want to use it:

pages/index.tsx
import { MuteButton } from "@/components/ui/mute-button"

function Example() {
  return <MuteButton>Mute</MuteButton>
}

Because we use a native <button>, clicks, Enter and Space all reach the primitive's press handler. Screen readers get its pressed state from aria-pressed.

The behavior stays in mute-button.ts. Changing frameworks changes how you render it, but the behavior stays the same. Render a Primitive covers options, state in your content and server rendering.

Style the Component

Parts render without styles. Here's a complete look for the button, with hover, focus and pressed states:

[data-slot="mute-button"] {
  border: 1px solid #e5e5e5;
  border-radius: 0.5rem;
  background: white;
  padding: 0.375rem 0.75rem;
  font-size: 0.875rem;

  &:hover {
    background: #f5f5f5;
  }

  &:focus-visible {
    outline: 2px solid #a3a3a3;
    outline-offset: 2px;
  }

  &[aria-pressed="true"] {
    background: #171717;
    color: white;
  }
}

The pressed look comes from aria-pressed. While muted is true, the root part renders aria-pressed="true", from the attrs you wrote when you defined the primitive. In the CSS tab, the &[aria-pressed="true"] rule selects it. In the Tailwind tab, the aria-pressed: classes do the same.

data-slot identifies the part, and these attributes are the same in every framework, so this CSS works for all five versions. Style a Component covers more state attributes, CSS variables and parts that come and go.

Next

You've defined a primitive, rendered it and styled it. The Core guides take each step further:

Or start from what's already built: