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.
| Component | Import | Behavior |
|---|---|---|
| Palette | trellis/palette | Command palette: fuzzy filter, grouping, keyboard selection |
| Dialog | trellis/dialog | Stacked dialog manager: focus trap, esc/backdrop policy, per-button resolve |
| Combobox | trellis/combobox | Autocomplete state machine (fuzzy, highlight ranges, selection) |
| Kanban | trellis/kanban | Dynamic board projection over entity rows (see below) |
| Table | trellis/table | Rich datatable: sort, filter, page, inline cell editing |
| Editor | trellis/editor | Rich text document model + commands + undo |
| Forms | trellis/forms | Schema-derived form state machine (values, errors, dirty) |
| Upload | trellis/upload | Transfer queue: concurrency, progress, cancel/retry, dedupe |
| Color picker | trellis/colorpicker | Draft/commit picking, format persistence, contrast as core data |
| Undo history | trellis/undo-history | Command 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.