Headless UI Components

Framework-free behavior cores with thin adapters for React, Vue, Svelte, and vanilla — plus the component registry and the admin Components panel.

Trellis ships UI affordances as headless behavior cores: framework-free, DOM-free state machines that run in Node (zero DOM), with thin adapters that bind them to React, Vue, Svelte, or vanilla. This is the "UI is a compiler for a visual runtime" convention (ADR 0034): behavior is written once and tested in Node; the framework layer is a mechanical ~100-line projection.

Why headless

  • Behavior is testable in Node. The palette's fuzzy filtering, the dialog's stack rules, the kanban's column bucketing — all verified in vitest with no browser.
  • One behavior, every framework. A core mounts identically under React, Vue, Svelte, and vanilla via its adapter. No per-framework reimplementation of playhead math, dialog stacking, or board bucketing.
  • Cores survive churn. Framework-adjacent code is the fragile part; framework-free cores are library code.

The bridge contract

Every core exposes the same minimal shared surface, plus domain-specific actions:

interface HeadlessCore<S> {
  /** Latest state — pull. Derived fields recomputed on read. */
  readonly state: S;
  /** Subscribe to mutations — push. Returns an unsubscribe. */
  subscribe(listener: () => void): () => void;
}

Actions are domain-specific (palette.actions.open(), dialog.actions.open(spec), combobox.actions.setQuery(q)), so adapters are mechanical: React uses useSyncExternalStore, Vue wraps state in reactive, Svelte gets a store contract, vanilla uses state/subscribe directly.

Available components

Each ships as trellis/<component> (core) plus trellis/<component>/react|vue|svelte|vanilla adapters.

ComponentImportBehavior
Palettetrellis/paletteCommand palette: fuzzy filter, grouping, keyboard selection
Dialogtrellis/dialogStacked dialog manager: focus trap, esc/backdrop policy, per-button resolve
Comboboxtrellis/comboboxAutocomplete state machine (fuzzy, highlight ranges, selection)
Kanbantrellis/kanbanDynamic board projection over entity rows (see below)
Tabletrellis/tableRich datatable: sort, filter, page, inline cell editing
Editortrellis/editorRich text document model + commands + undo
Formstrellis/formsSchema-derived form state machine (values, errors, dirty)
Uploadtrellis/uploadTransfer queue: concurrency, progress, cancel/retry, dedupe
Color pickertrellis/colorpickerDraft/commit picking, format persistence, contrast as core data
Undo historytrellis/undo-historyCommand stack with coalescing, shared by editor/table/kanban

Shared bridge furniture lives in trellis/headless (toSvelteStore, syncFromCore, fuzzy helpers).

Using a core headlessly

import { createPaletteCore } from "trellis/palette";

const palette = createPaletteCore({
  items: [
    { id: "new-task", label: "New task", keywords: ["create", "todo"] },
    { id: "sync", label: "Sync now", keywords: ["push", "pull"] },
  ],
});

palette.actions.setQuery("sync");
console.log(palette.state.results.map((r) => r.label)); // ['Sync now']

Render it however you like — the core only owns state. The vanilla adapter adds a subscribe-driven view; framework adapters handle re-rendering.

Kanban — dynamic board projection

trellis/kanban (createKanbanCore) generalizes the old static board: any entity attribute can drive the columns, columns are first-class (create, rename, reorder, sort, hide, color), and cards are entity rows. Moving a card writes the row's group attribute — one EQL-S entity write = one op, undoable and mergeable:

import { createKanbanCore } from "trellis/kanban";

const board = createKanbanCore({
  data: issues, // entity rows
  columns: taskColumns, // card-preview fields (TableColumn[])
  groupFields: statusField, // groupable surface fields
  groupFieldId: "status", // the field driving the columns
  board: { id: "issues", name: "Issues" },
  onCardMove: (rowId, columnValue) => updateEntity(rowId, { status: columnValue.value }), // one op
});

Component registry + admin panel

Every headless component is registered with the component registry (trellis/inspector), which pairs a core factory with metadata, action specs, and a vanilla renderer. trellis admin exposes it as the Components panel in the right inspector rail: pick a component, see live state JSON, drive its actions, and reset — a small Studio tool for exploring any core in isolation without an application context.

Where rendering lives

Headless cores own behavior, never presentation. Visual components consume theme tokens (CSS custom properties) from the renderer, not from cores. The canonical token set lives in the @trellis.computer/ui design-tokens package; the kernel's runtime surfaces (trellis admin) alias the same source.

The reusable Web Component rendering layer (<trellis-palette>, <trellis-kanban>, …) that wraps these cores is planned under @trellis.computer/ui — behavior stays in the kernel, rendering ships as one Web Component per affordance.