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.
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/reactDefine the Primitive
A primitive describes a component: its parts, its state and what it does. Create lib/reactions.ts and list the parts:
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 }namenames the component. The root'sdata-slotis the name itself,reactions, and every other part's starts with it, likereactions-reaction.stateandapiare empty for now. We'll fill them in soon.slotslists the parts:rootaround the bar, and areactionfor each button.propslets each reaction take avalue, 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.
"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:
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 }stateholdspicked, the values of the reactions you picked. It starts empty.apidefinestoggle, which adds a value topicked, or takes it out if it's already there.presscallstogglewith the reaction's own value. Inside a slot,keyis the part'svalue.attrsturns state into attributes. A picked reaction getsaria-pressed="true", which tells screen readers it's on and gives you something to style.
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:
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 }focusedis a new piece of state.createRovingFocuskeeps the value of the reaction with the Tab stop there.slotandkeysay which parts to move between and where to keep the stop.orientationpicks Left and Right.tabIndexgives the stop0and every other reaction-1.keydownmoves focus when you press an arrow key, andbindjoins each reaction to the group.
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:
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 }rolesays what the part is. Core writes it for you.keyssays who handles the arrow keys. Each role asks for what its pattern needs, so a toolbar withoutkeysis a type error. Here, each reaction handles its own keys, sononesays 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:
"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: