Give your agents the behaviors to build headless and accessible components, with keyboard, focus and screen reader support built in.

This section covers building your own components with @ui-sdk/core.

What it is

@ui-sdk/core is a TypeScript library of UI behaviors, the layers that make up a component library. It provides building blocks for keyboard navigation, focus, selection, popups, drag and drop, and the ARIA attributes those interactions need.

You combine those pieces to build your own components.

Let Me Show You

Here's an icon picker. This component is not available in @ui-sdk/react. An agent built this component by combining behaviors from @ui-sdk/core.

It has everything you expect: a popover that opens beside its button, arrow key navigation between icons, fuzzy search with ranked matches, and screen reader announcements.

import * as React from "react"
import {
  announce,
  createChange,
  createPopup,
  createRovingFocus,
  definePrimitive,
  popupContent,
  popupState,
  popupTrigger,
  selects,
  spatialMove,
  type ChangeDetails,
} from "@ui-sdk/core"
import {
  popoverPreset,
  type PopoverOpenChangeReason,
} from "@ui-sdk/core/popover"
import { fuzzyScore } from "@ui-sdk/core/text-match"
import { createParts } from "@ui-sdk/react/primitive"
import {
  BellIcon,
  BookIcon,
  BugIcon,
  CakeIcon,
  CalendarIcon,
  CameraIcon,
  CloudIcon,
  CodeIcon,
  CoffeeIcon,
  FlagIcon,
  FlameIcon,
  FlaskConicalIcon,
  FolderIcon,
  GiftIcon,
  GlobeIcon,
  HeartIcon,
  HouseIcon,
  LeafIcon,
  LightbulbIcon,
  LockIcon,
  MailIcon,
  MapPinIcon,
  MessageCircleIcon,
  MoonIcon,
  MountainIcon,
  MusicIcon,
  PaletteIcon,
  PencilIcon,
  PizzaIcon,
  PlaneIcon,
  RocketIcon,
  SparklesIcon,
  StarIcon,
  SunIcon,
  TargetIcon,
  TerminalIcon,
  TrophyIcon,
  WavesIcon,
  WrenchIcon,
  ZapIcon,
  type LucideIcon,
} from "lucide-react"

type IconPickerPickReason = "item-press" | "search-enter"

type IconPickerOptions = {
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (
    open: boolean,
    details: ChangeDetails<PopoverOpenChangeReason | "item-press">
  ) => void
  value?: string
  defaultValue?: string
  onValueChange?: (
    value: string,
    details: ChangeDetails<IconPickerPickReason>
  ) => void
}

type IconPickerState = ReturnType<typeof popupState> & {
  value: string | null
  query: string
  stop: string | null
  focused: string | null
  hovered: string | null
  highlighted: string | null
}

type IconProps = { value: string }

const iconPicker = definePrimitive({
  name: "icon-picker",
  options: {
    open: "boolean",
    defaultOpen: "boolean",
    onOpenChange: "callback",
    value: "string",
    defaultValue: "string",
    onValueChange: "callback",
  },
  state: (options: IconPickerOptions): IconPickerState => ({
    ...popupState(options),
    value: options.value ?? options.defaultValue ?? null,
    query: "",
    stop: null,
    focused: null,
    hovered: null,
    highlighted: null,
  }),
  sync: (options) => ({
    ...(options.open === undefined ? {} : { open: options.open }),
    ...(options.value === undefined ? {} : { value: options.value }),
  }),
  api: (context) => {
    const { store } = context
    const popup = createPopup(context, { preset: popoverPreset })
    const change = createChange(context)
    let searched = false
    const highlightBest = () =>
      store.set({
        highlighted: store.get().query.trim()
          ? (roving.items()[0]?.key ?? null)
          : null,
      })
    const roving = createRovingFocus<IconPickerState, IconProps>(context, {
      slot: "icon",
      key: "stop",
      move: spatialMove(),
      onItemsChange: () => {
        if (!searched) {
          return
        }
        const items = roving.items()
        highlightBest()
        announce(
          items.length === 0
            ? "No icons found"
            : items.length === 1
              ? "1 icon"
              : `${items.length} icons`
        )
      },
    })

    let wasOpen = store.get().open
    store.subscribe(() => {
      const { open, value } = store.get()
      if (open === wasOpen) {
        return
      }
      wasOpen = open
      if (!open) {
        return
      }
      searched = false
      store.set({ query: "", stop: value, hovered: null, highlighted: null })
    })

    const api = {
      ...popup,
      roving,
      setHovered: (icon: string | null) => store.set({ hovered: icon }),
      setFocused: (icon: string | null) => store.set({ focused: icon }),
      search(query: string) {
        searched = true
        store.set({ query })
        requestAnimationFrame(highlightBest)
      },
      pick(icon: string, reason: IconPickerPickReason, event?: Event) {
        change("value", icon, { reason, event })
        popup.close({ reason: "item-press", event })
      },
      searchKeydown(event: KeyboardEvent) {
        const first = roving.items()[0]
        if (event.key === "ArrowDown" && first) {
          event.preventDefault()
          roving.focus(first.key)
        } else if (event.key === "Enter" && first && store.get().query.trim()) {
          event.preventDefault()
          api.pick(first.key, "search-enter", event)
        }
      },
      iconKeydown(event: KeyboardEvent, el: HTMLElement) {
        const search = context.element("search") as HTMLInputElement | null
        const typing =
          event.key.length === 1 &&
          event.key !== " " &&
          !event.ctrlKey &&
          !event.metaKey &&
          !event.altKey
        if (
          (event.metaKey || event.ctrlKey) &&
          event.key.toLowerCase() === "a"
        ) {
          event.preventDefault()
          search?.focus()
          search?.select()
        } else if (typing) {
          search?.focus()
        } else {
          roving.keydown(event, el)
        }
      },
    }
    return api
  },
  slots: (slot) => ({
    trigger: popupTrigger(slot),
    content: popupContent(slot),
    search: slot({
      attrs: (state) => ({
        type: "search",
        value: state.query,
        autocomplete: "off",
      }),
      on: {
        input: (event, { api }) =>
          api.search((event.target as HTMLInputElement).value),
        keydown: (event, { api }) => api.searchKeydown(event),
      },
    }),
    grid: slot<{}, { query: string }>({
      attrs: () => ({ role: "group" }),
      state: (state) => ({ query: state.query }),
      on: { pointerleave: (_event, { api }) => api.setHovered(null) },
    }),
    icon: slot<IconProps>({
      props: { value: "string" },
      selects: selects({ stop: ({ key }) => [key, null] }),
      attrs: (state, part) => ({
        type: "button",
        tabindex: part.api.roving.tabIndex(state, part),
        "aria-pressed": String(state.value === part.key),
        "data-highlighted":
          state.focused === null && state.highlighted === part.key
            ? ""
            : undefined,
      }),
      press: ({ api, key }, event) => api.pick(key, "item-press", event),
      on: {
        keydown: (event, { el, api }) => api.iconKeydown(event, el!),
        pointerenter: (_event, { api, key }) => api.setHovered(key),
        focus: (_event, { api, key }) => api.setFocused(key),
        blur: (_event, { api }) => api.setFocused(null),
      },
      bind: (el, part) => part.api.roving.bind(el, part),
    }),
    preview: slot<{}, { active: string | null }>({
      state: (state) => ({
        active:
          state.hovered ?? state.focused ?? state.highlighted ?? state.value,
      }),
    }),
  }),
})

const parts = createParts(iconPicker, {
  trigger: "button",
  content: "div",
  search: "input",
  grid: "div",
  icon: "button",
  preview: "div",
})

const IconPicker = parts.Root

const IconPickerTrigger = parts.Trigger

const IconPickerContent = parts.Content

const IconPickerSearch = parts.Search

const IconPickerGrid = parts.Grid

const IconPickerIcon = parts.Icon

const IconPickerPreview = parts.Preview

type Entry = { name: string; icon: LucideIcon; keywords: string }

const icons: Entry[] = [
  { name: "Rocket", icon: RocketIcon, keywords: "launch ship fast" },
  { name: "Star", icon: StarIcon, keywords: "favorite rating" },
  { name: "Heart", icon: HeartIcon, keywords: "love like" },
  { name: "Flag", icon: FlagIcon, keywords: "goal milestone report" },
  { name: "Zap", icon: ZapIcon, keywords: "lightning fast energy" },
  { name: "Flame", icon: FlameIcon, keywords: "fire hot trending" },
  { name: "Sparkles", icon: SparklesIcon, keywords: "magic new shiny" },
  { name: "Sun", icon: SunIcon, keywords: "day light weather" },
  { name: "Moon", icon: MoonIcon, keywords: "night dark sleep" },
  { name: "Cloud", icon: CloudIcon, keywords: "weather sky upload" },
  { name: "Leaf", icon: LeafIcon, keywords: "nature plant green" },
  { name: "Mountain", icon: MountainIcon, keywords: "hike peak outdoors" },
  { name: "Waves", icon: WavesIcon, keywords: "ocean sea water beach" },
  { name: "Coffee", icon: CoffeeIcon, keywords: "cafe drink morning break" },
  { name: "Pizza", icon: PizzaIcon, keywords: "food lunch" },
  { name: "Cake", icon: CakeIcon, keywords: "birthday party celebrate" },
  { name: "Gift", icon: GiftIcon, keywords: "present birthday" },
  { name: "Music", icon: MusicIcon, keywords: "song audio playlist" },
  { name: "Camera", icon: CameraIcon, keywords: "photo picture" },
  { name: "Book", icon: BookIcon, keywords: "read docs library" },
  { name: "Pencil", icon: PencilIcon, keywords: "edit write draft" },
  { name: "Palette", icon: PaletteIcon, keywords: "design color paint" },
  { name: "Code", icon: CodeIcon, keywords: "developer programming" },
  { name: "Terminal", icon: TerminalIcon, keywords: "console command shell" },
  { name: "Bug", icon: BugIcon, keywords: "issue error insect" },
  {
    name: "Flask",
    icon: FlaskConicalIcon,
    keywords: "experiment lab science test",
  },
  { name: "Lightbulb", icon: LightbulbIcon, keywords: "idea tip" },
  { name: "Wrench", icon: WrenchIcon, keywords: "settings tools fix" },
  { name: "Folder", icon: FolderIcon, keywords: "files directory project" },
  { name: "Mail", icon: MailIcon, keywords: "email letter" },
  { name: "Message", icon: MessageCircleIcon, keywords: "chat comment bubble" },
  { name: "Bell", icon: BellIcon, keywords: "notification alert" },
  { name: "Calendar", icon: CalendarIcon, keywords: "date schedule event" },
  { name: "Globe", icon: GlobeIcon, keywords: "world web internet" },
  { name: "Pin", icon: MapPinIcon, keywords: "location place map" },
  { name: "Plane", icon: PlaneIcon, keywords: "travel flight trip" },
  { name: "House", icon: HouseIcon, keywords: "home" },
  { name: "Trophy", icon: TrophyIcon, keywords: "win award prize" },
  { name: "Target", icon: TargetIcon, keywords: "goal aim focus" },
  { name: "Lock", icon: LockIcon, keywords: "secure private password" },
]

const byName = new Map(icons.map((entry) => [entry.name, entry]))

function search(query: string) {
  if (!query.trim()) {
    return icons
  }
  return icons
    .map((entry) => ({
      entry,
      score: Math.max(
        fuzzyScore(entry.name, query),
        fuzzyScore(entry.keywords, query) * 0.9
      ),
    }))
    .filter(({ score }) => score > 0)
    .sort((a, b) => b.score - a.score)
    .map(({ entry }) => entry)
}

function Example() {
  const [value, setValue] = React.useState<string | null>(null)
  const Picked = value ? byName.get(value)!.icon : null
  return (
    <IconPicker
      value={value ?? undefined}
      onValueChange={(next: string) => setValue(next)}
    >
      <IconPickerTrigger>
        {Picked ? (
          <>
            <Picked />
            {value}
          </>
        ) : (
          "Pick an Icon"
        )}
      </IconPickerTrigger>
      <IconPickerContent sideOffset={6} aria-label="Choose an icon">
        <IconPickerSearch
          aria-label="Search icons"
          placeholder="Search icons…"
        />
        <IconPickerGrid aria-label="Icons">
          {({ query }) => {
            const results = search(query)
            if (results.length === 0) {
              return <p data-slot="hint">No icons found.</p>
            }
            return results.map(({ name, icon: Icon }) => (
              <IconPickerIcon key={name} value={name} aria-label={name}>
                <Icon />
              </IconPickerIcon>
            ))
          }}
        </IconPickerGrid>
        <IconPickerPreview>
          {({ active }) => {
            const entry = active ? byName.get(active) : undefined
            if (!entry) {
              return "Pick an icon"
            }
            const Icon = entry.icon
            return (
              <>
                <Icon />
                <span>{entry.name}</span>
              </>
            )
          }}
        </IconPickerPreview>
      </IconPickerContent>
    </IconPicker>
  )
}

export {
  Example,
  iconPicker,
  IconPicker,
  IconPickerContent,
  IconPickerGrid,
  IconPickerIcon,
  type IconPickerOptions,
  IconPickerPreview,
  IconPickerSearch,
  type IconPickerState,
  IconPickerTrigger,
  type IconProps,
}
Click Pick an Icon to open the picker, then click an icon or type to search. The best match is highlighted, so Enter picks it. Press Down to enter the results and use the arrow keys to move.

The icon picker is built from five pieces of @ui-sdk/core, which work together to provide the behavior:

  • A popover. createPopup with the popover preset opens the picker beside its button, moves focus to the search field, and closes on Escape or a click outside. Picking an icon closes it and puts focus back on the button.
  • One Tab stop. createRovingFocus lets Tab enter the grid once. The arrow keys move between icons, and the next Tab leaves it. You don't have to tab through every button.
  • Arrows that follow the layout. spatialMove chooses the nearest icon in the direction you press. Down still goes down when the grid fits fewer columns on a small screen.
  • Search results. fuzzyScore ranks each icon's name and keywords against what you type. "idea" finds the lightbulb, and "launch" finds the rocket.
  • Announcements. announce tells screen readers how many icons match what you type.

The picker adds the connections between them, like moving focus from the search field into the grid when you press Down.

How It Fits Together

The picker keeps state, like whether it's open, the search query and the focused icon. It has parts, like the button that opens it, the search field, the grid and each icon. A primitive puts that state, those parts and their behaviors together in one definition.

You write the primitive once. Framework adapters render its parts in React, Vue, Svelte, Solid and Web Components. The behavior stays in @ui-sdk/core, and each adapter connects it to your framework.

The parts are unstyled. They expose attributes and CSS variables for styling. You can style them how you want.

Why It Exists

Component libraries cover familiar patterns, like menus, dialogs and tabs. Your product may need a kanban board, a timeline you can scrub, or a picker made for one task.

You can build the interface, but the interactions take work. Focus needs somewhere to go when an item disappears. A drag needs a keyboard alternative. Closing a popup needs to return you to where you were.

Agents face the same problems when they generate interfaces. @ui-sdk/core gives you and your agents tested behaviors to build on, so each new component can reuse that work.

You still choose how the pieces fit and give the component its names, roles and instructions. The testing kit in @ui-sdk/core helps you check the result, including keyboard behavior, ARIA relationships and server-rendered attributes.

Build Your First Component

Start with Build a Component. You'll build a toggle with one piece of state and one button, render it in your framework, and add styles.

Then the Core guides take each step further: Define a Primitive, Render a Primitive and Style a Component. Compose Behaviors brings several behaviors together in a star rating, and Handle Changes lets the app control and respond to state.

From there:

  • Behaviors helps you find the pieces for what you're building.
  • Reference lists the functions and types when you need the details.