Components
Props, types, and one quick example per component.
import {
Page, Section, Stack, Grid, Card, Toolbar, Fragment,
Text, Stat, Divider, Badge, EmptyState, Table,
Button, SubmitButton, ResetButton, Link,
Form, Field, HiddenInput, NumberInput, DateInput, DateTimeInput,
TextInput, Textarea, Select, Checkbox, Toggle,
} from "@backfill-io/sdk/ui";
Props are validated server-side. Passing an unknown prop, the wrong type, or omitting a required prop throws at render time.
Layout
<Page>
Top-level container for every render script. Renders title, optional description, optional host-header toolbar, and a children slot.
| Prop | Type | Notes |
|---|---|---|
title | string | Required. Browser title and page header. |
description | string | Subtitle below the page header. |
toolbar | node | Slot for a <Toolbar> of header actions. |
children | node | Page body. |
<Page title="Reconciliation" description="Match Stripe charges to invoices.">
<Section>...</Section>
</Page>
<Section>
Visual grouping with optional title and description. Use multiple sections to break a page into named regions.
| Prop | Type | Notes |
|---|---|---|
title | string | Optional section heading. |
description | string | Subtitle below the heading. |
children | node | Section body. |
<Section title="Open invoices" description="Last 30 days.">
<Table ... />
</Section>
<Stack>
Flex container. Use for stacking children with consistent gaps.
| Prop | Type | Default |
|---|---|---|
direction | "row" | "column" | "column" |
gap | "xs" | "sm" | "md" | "lg" | "md" |
span | 1 - 12 | Optional grid column span on medium screens and up. |
align | "start" | "center" | "end" | "stretch" | Optional cross-axis alignment. |
justify | "start" | "center" | "end" | "between" | Optional main-axis distribution. |
self | "start" | "center" | "end" | "stretch" | Optional alignment of this stack inside a parent grid. |
wrap | boolean | Allow row children to wrap. |
children | node | — |
<Stack direction="row" gap="sm">
<Badge tone="success">Live</Badge>
<Text tone="muted">Synced 2 minutes ago</Text>
</Stack>
Use justify="end" and self="center" for row action groups in dense grids:
<Stack direction="row" span={3} justify="end" self="center" gap="xs">
<Button action="bump-hours">+.25</Button>
<Button action="delete-entry" variant="dangerLink">Delete</Button>
</Stack>
<Grid>
Fixed-column responsive grid.
| Prop | Type | Default |
|---|---|---|
columns | 1 - 12 | 1 |
gap | "xs" | "sm" | "md" | "lg" | "md" |
children | node | — |
<Grid columns={3} gap="md">
<Card><Stat label="Pending" value={pendingCount} /></Card>
<Card><Stat label="Reconciled" value={doneCount} /></Card>
<Card><Stat label="Errors" value={errorCount} /></Card>
</Grid>
<Card>
Bordered container with optional title.
| Prop | Type | Notes |
|---|---|---|
title | string | Card header. |
href | string | Optional same-origin destination; makes the card container clickable. |
tone | "default" | "subtle" | Optional background/border treatment. |
density | "default" | "compact" | Optional tighter padding. |
children | node | Card body. |
<Card title="This month">
<Stat label="Revenue" value="$12,400" />
</Card>
Use href when a repeated card should open a detail page. Nested buttons still
invoke their own actions.
<Card href={Routes.page(ctx, "work-week-detail", { query: { id: week.id } })}>
<Text>{week.customerName}</Text>
<Button action="delete-week" variant="dangerLink" params={{ workWeekId: week.id }}>
Delete Week
</Button>
</Card>
Use tone="subtle" for lightweight form grouping without adding custom CSS:
<Card tone="subtle" density="compact">
<Form action="save-line">...</Form>
</Card>
<Toolbar>
Container for action buttons, typically passed to <Page toolbar={...}>.
| Prop | Type |
|---|---|
children | node |
<Page
title="Vendors"
toolbar={<Toolbar><Link href="/vendors/new">New vendor</Link></Toolbar>}
>
...
</Page>
<Fragment>
Render multiple children without a wrapping element. Returned implicitly when you write <>...</>.
<Fragment>
<Text>First line</Text>
<Text>Second line</Text>
</Fragment>
Display
<Text>
Plain text with optional tone.
| Prop | Type | Default |
|---|---|---|
tone | "default" | "muted" | "default" |
children | node | — |
<Text tone="muted">No matches found.</Text>
<Stat>
Big-number display. Both label and value accept strings, numbers, or booleans.
| Prop | Type | Notes |
|---|---|---|
label | string | number | boolean | Required. |
value | string | number | boolean | Required. |
tone | "default" | "good" | "bad" | "warning" | "neutral" | Optional value color. |
children | node | Optional caption beneath the value. |
<Stat label="Outstanding AR" value="$48,210" tone="warning">
<Text tone="muted">across 12 invoices</Text>
</Stat>
<Divider>
Section separator with an optional centered label.
| Prop | Type | Notes |
|---|---|---|
label | string | Optional. |
<Divider label="Open tasks" />
<Badge>
Small colored chip.
| Prop | Type | Default |
|---|---|---|
tone | "info" | "success" | "warning" | "error" | "neutral" | "neutral" |
children | node | — |
<Badge tone="warning">Needs review</Badge>
<EmptyState>
Placeholder for empty result sets.
| Prop | Type | Notes |
|---|---|---|
title | string | Required. |
description | string | Optional supporting copy. |
{rows.length === 0
? <EmptyState title="No invoices yet" description="They'll show up once Stripe sends the first webhook." />
: <Table rows={rows} columns={cols} />}
<Table>
Tabular data. Pass an array of plain-object rows and a column descriptor.
| Prop | Type | Notes |
|---|---|---|
columns | TableColumn[] | Required. |
rows | Record<string, unknown>[] | Required. |
type TableColumn = {
key: string; // matches a key on each row
label: string;
align?: "start" | "center" | "end";
cell?:
| { kind: "badge"; tone?: BadgeTone }
| { kind: "link"; hrefKey: string; labelKey?: string }
| { kind: "action"; action: string; label?: string; labelKey?: string; params?: Record<string, string | number | boolean>; paramsKey?: string }
| { kind: "money"; currency?: string; currencyKey?: string }
| { kind: "date" };
};
import { Customer } from "@backfill-io/sdk";
<Table
rows={Customer.query({ limit: 50 })}
columns={[
{ key: "name", label: "Customer" },
{ key: "status", label: "Status", cell: { kind: "badge" } },
{ key: "balance", label: "Balance", align: "end", cell: { kind: "money", currency: "USD" } },
{ key: "href", label: "Open", cell: { kind: "link", hrefKey: "href", labelKey: "name" } },
{ key: "deleteLabel", label: "Actions", cell: { kind: "action", action: "delete-customer", params: { customerId: "id" } } },
]}
/>
Interaction
<Button>
Triggers a UI action declared in ui.actions[]. The action prop is the action’s id.
| Prop | Type | Notes |
|---|---|---|
action | string | Required. The action id. |
variant | "button" | "link" | "dangerLink" | Optional visual treatment. Defaults to "button". |
params | Record<string, string | number | boolean> | Optional action-specific params, exposed as ctx.action.params. |
children | node | Button label. |
<Button action="delete-entry" variant="dangerLink" params={{ entryId: entry.id }}>Delete</Button>
For navigation, use <Link>. <Button> is for invoking server-side action handlers.
<SubmitButton>
Submits the surrounding <Form>. Optionally invokes a different action than the form’s default.
| Prop | Type | Notes |
|---|---|---|
action | string | Optional override of the form’s action id. |
params | Record<string, string | number | boolean> | Optional action-specific params, exposed as ctx.action.params. |
children | node | Button label. |
<Form action="save-customer">
<Field name="name" label="Name"><TextInput name="name" /></Field>
<SubmitButton params={{ customerId: existing.id }}>Save</SubmitButton>
</Form>
<ResetButton>
Resets the surrounding form to its initial values. No props beyond children.
<ResetButton>Reset</ResetButton>
<Link>
Navigation link to another URL — extension page, dashboard route, or external.
| Prop | Type | Notes |
|---|---|---|
href | string | Required. |
variant | "link" | "button" | "primary" | Optional visual treatment. |
mode | "link" | "navigate" | "patch" | Optional navigation behavior. Defaults to plain browser navigation. |
children | node | Link text. |
<Link href="/nexus-addresses">Back to nexus addresses</Link>
Use mode="patch" for same-page query-param changes like tabs and filters. The
host renders this as a LiveView patch, so the URL and page content update without
a full browser reload.
<Link
href={Routes.page(ctx, "work-weeks", { query: { status: "closed" } })}
mode="patch"
variant="button"
>
Closed
</Link>
Use mode="navigate" for same-origin page-to-page navigation when you want
LiveView navigation semantics. LiveView modes require a same-origin href;
external links must use the default "link" mode.
Forms
<Form>
Wraps form fields and binds them to a UI action handler.
| Prop | Type | Notes |
|---|---|---|
action | string | Required. The action id to invoke on submit. |
cancelAction | string | Optional cancel-action id. |
cancelHref | string | Optional cancel link. |
autoSubmit | "input" | "change" | Optional. Invoke the form action automatically when a field emits that event. |
debounceMs | number | Optional debounce for autosubmit. Defaults to 500 for "input" and 0 for "change". |
children | node | Field rows + submit button. |
<Form action="save-vendor" cancelHref="/vendors">
<Field name="name" label="Vendor name" required>
<TextInput name="name" />
</Field>
<SubmitButton>Create</SubmitButton>
</Form>
Form fields are serialized into ctx.values. Button-specific params are not
merged into ctx.values; they are available at ctx.action.params, which keeps
route params, clicked-button params, and editable form values from colliding.
Use autoSubmit="change" for inline edit rows where a visible Save button would
be noise. The action can return refresh() to re-render the current page without
navigating.
<Form action="update-time-entry" autoSubmit="change">
<HiddenInput name="entryId" value={entry.id} />
<Field name="hours" label="Hours">
<NumberInput defaultValue={entry.hours} min={0} step={0.25} />
</Field>
</Form>
<Field>
Wraps a single input with a label, help text, and validation rules.
| Prop | Type | Notes |
|---|---|---|
name | string | Required. Form field name (key in ctx.values). |
label | string | Required. |
helpText | string | Subtitle under the field. |
required | boolean | Validate non-empty before invoking the action. |
minLength | number | String length validation. |
maxLength | number | String length validation. |
pattern | string | Regex string the value must match. |
span | 1 - 12 | Optional grid column span on medium screens and up. |
labelHidden | boolean | Visually hide the label while keeping it available to assistive tech. |
children | node | The input — HiddenInput, NumberInput, DateInput, DateTimeInput, TextInput, Textarea, Select, Checkbox, or Toggle. |
<Field name="email" label="Email" required pattern="^[^@]+@[^@]+$" helpText="Used for invoices.">
<TextInput name="email" type="email" />
</Field>
<HiddenInput>
Hidden form value. Use this for form-scoped IDs that should submit without rendering an editable field.
| Prop | Type | Notes |
|---|---|---|
name | string | Required. Key in ctx.values. |
value | string | number | boolean | Required. |
<Form action="save-hours">
<HiddenInput name="entryId" value={entry.id} />
<SubmitButton>Save</SubmitButton>
</Form>
<NumberInput>
Numeric input with browser number affordances.
| Prop | Type | Default |
|---|---|---|
defaultValue | string | number | boolean | — |
min | number | — |
max | number | — |
step | number | — |
readOnly | boolean | false |
disabled | boolean | false |
<NumberInput defaultValue={1.25} min={0} step={0.25} />
<DateInput>
Date input that submits ISO YYYY-MM-DD strings.
| Prop | Type | Default |
|---|---|---|
defaultValue | string | — |
min | string | — |
max | string | — |
readOnly | boolean | false |
disabled | boolean | false |
<DateInput defaultValue={weekStart} min={weekStart} max={weekEnd} />
<DateTimeInput>
Datetime input that submits browser datetime-local strings.
| Prop | Type | Default |
|---|---|---|
defaultValue | string | — |
min | string | — |
max | string | — |
readOnly | boolean | false |
disabled | boolean | false |
<DateTimeInput defaultValue="2026-05-11T09:00" />
<TextInput>
Single-line text input.
| Prop | Type | Default |
|---|---|---|
defaultValue | string | number | boolean | — |
placeholder | string | — |
type | "text" | "number" | "email" | "url" | "password" | "text" |
readOnly | boolean | false |
disabled | boolean | false |
<TextInput name="taxId" placeholder="GB123456789" />
<Textarea>
Multi-line text input.
| Prop | Type | Default |
|---|---|---|
defaultValue | string | — |
placeholder | string | — |
rows | number | platform default |
readOnly | boolean | false |
disabled | boolean | false |
<Textarea name="notes" rows={4} placeholder="Internal notes…" />
<Select>
Dropdown.
| Prop | Type | Notes |
|---|---|---|
defaultValue | string | — |
options | SelectOption[] | Required. { value, label }. |
disabled | boolean | false |
<Select
defaultValue="USD"
options={[
{ value: "USD", label: "US Dollar" },
{ value: "EUR", label: "Euro" },
{ value: "GBP", label: "British Pound" },
]}
/>
<Checkbox>
Boolean checkbox. Submitted as "true" / "false" in ctx.values.
| Prop | Type | Default |
|---|---|---|
checked | boolean | false |
disabled | boolean | false |
<Field name="active" label="Active">
<Checkbox checked={existing.active} />
</Field>
<Toggle>
Switch-style boolean. Same submit shape as <Checkbox>.
| Prop | Type | Default |
|---|---|---|
checked | boolean | false |
disabled | boolean | false |
<Toggle checked={settings.notifications} />
Action result helpers
Imported from the same module, used inside action handlers (not render scripts):
import { ok, message, redirect, refresh, validationError } from "@backfill-io/sdk/ui";
| Helper | Returns |
|---|---|
ok(message?, opts?) | Action succeeded. Optional toast. |
message(text, level?) | Toast at level "info" / "success" / "warning" / "error". |
redirect(href, message?, level?) | Navigate to href with optional toast. |
refresh(message?, level?) | Re-render the current extension page with optional toast. |
validationError(error, fieldErrors[]) | Per-field validation result. Each entry: { field, message }. |
import { Vendor } from "@backfill-io/sdk";
import { redirect, validationError } from "@backfill-io/sdk/ui";
export default async function saveVendor(ctx) {
if (!ctx.values.name) {
return validationError("Please fix the errors", [
{ field: "name", message: "Required" },
]);
}
Vendor.create({ name: ctx.values.name });
return redirect("/vendors", "Vendor created", "success");
}
Shared types
type ScalarValue = string | number | boolean;
type TableColumn = {
key: string;
label: string;
align?: "start" | "center" | "end";
cell?:
| { kind: "badge"; tone?: "info" | "success" | "warning" | "error" | "neutral" }
| { kind: "link"; hrefKey: string; labelKey?: string }
| { kind: "action"; action: string; label?: string; labelKey?: string; params?: Record<string, ScalarValue>; paramsKey?: string }
| { kind: "money"; currency?: string; currencyKey?: string }
| { kind: "date" };
};
type SelectOption = { value: string; label: string };