UI components
UI components
Bolt ships collection surfaces — CollectionTable , CollectionKanban , and CollectionForm — that read and write a collection with zero wiring: schema-derived, policy-filtered, and live by default . This page covers the surfaces, the +representation.svelte override, and how custom data types get their own rendering.
CollectionTable
A table is the default view of a collection. It requires an explicit columns snippet — table UI does not auto-derive columns from the model — and takes the client from the generated surface context:
<CollectionTable collection="tasks">
{#snippet columns({ Column })}
<Column name="title" />
<Column name="status" />
<Column name="assignee" />
{/snippet}
</CollectionTable>
Notable props:
query— a reactive query: filters, sorts, search, pagination apply on top of the collectionview— names this surface. Two tables over the same collection in one app need distinct viewsfeatures—{ search, filter, create }toggles the toolbar affordancesexportPipelines/importPipelines— run the collection’s pipelines from the toolbar, with selection-aware disable reasonsintegrations— inline status badges for the collection’s integrationsrowActions— per-row action snippetsListCard— mobile card override; omitted, cards derive from the column rolesselectable,disabled,title,description,emptyPlaceholder,
CollectionKanban
A kanban groups the collection by one field and moves records between lanes with an optimistic write:
<CollectionKanban collection="tasks" groupBy="status" />
Notable props:
groupBy— the field lanes are built from (required)lanes— lane subset, order, labels, and colors; omitted, lanes derive from the fieldrows— visual lane rows, for multi-row boardsonCardMove— move handler; the default writes the target lane into thegroupByfield optimistically and rolls back on failureCard— card snippet overridequery,view,selectable, and the same pipeline props as the table
CollectionForm
A form creates or edits one record through the same policy-filtered mutation path as every other client. It owns validation, editable-field selection, and the submit lifecycle:
<CollectionForm collection="tasks" defaultValues={selectedRow} />
Notable props:
defaultValues— the row being edited, or a partial seed for a new record: a value carrying the row key is an update, anything else is a draft. There is deliberately norecordIdprop.validation—{ schema, semantic }: a standard schema for field validation (including refinements) plus an async semantic check for cross-field rulesfields— an ordered field pick for the auto-emitted form; omitted, every writable field is emitted in declaration orderchildren— a full field composition snippet with a form controller, when the auto layout is not enoughonSubmit,onAfterSubmit,deleteAction,disabled,loading
Field-level renderer props let one field use a custom control without leaving the form. Submits land through client.db , so affected live queries re-evaluate locally — there is no query invalidation or refetch anywhere in this path.
The override: +representation.svelte
Schema-derived create, display, and edit are the defaults. When they are not enough, one collection-owned file overrides all three modes at once:
src/collections/<collection>/+representation.svelte <script lang="ts">
import type { RepresentationProps } from './$types.js';
let { record, close }: RepresentationProps = $props();
</script>
{#if record === null}
{!-- create mode --}
<!-- display/edit mode: record is the row -->
{record.title}
{/if}
- Generated
RepresentationPropsarrive from the adjacent./$types.js:{ record, close, refresh }, whererecord: Row | null—nullis create, a row is display/edit. - There is no separate create role and no call-site registration: table, kanban, and detail views resolve the same file from the generated static map.
- Keep editable controls inside the form. Do not repeat the same editable fact in a read-only summary. Shared presentation belongs in ordinary adjacent Svelte components, not the representation.
+representation.svelte only for a genuine override.Custom data types
When a domain value has more shape than a scalar column — a repayment schedule, a point in the site model, an address — it becomes a custom type : one schema authority plus one renderer, declared as a directory under src/custom-types/ .
src/datatypes/
└── site_coordinates/
├── +definition.ts # schema — the only source of truth for the value
└── +renderer.svelte # required — how the value renders and edits The definition default-exports defineCustomType with a schema — or a schema factory whose options flow through to the model:
// src/datatypes/site_coordinates/+definition.ts
import { defineCustomType } from '@norbital-ai/bolt/authoring';
import { Schema } from 'effect';
export default defineCustomType({
name: 'site_coordinates',
description:
'An x, y, and z point in the site model, with any axis that was never surveyed left empty.',
schema: Schema.Struct({
x: Schema.NullOr(Schema.Number),
y: Schema.NullOr(Schema.Number),
z: Schema.NullOr(Schema.Number)
})
});
A model uses the type with custom('<name>') ; the schema factory infers its optional options argument:
// src/collections/projects/+model.ts
import { defineModel, custom, text } from '@norbital-ai/bolt/authoring';
export default defineModel(
{
name: text().notNull(),
coordinates: custom('site_coordinates')
},
{ description: 'Construction project', recordLabel: 'name' }
);
- Custom values are stored as JSONB and validated at the boundary.
- The platform already owns
moneyandinstant_range— access both through custom('<name>') instead of redeclaring them; a datatype that shadows a platform name is a compile error. Thecustom('money')call uses the same validation and renderer path and can narrow its currency list. - The definition is the single inferred value type. Never cast it — the schema is what validates the data.
Renderers
The +renderer.svelte is the only UI for the value. It receives a discriminated RendererProps prop from its own generated ./$types.js :
- display —
{ mode: 'display', field, value }, for tables, kanban cards, and details - edit —
{ mode: 'edit', field, value, disabled, onValueChange }, for forms;onValueChangereports edits
<!-- src/datatypes/site_coordinates/+renderer.svelte -->
<script lang="ts">
import type { RendererProps } from './$types.js';
let props: RendererProps = $props();
</script>
{#if props.mode === 'display'}
{props.value ? `(${props.value.x ?? '—'}, ${props.value.y ?? '—'}, ${props.value.z ?? '—'})` : '—'}
{:else}
<input
type="number"
value={props.value?.x ?? ''}
disabled={props.disabled}
onchange={(e) => props.onValueChange({ ...props.value, x: Number(e.currentTarget.value) })}
/>
{/if}
Renderers are discovered statically by the compiler — there is no registration step, and a missing renderer is a compile error. A renderer override may also be applied per field inside a CollectionForm composition, for one-off fields that do not deserve a full custom type.
Composition rules
- Surfaces render inside the app body region — see Layout .
- Record navigation — clicking a row opens the detail stack — is handled by the shell; see Navigation state.
- All reads and writes go through the live data layer .
Related guides
- Layout — the app body contract surfaces render inside
- Apps — compose surfaces inside tenant application entry components
- Collections — define the models surfaces read and write
- Live data — the live queries and optimistic writes behind every surface