Build a Component with UI SDK

Build a reactions bar, then add state, arrow keys and accessibility one step at a time.

Let's build a reactions bar, like the one under a chat message. Press a reaction to add it, and press it again to take it back.

Press a reaction to toggle it. Focus one, then use Left and Right to move between them.

We'll start with the parts, then add one thing at a time: state, arrow keys and accessibility. The examples on this page are already styled so you can see each change. We'll write those styles near the end.

Install

Start by installing @ui-sdk/core and the package for your framework.

npm install @ui-sdk/core @ui-sdk/react

Define the Primitive

A primitive describes a component: its parts, its state and what it does. Create lib/reactions.ts and list the parts:

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

const reactions = definePrimitive({
  name: "reactions",
  state: () => ({}),
  api: () => ({}),
  slots: (slot) => ({
    root: slot({}),
    reaction: slot<{ value: string }>({
      props: { value: "string" },
      attrs: () => ({ type: "button" }),
    }),
  }),
})

export { reactions }
  • name names the component. The root's data-slot is the name itself, reactions, and every other part's starts with it, like reactions-reaction.
  • state and api are empty for now. We'll fill them in soon.
  • slots lists the parts: root around the bar, and a reaction for each button.
  • props lets each reaction take a value, like "heart", so the bar can tell them apart.

Render the Parts

createParts turns each slot into a component you can render. The object says which element each one renders: a <div> for the root and a <button> for each reaction. With web components, it names a custom element for each part instead.

components/message-reactions.tsx
"use client"

import { createParts } from "@ui-sdk/react/primitive"
import { reactions } from "@/lib/reactions"

const { Root: Reactions, Reaction } = createParts(reactions, {
  root: "div",
  reaction: "button",
})

function MessageReactions() {
  return (
    <Reactions>
      <Reaction value="thumbs-up">๐Ÿ‘</Reaction>
      <Reaction value="heart">โค๏ธ</Reaction>
      <Reaction value="laugh">๐Ÿ˜‚</Reaction>
      <Reaction value="celebrate">๐ŸŽ‰</Reaction>
    </Reactions>
  )
}

export { MessageReactions }

You'll see four buttons in a row. Each one has a data-slot, like data-slot="reactions-reaction", but pressing it does nothing yet.

Add State

Next, let's remember which reactions you picked. Four things change in lib/reactions.ts:

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

const reactions = definePrimitive({
  name: "reactions",
  state: () => ({ picked: [] as string[] }),
  api: (context) => ({
    toggle: (value: string) => {
      const { picked } = context.store.get()
      context.store.set({
        picked: picked.includes(value)
          ? picked.filter((item) => item !== value)
          : [...picked, value],
      })
    },
  }),
  slots: (slot) => ({
    root: slot({}),
    reaction: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => ({
        type: "button",
        "aria-pressed": String(state.picked.includes(part.key)),
      }),
      press: ({ api, key }) => api.toggle(key),
    }),
  }),
})

export { reactions }
  • state holds picked, the values of the reactions you picked. It starts empty.
  • api defines toggle, which adds a value to picked, or takes it out if it's already there.
  • press calls toggle with the reaction's own value. Inside a slot, key is the part's value.
  • attrs turns state into attributes. A picked reaction gets aria-pressed="true", which tells screen readers it's on and gives you something to style.
Press a reaction to pick it, and press it again to take it back. Then press Tab: focus stops on every reaction.

Move with the Arrow Keys

A row of buttons like this should be a single stop for the Tab key. Once you're in, the arrow keys move between them.

That's a behavior, one of the building blocks in @ui-sdk/core. createRovingFocus gives a group of parts one Tab stop and moves focus with the arrow keys. Add it to lib/reactions.ts:

lib/reactions.ts
import { createRovingFocus, definePrimitive } from "@ui-sdk/core"

const reactions = definePrimitive({
  name: "reactions",
  state: () => ({
    picked: [] as string[],
    focused: null as string | null,
  }),
  api: (context) => ({
    roving: createRovingFocus(context, {
      slot: "reaction",
      key: "focused",
      orientation: () => "horizontal",
    }),
    toggle: (value: string) => {
      const { picked } = context.store.get()
      context.store.set({
        picked: picked.includes(value)
          ? picked.filter((item) => item !== value)
          : [...picked, value],
      })
    },
  }),
  slots: (slot) => ({
    root: slot({}),
    reaction: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => ({
        type: "button",
        "aria-pressed": String(state.picked.includes(part.key)),
        tabindex: part.api.roving.tabIndex(state, part),
      }),
      press: ({ api, key }) => api.toggle(key),
      on: {
        keydown: (event, { el, api }) => {
          if (el) {
            api.roving.keydown(event, el)
          }
        },
      },
      bind: (el, part) => part.api.roving.bind(el, part),
    }),
  }),
})

export { reactions }
  • focused is a new piece of state. createRovingFocus keeps the value of the reaction with the Tab stop there.
  • slot and key say which parts to move between and where to keep the stop. orientation picks Left and Right.
  • tabIndex gives the stop 0 and every other reaction -1.
  • keydown moves focus when you press an arrow key, and bind joins each reaction to the group.
Press Tab to focus the bar, then use Left and Right to move. Home and End jump to the first and last reaction. Press Space to toggle.

Home and End come with createRovingFocus, and so does right to left: on a right-to-left page, Left and Right swap on their own.

Make It Accessible

The bar works with a mouse and a keyboard. Now let's tell screen readers what it is. A row of buttons with one Tab stop is a toolbar. Give the root that role:

lib/reactions.ts
import { createRovingFocus, definePrimitive } from "@ui-sdk/core"

const reactions = definePrimitive({
  name: "reactions",
  state: () => ({
    picked: [] as string[],
    focused: null as string | null,
  }),
  api: (context) => ({
    roving: createRovingFocus(context, {
      slot: "reaction",
      key: "focused",
      orientation: () => "horizontal",
    }),
    toggle: (value: string) => {
      const { picked } = context.store.get()
      context.store.set({
        picked: picked.includes(value)
          ? picked.filter((item) => item !== value)
          : [...picked, value],
      })
    },
  }),
  slots: (slot) => ({
    root: slot({
      role: "toolbar",
      keys: { none: "Each reaction handles its own arrow keys." },
    }),
    reaction: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => ({
        type: "button",
        "aria-pressed": String(state.picked.includes(part.key)),
        tabindex: part.api.roving.tabIndex(state, part),
      }),
      press: ({ api, key }) => api.toggle(key),
      on: {
        keydown: (event, { el, api }) => {
          if (el) {
            api.roving.keydown(event, el)
          }
        },
      },
      bind: (el, part) => part.api.roving.bind(el, part),
    }),
  }),
})

export { reactions }
  • role says what the part is. Core writes it for you.
  • keys says who handles the arrow keys. Each role asks for what its pattern needs, so a toolbar without keys is a type error. Here, each reaction handles its own keys, so none says why the root doesn't.

An emoji on its own doesn't make a good name, so give each reaction an aria-label, and give the bar one too:

components/message-reactions.tsx
"use client"

import { createParts } from "@ui-sdk/react/primitive"
import { reactions } from "@/lib/reactions"

const { Root: Reactions, Reaction } = createParts(reactions, {
  root: "div",
  reaction: "button",
})

function MessageReactions() {
  return (
    <Reactions aria-label="Reactions">
      <Reaction value="thumbs-up" aria-label="Thumbs up">
        ๐Ÿ‘
      </Reaction>
      <Reaction value="heart" aria-label="Heart">
        โค๏ธ
      </Reaction>
      <Reaction value="laugh" aria-label="Laugh">
        ๐Ÿ˜‚
      </Reaction>
      <Reaction value="celebrate" aria-label="Celebrate">
        ๐ŸŽ‰
      </Reaction>
    </Reactions>
  )
}

export { MessageReactions }

That's the bar at the top of this page. A screen reader announces it as the Reactions toolbar, then reads each reaction by name, and says whether it's pressed.

Style It

Parts render without styles. Use data-slot to pick a part, and aria-pressed to style a picked reaction:

[data-slot="reactions"] {
  display: flex;
  gap: 0.25rem;
  padding: 0.25rem;
  border: 1px solid #e5e5e5;
  border-radius: 999px;
}

[data-slot="reactions-reaction"] {
  border: 0;
  border-radius: 999px;
  background: transparent;
  padding: 0.375rem 0.625rem;
  font-size: 1.25rem;

  &:hover {
    background: #f5f5f5;
  }

  &[aria-pressed="true"] {
    background: #fde68a;
  }
}

These attributes are the same in every framework, so the same CSS styles all five versions.

Check It

checkPattern from @ui-sdk/core/testing checks a rendered component against its WAI-ARIA pattern. Pass it the bar's root element:

import { checkPattern } from "@ui-sdk/core/testing"

await checkPattern(root, "toolbar")

It checks the toolbar's role and that it has one Tab stop, then presses the keys: the arrows, Home and End. If something is wrong, it throws one error listing every problem.

Next

You've built a component from scratch: parts, state, a behavior and accessibility. Here's where to go from here: