Skip to main content
zerotal

Built-in Components

Flow ships a library of ready-made native components and unstyled headless primitives. All are imported from @zerotal/flow.

Tip — See them live: the component gallery shows modals, drawers, tabs and menus driven by a real server, and the flow-ui kit walks the styled set. The full catalogue documents every component with props.

SPA navigation — prevents a full reload, swaps the page over the WebSocket, and updates the URL. The bridge automatically adds data-current to the link matching the current URL, so you style the active state with Tailwind's data-[current]: variant or a CSS attribute selector:

import { Link } from "@zerotal/flow";

<Link href="/posts" class="data-[current]:font-bold">
  Posts
</Link>;

{
  /* Prefetch the target page after ~60ms hover */
}
<Link href="/posts" hover>
  Posts
</Link>;

{
  /* Prefetch on pointer-down instead — for a row in a long list */
}
<Link href="/posts/42" down>
  One post
</Link>;

{
  /* Disable automatic data-current (e.g. always-active home links) */
}
<Link href="/" current={false}>
  Home
</Link>;

Choose between hover and down by how many of the link there are. hover prefetches after a short dwell, which is free speed on a handful of stable links — a navigation rail, a breadcrumb. On a dense list it inverts: the pointer crosses every row between where it is and where it is going, so scrolling a hundred-row table asks the server for a hundred pages nobody chose. down fires on pointerdown instead — once, on the link the reader has committed to, and still ahead of the click by however long the button is held. Both may be set; the target is fetched once and cached either way.

data-current matches by prefix — a link to /posts stays active on /posts/42 — which is what you want for a section parent. For an index link that should be active only on its own exact URL (an "Overview" tab that shouldn't light up on the section's sub-pages), add exact:

<Link href="/dashboard" exact class="data-[current]:font-bold">
  Overview
</Link>

Any extra props (class, target, rel, …) pass through to the rendered <a>.

Scroll position

Following a link lands at the top of the new page, exactly as a full navigation would — or at the fragment, if the href names one (/docs#install). Going Back returns you to where you were on the page you left, and Forward does the same.

Some links aren't really going anywhere, though: a sort header, a filter chip, a tab strip partway down a long page. Jumping to the top for those loses the control the user was just looking at. preserveScroll leaves the viewport alone:

<Link href={this.currentUrl({ query: { sort: "title" } })} preserveScroll>
  Title
</Link>

The same applies to this.navigateCurrent(), which takes preserveScroll as an option — see Routing.

Head management

Injects content into the document <head>. Author it anywhere in render(); the client hoists it into <head> on load and replaces it on every navigate visit:

import { Head } from "@zerotal/flow";

<Head>
  <title>Dashboard — My App</title>
  <meta name="description" content="Your personal dashboard." />
  <link rel="canonical" href="https://example.com/dashboard" />
</Head>;

Title

Shorthand for <Head><title>…</title></Head>. Supports interpolation:

import { Title } from "@zerotal/flow";

<Title>{`${this.unreadCount} unread — Inbox`}</Title>;

Persistence

Persist

Preserve an element — and its live state (a playing <audio>, an embedded widget) — across navigate page visits. Give it a stable name; on navigation the bridge re-uses the existing DOM node instead of replacing it. Best placed in your layout:

import { Persist } from "@zerotal/flow";

{
  /* In AppLayout: */
}
<Persist name="audio-player">
  <audio src={this.currentTrack} controls autoPlay />
</Persist>;

Elements inside <Persist> also survive server-patch morphs (they carry flow:ignore) so their live state isn't disrupted by unrelated updates.

Overlays

A dialog that bundles reactive visibility, a backdrop, a panel, a transition, a close button, and Escape-to-close — all wired to one boolean prop. Clicking the backdrop, the × button, or pressing Escape sets that prop back to false:

import { Modal } from "@zerotal/flow";

export class ContactsPage extends Component {
  @expose open = false;
  @expose editingId: number | null = null;

  @expose async openEdit(id: number): Promise<void> {
    this.editingId = id;
    this.open = true;
  }

  override async render() {
    return (
      <div>
        <button onClick={() => this.openEdit(contact.id)}>Edit</button>

        <Modal show={this.open} title="Edit contact">
          <Field label="Name" error={this.errors.name}>
            <input value={this.form.name} class="input" />
          </Field>
          <button onClick={this.saveContact} loadingAttr="disabled">
            Save
          </button>
        </Modal>
      </div>
    );
  }
}
PropTypeDescription
showboolean @exposeBound boolean that controls visibility
titlestringDialog title
onClosemethod ref or arrowOverride the default close (sets show=false)
closablebooleanHide the × button when false
classstringExtra classes on the panel element

Drawer

A slide-over panel — the edge-anchored sibling of <Modal>. Same binding and close model (backdrop, × and Escape all close it client-side with no round-trip; focus-trapped), but slides in from an edge:

import { Drawer } from "@zerotal/flow";

<button onClick={() => (this.cartOpen = true)}>Cart ({this.cartCount})</button>

<Drawer show={this.cartOpen} side="right" title="Your cart" class="w-96">
  {this.cartItems.map((item) => (
    <div key={String(item.id)} class="flex justify-between py-2">
      <span>{item.name}</span>
      <span>${item.price}</span>
    </div>
  ))}
  <button onClick={this.checkout} class="btn-primary w-full mt-4">Checkout</button>
</Drawer>

side: "right" (default) | "left" | "top" | "bottom".

Feedback

Flash

A self-contained toast container. Server-side this.flash(message, level) dispatches a flow:flash event; <Flash> listens and renders an auto-dismissing toast. Drop one in your layout so toasts work app-wide:

import { Flash } from "@zerotal/flow";

{
  /* In AppLayout: */
}
<Flash position="bottom-right" duration={4000} />;
PropTypeDescription
positionstring"top-left" "top-center" "top-right" "bottom-left" "bottom-center" "bottom-right"
durationnumberAuto-dismiss delay in ms (default: 4000)

Levels success / error / warning / info map to distinct colors. Click a toast to dismiss it early.

Alert

A dismissible inline alert. variant sets the palette and ARIA role (error/warning announce assertively). Dismissal is client-only — no round-trip:

import { Alert } from "@zerotal/flow";

<Alert variant="success" dismissible>
  Your changes have been saved.
</Alert>

<Alert variant="error">
  Failed to connect to the database.
</Alert>

variant: "info" | "success" | "warning" | "error".

Loading

Shows its children only while a server action is in flight — and, so a fast action never flashes a spinner, loading indicators wait out a short delay (~200ms) by default: an action that finishes inside that window shows nothing at all. This applies to the whole loading family — <Loading>, showOnLoading, hideOnLoading, and loadingClass. Only loadingAttr (e.g. loadingAttr="disabled") is applied immediately, so a submit button still guards against a double-click even on a sub-100ms action. The delay prop is now the default behaviour and kept only for clarity/back-compat:

import { Loading } from "@zerotal/flow";

<button onClick={this.save}>Save</button>
<Loading target="save" delay>Saving…</Loading>

{/* Inverted — show when NOT loading */}
<Loading hide>Ready</Loading>

{/* Scope to multiple actions */}
<Loading target={["save", "publish"]}>Working…</Loading>
PropDescription
targetAction name(s) to scope to; omit to react to any in-flight action
delayWait briefly before showing (prevents flicker)
hideInverts the logic — shown when NOT loading

Errors

The Errors component

Renders the component's entire validation error bag as a list. Hidden when there are none:

import { Errors } from "@zerotal/flow";

<Errors />                              {/* every current error */}
<Errors only={["email", "password"]} /> {/* just these fields */}

ErrorMessage

A single field's first error message as a self-hiding <span>. Equivalent to <span error={this.errors.field} />:

import { ErrorMessage } from "@zerotal/flow";

<input value={this.form.email} />
<ErrorMessage for={this.errors.email} class="text-sm text-red-500" />

ErrorBoundary

Contains a failure in a nested component so it costs that component rather than the page. Without one, a child that throws while mounting or rendering takes the whole response with it — one broken widget blanks the dashboard.

import { ErrorBoundary } from "@zerotal/flow";

<ErrorBoundary fallback={<p class="text-sm text-red-600">Sales data unavailable.</p>}>
  <SalesReport />
</ErrorBoundary>;

fallback may be a function, which receives the thrown error. onError reports it (for logging or an error tracker) without changing what renders:

<ErrorBoundary fallback={(e) => <p>{(e as Error).message}</p>} onError={(e) => Log.error(e)}>
  <RiskyWidget />
</ErrorBoundary>

Boundaries nest, and the innermost one wins. Siblings are independent: one failing widget does not affect the other.

What it covers is child components. Inline JSX in the same render() is evaluated before the boundary is called, so a throw there cannot be intercepted — move the risky work into a child component. Containment is also opt-in: a child outside any boundary still fails the page, so real bugs surface instead of rendering as blank space forever.

Data display

Table

A data table with URL-driven sortable headers. Clicking a sortable header navigates to ?sortBy=key&sortDir=asc|desc. Pair with @url sortBy/@url sortDir and sort the rows server-side in render():

import { Table, Pager } from "@zerotal/flow";

export class UsersPage extends Component.using(Pagination) {
  @url sortBy: string = "name";
  @url sortDir: string = "asc";
  @locked users: User[] = [];

  override async onMount() {
    this.users = await User.query().orderBy(this.sortBy, this.sortDir).get();
  }

  override async render() {
    const users = await User.paginate(20);

    return (
      <div>
        <Table
          columns={[
            { key: "name", label: "Name", sortable: true },
            { key: "email", label: "Email", sortable: true },
            { key: "role", label: "Role" },
            {
              key: "actions",
              label: "",
              render: (row) => <button onClick={() => this.edit(row.id)}>Edit</button>,
            },
          ]}
          rows={p.data}
          sortBy={this.sortBy}
          sortDir={this.sortDir}
          params={{ q: this.search }} // preserve query state in sort links
          hover
        />
        <Pager paginator={p} params={{ sortBy: this.sortBy, sortDir: this.sortDir }} />
      </div>
    );
  }
}

Pager

Renders a Prev / numbered / Next pager from either paginator — Model.paginate() or the in-memory paginate() helper. Links are navigate anchors to ?page=N, so they pair with @url page automatically. (The Pagination export is the page-state mixin; <Pager> is the links UI.)

import { Pager } from "@zerotal/flow";

const users = await User.paginate(this.perPage);
<Pager paginator={p} params={{ q: this.query, perPage: this.perPage }} hover />;

hover prefetches the next page on hover. params keeps other query state in the pager links.

InfiniteScroll

A sentinel element that calls a server action when it scrolls into view. Pass show to stop loading when there are no more items:

import { InfiniteScroll } from "@zerotal/flow";

@expose async loadMore(): Promise<void> {
  const nextBatch = await Post.query().offset(this.posts.length).limit(20).get();
  this.posts = [...this.posts, ...nextBatch];
}

// In render():
<ul>{this.posts.map((p) => <li key={String(p.id)}>{p.title}</li>)}</ul>
<InfiniteScroll show={this.posts.length < this.total} onMore={this.loadMore} />

Virtualize

A scrolling window over a collection too large to put in the DOM. Only the visible rows exist as elements; spacers above and below hold the scrollbar at the size the full collection implies.

As the viewport moves, onWindow is called with (start, count) and your action loads that slice. The collection never has to reach the client in full.

import { Virtualize } from "@zerotal/flow";

@expose rows: Row[] = [];
@expose windowStart = 0;
@expose total = 0;

override async onMount(): Promise<void> {
  this.total = await Row.query().count();
  await this.loadWindow(0, 30);
}

@expose async loadWindow(start: number, count: number): Promise<void> {
  this.rows = await Row.query().offset(start).limit(count).get();
  this.windowStart = start;
}

// In render():
<Virtualize
  items={this.rows}
  start={this.windowStart}
  total={this.total}
  itemHeight={36}
  height={480}
  onWindow={this.loadWindow}
>
  {(row) => <div class="h-9 px-3 leading-9">{row.name}</div>}
</Virtualize>

Rows must all be itemHeight pixels tall — that is what lets a scroll offset become an index without measuring anything. overscan (default 6) renders extra rows beyond the viewport to hide fetch latency.

Virtualize or InfiniteScroll? InfiniteScroll appends and grows the DOM without bound, which is right for a feed someone scrolls a few screens of. Reach for Virtualize when keeping every rendered row is the problem.

A click-to-open menu, entirely client-side (no round-trip). Fully keyboard-navigable: Down/Up/Enter opens (focusing first/last item), arrow keys + Home/End move between items, Escape closes and returns focus to the trigger, click-outside dismisses:

import { Dropdown } from "@zerotal/flow";

<Dropdown label="Options" align="right">
  <button class="dropdown-item">Profile</button>
  <button class="dropdown-item">Settings</button>
  <hr class="my-1" />
  <button class="dropdown-item text-red-600" onClick={this.logout}>
    Sign out
  </button>
</Dropdown>;

Tabs

Client-side tabbed panels. Pass items, each with a label and the content to show when selected:

import { Tabs } from "@zerotal/flow";

<Tabs
  items={[
    { label: "Overview", content: <OverviewPanel /> },
    { label: "Activity", content: <ActivityPanel /> },
    { label: "Settings", content: <SettingsPanel /> },
  ]}
/>;

<Tabs> emits role="tablist" / "tab" / "tabpanel" with roving arrow-key navigation.

Note — selection is client-only and not addressable. The active tab lives in Alpine state, always starts on the first item, and there is no prop to bind or read it, so ?tab=settings cannot be made to work and the back button does not step between panels. The tab strip's own classes are fixed (border-gray-800, text-white, border-indigo-500); only the outer wrapper takes class, so on a light surface the selected tab is white on white. For a tab set that has to be linkable or has to match a design system, drive the panels from a @url property and write the strip out — it is a dozen lines, and you keep both.

File upload component

FileUpload + FileUploads mixin

A dropzone bound to an @expose property. Choosing a file POSTs the bytes to /__flow/upload over HTTP, shows live upload progress, and resolves to a signed TemporaryUploadedFile reference. Compose the FileUploads mixin for the removeUpload action:

import { Component, expose, FileUpload, FileUploads, TemporaryUploadedFile } from "@zerotal/flow";

export class AvatarPage extends Component.using(FileUploads) {
  @locked user!: User; // /users/:user/avatar — the record, already found
  @expose photo: TemporaryUploadedFile | null = null;

  @expose async save(): Promise<void> {
    if (!this.photo) return;
    const path = await this.photo.store("avatars"); // moves to permanent storage
    this.user.avatarUrl = path;
    await this.user.save();
    this.flash("Avatar updated.", "success");
  }

  override async render() {
    return (
      <div class="space-y-4">
        {this.user.avatarUrl && <img src={this.user.avatarUrl} class="h-24 w-24 rounded-full" />}

        <FileUpload bind={this.photo} accept="image/*" maxSize="5mb" />

        {this.photo && (
          <div class="flex items-center gap-2">
            <span>{this.photo.name}</span>
            <button onClick={() => this.removeUpload("photo")}>✕</button>
          </div>
        )}

        <button onClick={this.save} loadingAttr="disabled" class="btn-primary">
          Save avatar
        </button>
      </div>
    );
  }
}

For multiple files, use multiple:

@expose photos: TemporaryUploadedFile[] = [];

<FileUpload bind={this.photos} multiple accept="image/*" />

{/* Remove a specific file from the array: */}
<button onClick={() => this.removeUpload("photos", index)}>Remove</button>

Alpine UI plugins

Common Alpine plugins are bundled and exposed as props:

{/* Input masking */}
<input mask="(999) 999-9999" value={this.phone} live />
<input mask="9999 9999 9999 9999" value={this.cardNumber} live />

{/* Focus trapping (modals, dialogs) */}
<div trap="$flow.modalOpen" class="modal">…</div>

{/* Height animation (pair with native x-show) */}
<button onClick={() => this.expanded = !this.expanded}>Toggle</button>
<div x-show="$flow.expanded" x-collapse>…</div>

{/* Floating positioning */}
<button x-ref="trigger">Options</button>
<div anchor="$refs.trigger" anchor.bottom class="dropdown">…</div>
PropBacked byEffect
mask="(999) …"@alpinejs/maskFormat an input as the user types
trap="$flow.open"@alpinejs/focusTrap focus while truthy; $focus magic also available
collapse@alpinejs/collapseAnimate height — pair with native x-show
anchor="$refs.x"@alpinejs/anchorFloat relative to another element

Persisted client state@alpinejs/persist is bundled, so $persist works in any Alpine scope:

<div x-data="{ sidebarCollapsed: $persist(false) }">…</div>

Headless primitives

Unstyled, fully accessible interactive primitives that expose state through data-* attributes so you style them yourself with Tailwind variants or plain CSS.

Switch

An accessible on/off toggle (role="switch", keyboard-operable). Style the on-state with data-[checked]:…:

import { Switch } from "@zerotal/flow";

<Switch
  bind={this.notifications}
  class="relative h-6 w-11 rounded-full bg-gray-700 transition data-[checked]:bg-indigo-600"
>
  <span class="absolute h-4 w-4 rounded-full bg-white top-1 left-1 transition group-data-[checked]:translate-x-5" />
</Switch>;

The Switch button is a Tailwind group, so the inner knob reacts with group-data-[checked]:….

Checkbox bind

An accessible checkbox (role="checkbox") bound to a boolean. Style the checked state with data-[checked]:…:

import { Checkbox } from "@zerotal/flow";

<Checkbox bind={this.agree} class="h-5 w-5 rounded border border-gray-700 data-[checked]:bg-indigo-600 data-[checked]:border-indigo-600">
  <svg class="hidden data-[checked]:block w-3 h-3 text-white" viewBox="0 0 12 12">
    <path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" fill="none" />
  </svg>
</Checkbox>
<label>I agree to the terms</label>

Select bind options

A styled native <select>. Fully accessible out of the box. Use <Listbox> only when you need custom option markup:

import { Select } from "@zerotal/flow";

<Select
  bind={this.country}
  placeholder="Select a country"
  options={[
    { label: "Canada", value: "ca" },
    { label: "United States", value: "us" },
    { label: "United Kingdom", value: "uk" },
  ]}
  class="select"
/>;

RadioGroup bind options

role="radiogroup" with arrow-key roving. Style the selected option with data-[checked]:…:

import { RadioGroup } from "@zerotal/flow";

<RadioGroup
  bind={this.plan}
  options={[
    { label: "Starter — $9/mo", value: "starter" },
    { label: "Pro — $29/mo", value: "pro" },
    { label: "Team — $99/mo", value: "team" },
  ]}
  optionClass="flex items-center gap-2 px-4 py-3 rounded-lg border cursor-pointer data-[checked]:border-indigo-500 data-[checked]:bg-indigo-50"
/>;

Listbox bind options

A fully keyboard-navigable custom select. Arrow keys, Home/End, Enter/Escape, aria-activedescendant. multiple makes the value an array:

import { Listbox } from "@zerotal/flow";

<Listbox
  bind={this.assignee}
  placeholder="Unassigned"
  options={users.map((u) => ({ label: u.name, value: u.id }))}
  optionClass="flex items-center gap-2 px-3 py-2 data-[active]:bg-gray-800 data-[selected]:text-indigo-400"
/>;

States: data-[selected], data-[active], data-[open].

Combobox bind options

An autocomplete input + filtered dropdown list. Two modes:

import { Combobox } from "@zerotal/flow";

{
  /* Client filter — options rendered once, filtered locally as you type */
}
<Combobox bind={this.assigneeId} options={people} placeholder="Search teammates…" />;

{
  /* Server filter — query syncs to an @expose prop and re-renders on each keystroke */
}
<Combobox
  name="cityId"
  queryName="citySearch"
  bind={this.cityId}
  query={this.citySearch}
  options={this.citySuggestions}
  placeholder="Search cities…"
/>;

States: data-[active], data-[selected].

Disclosure

A single collapsible section with proper aria-expanded / aria-controls. data-open is exposed on trigger and panel:

import { Disclosure } from "@zerotal/flow";

<Disclosure
  label="Refund policy"
  defaultOpen={false}
  buttonClass="flex w-full justify-between px-4 py-3 font-medium"
  panelClass="px-4 pb-4 text-gray-600"
>
  Full refund within 30 days. No questions asked.
</Disclosure>;

Accordion

A group of disclosures — single-open by default, or multiple to allow several expanded at once:

import { Accordion } from "@zerotal/flow";

<Accordion
  items={[
    { label: "Shipping", content: "Ships in 1–2 business days." },
    { label: "Returns", content: "Free returns within 30 days." },
    { label: "Warranty", content: "2-year manufacturer warranty." },
  ]}
  multiple
/>;

Popover

An anchored panel that opens on click and closes on click-outside or Escape. data-open is exposed for styling:

import { Popover } from "@zerotal/flow";

<Popover
  label="Solutions ▾"
  class="relative inline-block"
  panelClass="absolute z-10 mt-2 w-48 bg-white shadow-lg rounded-lg"
>
  <a href="/analytics" class="block px-4 py-2">
    Analytics
  </a>
  <a href="/reports" class="block px-4 py-2">
    Reports
  </a>
</Popover>;

Field / Label / Description

Accessibility glue around a single control. <Field> wires for / id / aria-describedby between the label, the control, the description, and the error — keeping screen-reader semantics correct without hand-wiring IDs:

import { Field } from "@zerotal/flow";

<Field label="Email" description="We'll never share it." error={this.errors.email}>
  <input value={this.form.email} type="email" class="input" />
</Field>;

All three props (label, description, error) are optional. You can also compose <Label> and <Description> as children for more control over layout.

Fieldset / Legend

Group related fields. A native <fieldset disabled> cascades the disabled state to every control inside:

import { Fieldset } from "@zerotal/flow";

<Fieldset legend="Billing address" disabled={this.saving}>
  <Field label="Street">
    <input value={this.form.street} class="input" />
  </Field>
  <Field label="City">
    <input value={this.form.city} class="input" />
  </Field>
</Fieldset>;

Tooltip

Shows content on hover/focus of its children, wired with aria-describedby and role="tooltip". Client-only:

import { Tooltip } from "@zerotal/flow";

<Tooltip content="Copy link to clipboard" placement="top">
  <button onClick={this.copyLink}>🔗</button>
</Tooltip>;

Why a page renders through the runtime

Most pages compile ahead of time to string concatenation. A page the compiler can't handle renders through the runtime instead — slower, but identical output, and the usual outcome for anything built from components. The boot line counts them:

[Flow] Compiled 4 page(s), 2 from cache, 3 bind-injected, 8 using runtime (76ms)

To find out which pages those are and what stopped each one, set an env flag:

# .env
ZT_FLOW_COMPILE_LOG=1

Every fallback then names itself, with the exact spot to look at:

[Flow] ListsPage renders through the runtime.
  What stops it compiling:
  app/showcase/flow/lists.tsx:83:10  `<Demo>` is a component, not an HTML element
      → inline its markup here, or let this page render through the runtime

It stays off by default because falling back is normal, not a defect. Turn it on when you're chasing compilation for a hot page, or when a page reads $flow in a value position — that combination is an error rather than a fallback, since the runtime renderer evaluates JSX on the server where $flow doesn't exist.

CSP-safe mode

For environments with a strict Content-Security-Policy that omits 'unsafe-eval', enable CSP-safe mode with an env flag:

# .env
ZT_FLOW_CSP_SAFE=true

When on, the client runtime swaps Alpine's evaluator for an eval-free interpreter, the bridge avoids new Function, and the AOT compiler emits only CSP-safe expressions:

<button onClick={() => this.count++}>+</button>
// standard mode → flow:click="() => $flow.count++"
// CSP mode      → flow:click="$flow.count++"

In CSP mode, every page must AOT-compile — an unsupported expression fails the build loudly rather than degrading silently.

Supported in CSP mode: onClick={this.save}, onClick={() => this.count++}, value={this.x}, show={this.flag}, member access ($flow.user.name), method calls with args, comparisons, arithmetic, ternaries, &&/||, string concat, array/object literals.

Compile-time error (move to a server action): arrow handlers that use the event ((e) => …), block-body arrows, template literals (`${x}` — use a + b), .filter(i => …), computed access (obj[key]), spread.

Content-Security-Policy:
  default-src 'self';
  script-src 'nonce-<random>' 'strict-dynamic';
  style-src 'self' 'unsafe-inline';

View Transitions

navigate links automatically wrap the page swap in the browser's View Transitions API when supported, giving smooth cross-page animations for free. Style them with the standard ::view-transition-* CSS:

::view-transition-old(root) {
  animation: fade-out 150ms ease;
}
::view-transition-new(root) {
  animation: fade-in 150ms ease;
}

Next steps

  • Flow overview — the guide's front page and the rest of the sections.
  • Reference — every decorator, prop, and directive in one table.