Skip to content
preact-components

  • OverviewGuidePage
  • UI@spy4x/preact-uiPage
  • Icons@spy4x/preact-iconsPage
  • Theme@spy4x/preact-themePage
  • Charts@spy4x/preact-chartsPage
  • Map@spy4x/preact-mapPage
  • System@spy4x/preact-systemPage
  • CRUD@spy4x/preact-crudPage
preact-components

preact-components

preact-components

Preact components that render on the server and work from the keyboard.

Components, charts, Tailwind styles and icons for Deno apps. Every one runs live in this guide, with its code one click away.

deno add jsr:@spy4x/preact-ui

176 components · 126 icons · 11 packages

See it in an app

A dashboard, built from the library

Every part of this frame is a component from these packages, running on local state: filter and sort the projects, run the checks for a toast, add a project in the dialog. The header's switches repaint it.
OrbitDemo
Projects
5
Builds
494
all time
Failing
1
Median build
2m 41s

Builds this week

PassedFailed
Builds this week
PointPassedFailed
Mon182
Tue241
Wed214
Thu302
Fri273
Sat120
Sun91
Projects
AtlasHealthy128
BeaconBuilding96
CometFailing41
DriftHealthy212
EmberPaused17
Code
import { Kpi, KpiGrid } from "@spy4x/preact-charts/kpi"
import { LineChart } from "@spy4x/preact-charts/line-chart"
import { createToastStore } from "@spy4x/preact-signals/toast"
import { Badge, Button, Cluster, DataTable, Field, Input, Modal, Toastr } from "@spy4x/preact-ui"
import { useSignal } from "@preact/signals"
import { useMemo } from "preact/hooks"

export function Dashboard({ projects }: { projects: Project[] }) {
  const toasts = useMemo(() => createToastStore(), [])
  const sort = useSignal([])
  const open = useSignal(false)

  return (
    <>
      <Cluster>
        <Button variant="outline" onClick={() => toasts.info({ body: "Checks queued." })}>
          Run checks
        </Button>
        <Button onClick={() => open.value = true}>New project</Button>
      </Cluster>
      <Toastr toasts={toasts.list.value} onDismiss={toasts.remove} />

      <KpiGrid>
        <Kpi label="Projects" value={projects.length} />
        <Kpi label="Failing" value={1} tone="negative" />
      </KpiGrid>
      <LineChart ariaLabel="Builds this week" series={builds} />
      <DataTable
        caption="Projects"
        rows={projects}
        rowKey={(row) => row.id}
        sort={sort.value}
        onSortChange={(next) => sort.value = next}
        columns={[
          { key: "name", header: "Project", sortable: true },
          { key: "status", header: "Status", render: (row) => <Badge text={row.status} /> },
          { key: "builds", header: "Builds", sortable: true, align: "right" },
        ]}
      />

      {open.value && (
        <Modal open title="New project" onClose={() => open.value = false}>
          <Field id="name" label="Project name" required>
            <Input />
          </Field>
        </Modal>
      )}
    </>
  )
}

Why preact-components

  • Web standards firstNative <dialog>, <details> and forms that post before a script has run. The browser does what it already knows how to do.
  • Rendered on the serverEvery component renders complete HTML on the server and hydrates in the browser. This page is served that way and reads with JavaScript off.
  • Proven in a real browserKey presses, focus moves, dialogs and toasts are driven in headless Chromium on every pull request, not assumed from the markup.
  • No third-party component libraryNo Radix, Headless UI or Material underneath. Every component's markup, keyboard handling and focus are written, and tested, here.
  • Themed by tokensSet --color-primary for light and --color-accent for dark, and the components' accent follows: buttons, switches, tabs, focus rings. Status colours stay fixed on purpose.
  • MIT licensedUse it in any project, commercial or not. Every package is on JSR under @spy4x, all at one version.

Get started

The command above installs the components. Two more steps and a page renders.

  1. Add the styles

    Components render against the theme's tokens and classes, compiled by Tailwind into your stylesheet.

    How to build the stylesheet
    deno add jsr:@spy4x/preact-theme
  2. Use a component

    Import it, pass props. It renders on the server and hydrates in the browser like any Preact component.

    toolbar.tsx
    import {
      Badge,
      Button,
      Cluster,
    } from "@spy4x/preact-ui"
    
    export function Toolbar(
      { onSave }: { onSave: () => void },
    ) {
      return (
        <Cluster>
          <Button onClick={onSave}>
            Save
          </Button>
          <Badge text="Draft" />
        </Cluster>
      )
    }
  3. Read on

    Each package's README lists every component and helper it exports, with its props.

Packages

Design rules

What @spy4x/preact-theme provides, and the page conventions the components assume.

Every class below is a Tailwind 4 utility emitted by preset.css. Import tokens.css before it to get the palette these utilities read; the presets fall back to the library's own values if you skip it.

Put .theme-base on <body> to opt into the document-level font, colour and canvas — nothing is applied to the host page by importing the preset. Lay a page out with Page, Section, Stack and Grid from @spy4x/preact-ui/layout; .page-layout still gives the standard page width, and is deprecated in their favour.

Put .dark on <html> for the dark palette. On an element it repaints the tokens for that subtree only, so the subtree stays dark in both themes, as WindowFrame's terminal does.

The ui/ primitives in this catalogue deliberately do not use these classes: they inline their own utilities so they render without the token layer. Reach for a component first and a class second.

Colour atoms

.text-primary.bg-primary.bg-primary-muted.border-primary.rounded-primary.text-muted.bg-canvas.bg-surface.border-subtle.border-control.bg-danger.bg-warning.bg-success.text-danger.text-warning.text-success

Tokens

.dark.bg-selected.text-selected-foreground.bg-selected-soft.text-selected.border-selected.bg-selected-hover.bg-selected-soft-hover.bg-hover.bg-track.bg-foreground.text-canvas.bg-scrim.text-scrim-foreground.bg-info.text-info.border-info.text-info-foreground.bg-danger-fill.bg-danger-fill-hover.text-danger-foreground.text-primary-foreground.text-accent-foreground.text-foreground.text-placeholder.font-heading.ring-focus.ring-offset-focus.pc-focus-offset-surface.ring-subtle.ring-control.divide-subtle.rounded-card.rounded-control.shadow-raised.shadow-popover.text-danger-fill-foreground.bg-danger-soft.bg-info-soft.bg-warning-soft.bg-success-soft.border-danger.border-warning.border-success.bg-surface-overlay.bg-scrim-strong.bg-on-scrim.bg-on-scrim-strong.border-on-scrim.text-on-scrim-muted.border-strong.border-button.border-frame.decoration-control.border-surface.ring-surface.ring-offset-surface

Type

.pc-h1.pc-h2.pc-h3.pc-h4.pc-h5.pc-link.page-layout.list-ul.pc-prose.theme-base

Buttons

.btn.btn-primary.btn-primary-outline.btn-danger.btn-danger-outline.btn-warning.btn-warning-outline.btn-success.btn-success-outline.btn-icon.btn-link.btn-input-icon.btn-disabled

Forms

.pc-input.pc-select.pc-textarea.pc-label.pc-checkbox.pc-radio

Surfaces

.pc-card.pc-card-header.pc-card-body.pc-card-footer.pc-scrollbar

Data display

.pc-num.pc-kpi.pc-kpi-label.pc-kpi-value.pc-bar

Map

.map-marker.status-on.status-off.status-unknown

Spacing

.pb-safe.pb-safe-3.ps-safe

Removed, and not coming back

  • .h6 — use text-base font-medium
  • .btn-sm — use h-9 px-4 on the button
  • .power-anomaly — application-specific wording, not part of this library — colour a marker with status-on / status-off / status-unknown instead

@spy4x/preact-ui

UI

The controls and surfaces an app is assembled from: buttons and badges, tables and meters, fields and pickers, dialogs, toasts and the empty and error states.

Badges

Small labels for a status or a category.

Badge

A small coloured label for a status or a category, filled or outlined.
filled
redorangegreengraybluepurplepurpleNav
outline
redorangegreengraybluepurplepurpleNav
content
routed
system pagenavigate not called yet
Props
PropType and defaultWhat it does
textstringA plain text label, shown in the case you give it. Give this or children, not both.
childrenComponentChildrenElement content in place of text, such as a StatusMark beside a word, or a link inside the pill; its parts get a small gap.
hrefstringMakes the whole pill one link, with the focus ring; its content is the link's name.
navigate(href: string) => voidWith href: called on a plain click instead of the browser following the link, as on Link and Button. A modified click, or a link to another site, stays the browser's.
colorBadgeColor= "purple"One of the seven palette entries shown here. purple and purpleNav are the theme's accent: they follow --color-primary in light and --color-accent in both.
type"filled" | "outline"= "filled"Tinted, or only outlined.
Code
<Badge text="paid" color="green" />
<Badge text="draft" color="gray" type="outline" />
<Badge color="gray" href="https://ci.example.com/my-repo">
  <span>CI</span>
  <StatusMark status="ready" label="Passing" />
</Badge>
<Badge text="system page" href="/system" navigate={router.navigate} />

StatusMark

A project's or a feature's lifecycle state, as a shape and a word that read without colour.
ReadyIn useBeta
In progressPausedArchived
Known issueOutcome
LiveOffline
Code
<StatusMark status="ready" />
<StatusMark status="known-issue" label="Flaky on Safari" />
<StatusMark status="outcome" label="Acquired 2023" />

Buttons

Buttons in every look and size, the buttons that copy, locate or export, and a link that hands plain clicks to your router.

Button

A native button in the library's variants and sizes, or a link that looks like one; every other attribute passes through.
sm
md
lg
disabled
clicked 0 times

confirmed 0 times

saved 0 times

submitted 0 times

0 notes, sent 0 times

Props
PropType and defaultWhat it does
variantButtonVariant= "primary"One of the six looks in the rows above.
size"sm" | "md" | "lg" | "none"= "md"The button's height and padding. "none" sets no padding, gap or text size; size it with class.
hrefstringRenders an <a> with the same classes instead of a <button> (ButtonLinkProps).
navigate(href) => voidA link's router port: a plain click calls it instead of the browser navigating; modified clicks and links to another origin stay the browser's.
disabledbooleanOn a link: drops href, sets role="link" and aria-disabled, and dims it; it leaves the tab order.
type"button" | "submit" | "reset"= "button"A button submits a form only when you say so.
busyboolean= falseShows a spinner, sets aria-busy and ignores presses, but keeps focus on the button.
busyLabelComponentChildrenShown instead of the children while busy. An icon button shows no label.
hotkeystringA key such as "n" or "mod+enter" that clicks the button while it is shown and enabled. A plain key never fires while you type in a field; a combination holding Control, Command or mod does, except the editing chords a field keeps, such as mod+z. Sets aria-keyshortcuts.
hotkeyHintboolean= true, false on an icon buttonShows the key in a Kbd hint inside the button. Never shown where the main pointer is coarse, such as a phone.
kbdLabelsPartial<KbdLabels>= EnglishThe words the hint shows for a key, such as Ctrl.
refRef<HTMLButtonElement>Reaches the native <button>, so it can be focused.
Code
<Button variant="primary" size="md" onClick={save}>Save</Button>
<Button variant="danger" disabled>Delete</Button>
<Button type="submit" busy={saving.value} busyLabel="Confirming…">Confirm</Button>
<Button href="/reports" navigate={router.navigate}>Reports</Button>
<Button href="/book" size="none" class="px-6 py-3">Book a call</Button>
<Button hotkey="n" onClick={addNote}>New note</Button>
<Button hotkey="mod+enter" onClick={send}>Send</Button>
useSucceeded(saving.value, error.value, closeDialog) // once a save ends well

CopyButton

Copies a piece of text to the clipboard, as an icon alone or with a title.

port received: nothing yet

Props
PropType and defaultWhat it does
textToCopystring | () => stringWhat lands on the clipboard; a function is read at click time.
titlestringA visible label, replaced by the confirmation while it shows; without it, the icon alone.
variant"outline" | "primary"= "outline"The look of a button with a title; primary for a page whose main action is the copy. The icon alone ignores it.
copy(text) => void | boolean | Promise= the clipboardReplaces the clipboard; throwing, rejecting or returning false shows the failure state.
copiedLabelstring= "Copied"Shown and announced after a copy that worked.
failedLabelstring= "Couldn't copy"Shown and announced after a copy that failed.
copiedForMsnumber= 1500How long either confirmation stays.
hotkeystringA key that copies, as Button's does; with a title it shows in a hint, the icon alone shows none.
data-*, id, …button attributesPassed to the button, for analytics among other things.
Code
<CopyButton textToCopy={invoice.id} />
<CopyButton textToCopy={invoice.id} title="Copy id" copy={app.clipboard.copy} />
<CopyButton textToCopy={() => input.value} data-umami-event="copy-input" />
<CopyButton textToCopy={invoice.id} title="Copy id" hotkey="c" />
<CopyButton textToCopy={results} title="Copy results" variant="primary" />

ThemeToggle

One icon button that steps the theme through auto, the opposite of the device and the device's own, with an Auto mode hint on the switch back to auto.

preference system, an app would paint light

At the end of a header
Props
PropType and defaultWhat it does
storeThemeToggleStoreYour app's createThemeStore(); you call its attach(), the button calls cycle().
labelsPartial<ThemeToggleLabels>= EnglishThe accessible name for each state and the hint's text.
hintForMsnumber= 2000How long the Auto mode hint stays up.
classstringUtilities appended to the wrapper around the button and its hint.
hotkeystringA key that steps the theme, as Button's does. An icon button shows no hint unless hotkeyHint asks.
Code
const theme = createThemeStore()
useEffect(() => theme.attach(), [])

<ThemeToggle store={theme} hotkey="t" />

Layout

The five components that put space between a page's parts, each with a named gap from the spacing scale.

Stack

Puts its children in a column, a named gap apart.
gap="sm"
8 px apart
buttons stacked on a phone
gap="md"
16 px apart
the fields of a form
gap="xl"
32 px apart
the sections of a page
Props
PropType and defaultWhat it does
gapSpacingGap= "md"none, xs, sm, md, lg, xl or 2xl: from 0 to 48 px.
asLayoutElement= "div"The element it renders, such as section, ul or form.
Code
<Stack>
  <Field id="name" label="Name"><Input id="name" /></Field>
  <Field id="email" label="Email"><Input id="email" type="email" /></Field>
</Stack>

Cluster

Puts its children in a row that wraps: a toolbar, a row of buttons, a list of tags.

Invoices

paidoverduedraftrefunded
Props
PropType and defaultWhat it does
gapSpacingGap= "sm"The space between the children, across and down.
alignClusterAlign= "center"Lines the children up across the row: start, center, end, baseline or stretch.
justifyClusterJustify= "start"Places the children along the row: start, center, end or between.
Code
<Cluster justify="between">
  <h4 class="pc-h3">Invoices</h4>
  <Cluster>
    <Button variant="secondary">Export</Button>
    <Button>New invoice</Button>
  </Cluster>
</Cluster>

Section

A titled block of a page: a heading, an optional description, then its content.

Payment methods

Cards we can charge for this workspace.

Company card
Backup card
Props
PropType and defaultWhat it does
titlestringThe heading, which also names the section.
descriptionComponentChildrenA line under the heading.
headingLevel2 | 3 | 4= 2The heading's level, and its size.
actionComponentChildrenControls beside the heading, such as a button; they wrap under it when narrow.
asSectionElement= "section"section, article, aside or div, which is not a landmark.
Code
<Section
  title="Payment methods"
  description="Cards we can charge."
  headingLevel={4}
  action={<Button variant="outline" size="sm">Add card</Button>}
>
  <Grid>…</Grid>
</Section>

Grid

Lays its children out in equal columns, as many as fit the width.
Revenue
Customers
Refunds
Churn
Trials
Upgrades
Props
PropType and defaultWhat it does
minColumnWidth"sm" | "md" | "lg"= "md"The narrowest a column gets: 12, 16 or 20 rem.
gapSpacingGap= "md"The space between the cells, across and down.
Code
<Grid minColumnWidth="sm">
  <Card>…</Card>
  <Card>…</Card>
  <Card>…</Card>
</Grid>

Page

The content column of a page: centred, with the page's side gutter and room between its sections.
page title
first section
second section
Code
<Page>
  <PageTitle>Billing</PageTitle>
  <Section title="Invoices">…</Section>
  <Section title="Payment methods">…</Section>
</Page>

Display

What shows content rather than collects it: headings, tables, meters, cards, tabs, pages of results and avatars.

PageTitle

The page's main heading, in the library's h1 style and with no margin of its own.

Transactions

Nested detail, at a smaller size
Props
PropType and defaultWhat it does
childrenComponentChildrenThe heading's content.
headingLevel1 | 2 | 3 | 4 | 5 | 6= 1The heading's level. Leave it out for the page's one h1; the card uses 4 and 5, under its own h3.
classstringUtilities merged after the defaults.
Code
<Stack>
  <PageTitle headingLevel={4}>Transactions</PageTitle>
  <PageTitle headingLevel={5} class="text-xl sm:text-xl">Nested detail</PageTitle>
</Stack>

PageHeader

The header a page starts with: a back arrow, a one-line title, one primary action and a More actions menu.

Groceries for the long weekend at the lake house with everyone

Personal

Last pressed: nothing yet.

Props
PropType and defaultWhat it does
titlestringThe heading; it stays on one line and truncates, with its full text as tooltip.
headingLevel1 | 2 | 3 | 4 | 5 | 6= 1The heading's level. The cards here use 4, under the card's own h3.
headingComponentChildrenDrawn inside the heading in place of title, such as a field that renames.
subtitleComponentChildrenOne quiet line under it.
back{ href: string; label: string }An arrow link to the parent page, named by label.
navigate(href: string) => voidFollows back without a page load.
actionComponentChildrenThe one primary action, usually a PageAction.
menuComponentChildrenDropdownItems behind one three-dots button.
menuLabelstring= "More actions"The three-dots button's accessible name.
Code
<PageHeader
  title="Groceries for the long weekend"
  subtitle="Personal"
  back={{ href: "/lists", label: "Back to lists" }}
  navigate={navigate}
  action={<PageAction label="New task" Icon={IconPlus} onClick={addTask} />}
  menu={<DropdownItem onClick={share}>Share</DropdownItem>}
/>
<PageHeader
  title={name}
  heading={
    <InlineEdit
      class="[&>button]:text-xl [&>button]:font-semibold sm:[&>button]:text-2xl"
      value={name}
      onSave={rename}
      inputLabel="List name"
    />
  }
/>

FilterBar

Search, filter menus, one-tap toggles and a sort over a list, with the active filters as removable chips and a result count.
  • Q4 planning: hiring and budgetMaya Chen · Design, Plans
  • Design review notesYou · Design

q: (empty) · tags: design (any) · author: (anyone) · mine: off · sort: updated desc

Props
PropType and defaultWhat it does
search{ value, onChange, label?, placeholder?, hotkeyHint?, clearLabel? }A search field with a clear button; hotkeyHint shows a key the app already binds, such as /.
filtersFilterBarMenu[]One menu each: { id, label, options, value, onChange }, with multiple: true for boxes that keep the menu open (with a Done item on a phone), chipLabel, anyLabel, activeLabel (an active trigger's accessible name; on screen it shows label and a count), and a header slot with an optional headerLabel.
toggles{ id, label, pressed, onChange }[]One-tap filters, drawn as ToggleChips.
sort{ options, value, onChange, label? }A menu at the end of the row; its label shows from sm up.
countComponentChildrenThe muted result count, announced politely when it changes.
onClear() => voidClears every filter in one go, such as clearFilters of a useUrlFilters that holds only the filters; left out, each filter gets its own onChange.
Code
// Every value lives in the URL; `useUrlFilters` is in @spy4x/preact-signals.
const { filters: f } = useUrlFilters({
  q: { signal: useSignal(""), urlParam: "q", initialValue: "" },
  tags: { signal: useSignal(""), urlParam: "tags", initialValue: "" },
  mine: { signal: useSignal(""), urlParam: "mine", initialValue: "" },
  sort: { signal: useSignal("updated"), urlParam: "sort", initialValue: "updated" },
})

<FilterBar
  search={{ value: f.q.value, onChange: (q) => f.q.value = q, label: "Search notes", hotkeyHint: "/" }}
  filters={[{
    id: "tags",
    label: "Tags",
    chipLabel: "Tag",
    multiple: true,
    options: [{ value: "design", label: "Design", count: 12 }, { value: "plans", label: "Plans", count: 5 }],
    value: f.tags.value === "" ? [] : f.tags.value.split(","),
    onChange: (next) => f.tags.value = next.join(","),
  }]}
  toggles={[{
    id: "mine",
    label: "Created by me",
    pressed: f.mine.value === "1",
    onChange: (on) => f.mine.value = on ? "1" : "",
  }]}
  sort={{
    options: [{ value: "updated", label: "Last updated" }, { value: "title", label: "Title (A–Z)" }],
    value: f.sort.value,
    onChange: (sort) => f.sort.value = sort,
  }}
  count={`${shown.length} notes`}
/>

PageAction

A page header's primary action: icon and label from sm up, the icon alone on a phone, named at every width.
Export

Pressed 0 times; P presses New task. On a phone each shows its icon alone.

Props
PropType and defaultWhat it does
labelstringVisible from sm up; always its name.
IconComponentType<IconProps>Shown at every width.
hrefstringMakes it a link; otherwise a button.
onClick() => voidRuns on a press of the button form.
variantButtonVariant= "primary"The button's look.
hotkeystringA key that presses the action, shown in a hint, as Button's does.
Code
<PageAction label="New task" Icon={IconPlus} onClick={addTask} hotkey="p" />
<PageAction label="Export" Icon={IconDownload} href="/export" variant="secondary" />

MoreMenu

A three-dots button that opens a menu of DropdownItems, for a page or for one row of a list.
Ada Lovelace

Last chosen: nothing yet.

Props
PropType and defaultWhat it does
labelstringThe button's and the menu's accessible name; required.
childrenComponentChildrenThe DropdownItems.
Code
<MoreMenu label="Actions for Ada Lovelace">
  <DropdownItem onClick={promote}>Make owner</DropdownItem>
  <DropdownItem onClick={remove} danger>Remove</DropdownItem>
</MoreMenu>

SettingList

A card of setting rows, one per line and divided by a rule.
Name
Ada Lovelace
E-mail
ada@example.com
Code
<SettingList>
  <SettingRow
    label="Name"
    value="Ada Lovelace"
    action={
      <Button variant="ghost" size="sm" class={TOUCH_TARGET} onClick={editName}>
        Edit<span class="sr-only"> name</span>
      </Button>
    }
  />
  <SettingRow label="E-mail" value="ada@example.com" />
</SettingList>

SettingRow

One setting: its name, its value under it, and one action that stays beside them on a phone.
Time zone
Europe/Lisbon, which is where the calendar shows every event
Props
PropType and defaultWhat it does
labelstringThe setting's name.
valueComponentChildrenIts current value.
actionComponentChildrenOne control on the right; name it after the setting.
Code
<SettingRow
  label="Time zone"
  value="Europe/Lisbon, which is where the calendar shows every event"
  action={
    <Button variant="ghost" size="sm" class={TOUCH_TARGET} onClick={editTimeZone}>
      Edit<span class="sr-only"> time zone</span>
    </Button>
  }
/>

SettingGroup

A named group of settings: a small h2 over its rows, so the page title stays the one large heading.

Security

How you sign in.

Password
Changed 3 months ago
Two-factor
On
Props
PropType and defaultWhat it does
titlestringThe group's h2, which names it.
descriptionComponentChildrenOne line under it.
Code
<SettingGroup title="Security" description="How you sign in.">
  <SettingList>
    <SettingRow
      label="Password"
      value="Changed 3 months ago"
      action={
        <Button variant="ghost" size="sm" class={TOUCH_TARGET} onClick={changePassword}>
          Edit<span class="sr-only"> password</span>
        </Button>
      }
    />
  </SettingList>
</SettingGroup>

MoneyDisplay

Shows an amount kept in a currency's smallest unit, in the person's region format, with that currency's own decimals.
Tone or currencyde-DEen-US
plain1.234,56 €€1,234.56
signed ++3.200,00 €+€3,200.00
signed −-84,20 €-€84.20
signed 00,00 €€0.00
negative-45,99 €-€45.99
JPY12.345 ¥¥12,345
KWD12,345 KWDKWD 12.345
BTC0,00000001 BTCBTC 0.00000001
Props
PropType and defaultWhat it does
amountnumberThe amount in the currency's smallest unit.
currencystringThe ISO code, such as EUR, or a coin's.
localestring= useFormat().localeThe number format; by default the region of FormatContext.
decimalsnumberA coin's own decimals, such as 8 for BTC.
tone"plain" | "negative" | "signed"= "plain"signed adds a plus and the success colour to income, the danger colour to spending.
Code
<MoneyDisplay amount={123456} currency="EUR" />
<MoneyDisplay amount={-8420} currency="EUR" tone="signed" />
<MoneyDisplay amount={1} currency="BTC" decimals={8} />

I18nContext

Hands one translator to every component below it: their words, in the reader's language, and its text direction.
Language

Dates follow FormatContext, not the language: an app provides both.

Code
import uiEn from "@spy4x/preact-ui/i18n/en"
import uiDe from "@spy4x/preact-ui/i18n/de"

const translator = createTranslator({
  language: "de",
  catalogs: [{ source: uiEn, translation: uiDe }, { source: appEn, translation: appDe }],
})

<I18nContext.Provider value={translator}>
  <div dir={translator.dir}>{app}</div>
</I18nContext.Provider>

Rich

A translated sentence with a span the caller draws, such as a link, while the message itself stays plain text.

I agree to the terms of use, Ada.

Props
PropType and defaultWhat it does
partsreadonly MessagePart[]The message as spans, from useT().parts(key, values).
tagsReadonly<Record<string, (text: string) => ComponentChildren>>= {}How each tag renders, given its text; a tag with no renderer shows as text.
Code
// "signup.terms": "I agree to the <terms>terms of use</terms>, {name}."
<Rich
  parts={useT().parts("signup.terms", { name })}
  tags={{ terms: (text) => <a href="/terms">{text}</a> }}
/>

FormatContext

Hands one person's date format, time zone and clock to every date below it, When, OnDay and useFormat() alike.
10:05London, en-GB, region clock
5:05 AMNew York, en-US, 12-hour clock
Code
<FormatContext.Provider value={createFormatters({
  locale: "en-US",
  timeZone: "America/New_York",
  hourCycle: "h12",
})}>
  <OnDay at={at} /> {useFormat().time(at)} <When at={at} />
</FormatContext.Provider>

When

A moment as its distance from now, with the exact instant in the person's own format on hover.
five seconds beforefive minutes beforethree days before-no date
Props
PropType and defaultWhat it does
atDate | number | string | nullThe moment; a missing or unreadable one shows the missing label.
nownumber= the time of the renderThe present in epoch milliseconds, for text that must not move.
labelsPartial<WhenLabels>= WHEN_LABELSThe distance text, the missing date and the hover text.
Code
<FormatContext.Provider value={createFormatters(settings)}>
  <When at={note.updatedAt} />
</FormatContext.Provider>

OnDay

A calendar day in the person's own format and zone, with the exact instant on hover.
London, en-GBTokyo, en-GB: already the next dayNew York, en-US
Code
<FormatContext.Provider value={createFormatters({ timeZone: "Asia/Tokyo" })}>
  <OnDay at="2026-10-17T22:30:00.000Z" />
</FormatContext.Provider>

Progress

A progress bar with an optional caption, which draws a bare track while there is no reading.
Primary68%
Success68%
Warning68%
Danger68%
Past the maximum (140 of 100)100%
No reading yet
30%
Props
PropType and defaultWhat it does
valuenumber | nullThe reading; kept between 0 and max, and none draws a bare track.
maxnumber= 100The value of a full bar.
tone"primary" | "success" | "warning" | "danger"= "primary"The bar's colour.
labelstringA caption above the bar; it needs an id beside it.
ariaLabelstringThe bar's name when it has no caption.
Code
<Progress id="upload" label="Upload" value={42} tone="success" />
<Progress value={null} ariaLabel="Sync" />

UsageMeter

How much of a limit is used, such as a budget's spending: the figures, a bar, and a line that says what is left or how far over it is.
Groceries
Restaurants
Fuel
Clothes
Home improvement and garden supplies
Gifts, with no budget
Props
PropType and defaultWhat it does
usednumberThe amount used so far.
limitnumberThe amount allowed, in the same unit.
format(amount: number) => stringTurns an amount into text: money, storage or seats.
labelstringWhat is measured; it names the meter.
warnAtnumber= 80The percent of the limit from which the meter warns.
labelsPartial<UsageMeterLabels>Overrides for the words; each state is one whole sentence.
localestringLocale of the percentage; defaults to the translator's language.
Code
<UsageMeter
  label="Groceries"
  used={41200}
  limit={50000}
  format={(cents) => formatMoney(cents, "EUR", locale)}
/>

RunSteps

A fixed list of a run's steps, each filling in with its value and a verdict, so the layout never shifts while it runs.

Measurements

  1. Download the dataBudget: under 10 s
    2.4 sWithin budget
  2. Scroll the listBudget: under 5% of frames dropped
    12% of frames droppedOver budget
  3. Memory usedThis browser doesn't report it.
    Not available
  4. Search on this deviceBudget: under 100 ms
    Failed
  5. Save to this device
    3,200 of 10,000 records
    Running
  6. Storage usedNo budget
    Not measured

Stopped part way

  1. Download the dataBudget: under 10 s
    2.4 sWithin budget
  2. Scroll the listBudget: under 5% of frames dropped
    12% of frames droppedOver budget
  3. Memory usedThis browser doesn't report it.
    Not available
  4. Search on this deviceBudget: under 100 ms
    Failed
  5. Save to this device
    Stopped
  6. Storage usedNo budget
    Not measured

The value column lines up from 640 px; on a phone each value sits on its label's line with the badge under it.

Props
PropType and defaultWhat it does
stepsRunStep[]The steps in order, each { id, label, state, hint?, value?, verdict?, progress?, detail? }. state is pending, running, done, failed, unavailable or stopped; value and verdict show once done.
labelsPartial<Record<RunStepState, string>>The word each state shows in the value's place.
aria-labelledbystringThe id of the heading that names the list.
Code
<RunSteps
  aria-labelledby="measurements"
  labels={{ pending: "Not measured" }}
  steps={[
    {
      id: "download",
      label: "Download the data",
      state: "done",
      hint: "Budget: under 10 s",
      value: "2.4 s",
      verdict: { label: "Within budget", tone: "success" },
    },
    {
      id: "save",
      label: "Save to this device",
      state: "running",
      progress: { value: 3200, max: 10000, label: "3,200 of 10,000 records" },
    },
  ]}
/>

ComparisonTable

A how-we-compare table for a public page: a legend, Yes, Partial or No per product with an icon and a word, and the method and fit that make it honest.
  • Yesbuilt in
  • Partialwith limits, or through an add-on
  • Nonot offered
How our app compares with three other budgeting apps for families
CapabilityOur appOursProduct AProduct BProduct C
Works offlineYesPartialNoYes
Export to CSVevery field, not a summaryYesYesPartialNo
Shared budgetsPartialNoYesYes
Self-hostingNoNoPartialNo
Method
Checked on October 1, 2026. We read each product's official feature, pricing and help pages. Yes means it is built in on the cheapest paid plan; Partial means a limit, an add-on or a higher plan.
Fit
Our app is not for teams over fifty people, or for anyone who needs payroll.
  • Yesbuilt in
  • Partialwith limits, or through an add-on
  • Nonot offered
Our app or a spreadsheet
CapabilityOur appOursA spreadsheet you keep on your own
Adds up every accountYesYes
Imports bank statements and sorts every line into a categoryRules you write once apply to every later import.YesPartial
Reminds you before a bill is due, and again on the dayYesNo
Costs nothingNoYes
Method
Checked on October 1, 2026. We compared our app with a spreadsheet kept by hand.
Props
PropType and defaultWhat it does
productsstring[]One column each; the first is yours and is highlighted.
rows{ capability, values: ("yes" | "partial" | "no")[], note? }[]One row per capability, with one value per product.
captionComponentChildrenThe table's name.
checkedOnDateWhen the comparison was checked; printed in the method line.
methodComponentChildrenWhat was read, and what each value requires.
fitComponentChildrenWho your product is not for.
localestring= "en"The date's locale.
timeZonestring= "UTC"The date's time zone, so the server and the browser print the same day.
labelsPartial<ComparisonTableLabels>Replaces any English word: the values, the legend, Method, Fit, the date line.
Code
<ComparisonTable
  caption="How our app compares"
  products={["Our app", "Product A", "Product B", "Product C"]}
  rows={[
    { capability: "Works offline", values: ["yes", "partial", "no", "yes"] },
    { capability: "Export to CSV", note: "every field", values: ["yes", "yes", "partial", "no"] },
  ]}
  checkedOn={new Date("2026-10-01")}
  method="We read each product's official feature, pricing and help pages."
  fit="Our app is not for teams over fifty people."
/>

BrandMark

A product's mark: one glyph in a tile at the card radius, filled with the brand hue, then the product's name.
Props
PropType and defaultWhat it does
IconComponentType<IconProps>The glyph in the tile, any icon of @spy4x/preact-icons.
namestringThe product's name, printed after the tile.
Code
<a href="/" class="rounded-md">
  <BrandMark Icon={IconSprout} name="Acme" />
</a>

DemoVideo

A demo clip that costs nothing until it is pressed: a poster with a play button, swapped for an inline video with sound and controls, one clip at a time.
A four-second test clip
A second clip: starting one pauses the other
A clip whose file is missing: press it to see the error state
Props
PropType and defaultWhat it does
poster{ src, srcset?, sizes?, width, height, dark? }The still frame shown before the press; its size keeps the box still. dark: { srcset, media } draws a <picture> whose dark file shows while media matches.
sources{ src, type }[]The clip's files, WebM before MP4. The first is where the link goes without JavaScript.
titlestringWhat the clip shows; the link is named Play video: {title}.
eagerboolean= falseLoad the poster at once, at high priority, for a clip on the first screen.
loopboolean= falsePlay in a loop, except when the reader asks for reduced motion.
errorLabelstring= "The video didn't load."Shown in the clip's box, with a link to the file, when no source can play.
...restanchor attributesAny other attribute, such as id or data-* for analytics, on the play link.
Code
<WindowFrame variant="plain">
  <DemoVideo
    title="Creating an invoice"
    poster={{ src: "/demo.webp", width: 1280, height: 720 }}
    sources={[
      { src: "/demo.webm", type: "video/webm" },
      { src: "/demo.mp4", type: "video/mp4" },
    ]}
    data-umami-event="play-demo"
  />
</WindowFrame>

LoopVideo

A silent loop that proves a claim: a themed still on the server, a muted clip that plays while half of it is in view, and a pause button whose pause lasts the visit.
A square crosses a card while a bar fills under it.
A six-second test loop, light and dark
A loop that cannot play.
A loop no browser can play: the still stays and the button goes away
Props
PropType and defaultWhat it does
poster{ src, srcset?, sizes?, width, height, dark? }The still: rendered on the server, shown until the clip has a frame, and kept when it cannot play. dark: { srcset, media } is its dark file and the query that picks it.
sources{ src, type }[]The clip's files in the light theme, WebM before MP4.
darkSources{ src, type }[]The clip's files in the dark theme, played while poster.dark.media matches; a theme change keeps the position.
titlestringWhat the clip shows: the video's name.
namestringA short name; the button says Pause animation: {name} or Play animation: {name}.
pauseLabelstring= "Pause animation"The button's first words while the clip plays.
playLabelstring= "Play animation"The button's first words while it is stopped.
classstringUtilities for the root, joined after its own.
Code
<WindowFrame variant="plain">
  <LoopVideo
    name="Realtime sync"
    title="A note saved in one window appears in the other within a second."
    poster={{
      src: "/realtime-light.webp",
      width: 1440,
      height: 900,
      dark: { srcset: "/realtime-dark.webp", media: "(prefers-color-scheme: dark)" },
    }}
    sources={[
      { src: "/realtime-light.webm", type: "video/webm" },
      { src: "/realtime-light.mp4", type: "video/mp4" },
    ]}
    darkSources={[
      { src: "/realtime-dark.webm", type: "video/webm" },
      { src: "/realtime-dark.mp4", type: "video/mp4" },
    ]}
  />
</WindowFrame>

YouTubeVideo

A YouTube video that asks YouTube for nothing until it is pressed: the caller's own poster with a play button, swapped for the youtube-nocookie.com player.
The player loads from YouTube only on press
Props
PropType and defaultWhat it does
videoIdstringThe video's YouTube id.
poster{ src, srcset?, sizes?, width, height }The caller's own still, shown before the press; its size keeps the box still.
titlestringWhat the video shows; the link is named Play video: {title}.
eagerboolean= falseLoad the poster at once, at high priority, for a video on the first screen.
...restanchor attributesAny other attribute, such as id or data-* for analytics, on the play link.
Code
<WindowFrame variant="plain">
  <YouTubeVideo
    videoId="aqz-KE-bpKQ"
    title="Big Buck Bunny"
    poster={{ src: "/big-buck-bunny.webp", width: 1280, height: 720 }}
    data-umami-event="play-video"
  />
</WindowFrame>

FaqList

Questions and answers as native disclosures that open with no JavaScript.

Can I export my data?

Yes. Every record exports to CSV from the settings page, at any time.

Is there a free plan?

Yes, for one person, with every feature and no time limit.

Can I host it myself?

Yes. The source is open and runs on any server with Docker.
Props
PropType and defaultWhat it does
items{ question, answer, id? }[]The questions; an id lets a link open the page at that question.
headingLevel2 | 3 | 4= 3The level of each question's heading.
namestringGroups the questions so opening one closes the others, where supported.
Code
<FaqList
  name="faq"
  items={[
    { question: "Can I export my data?", answer: "Yes, as CSV.", id: "faq-export" },
    { question: "Is there a free plan?", answer: "Yes, for one person." },
  ]}
/>

FeatureCard

One feature of a product on its public site: an icon, a heading, a line of text, a link the heading describes and an optional picture to the card's edges.

Works offline

Every record is on your device and syncs when you are back online.

Learn more

An admin panel

Every feature has its operator view on a host of its own.

Learn more
Your screenshot
Props
PropType and defaultWhat it does
idstringThe heading's id; it labels the card.
titlestringThe feature's name, the card's heading.
childrenComponentChildrenA line or two on the feature.
IconComponentType<IconProps>A glyph in an accent tile.
hrefstringWhere the link goes; left out, no link.
linkLabelstring= "Learn more"The link's text; the heading describes it.
mediaComponentChildrenA picture under the text.
wideboolean= falseSpans two columns of the grid from lg.
headingLevel2 | 3= 3The level of the heading.
Code
<div class="grid gap-6 lg:grid-cols-3">
  <FeatureCard id="feature-sync" title="Works offline" Icon={IconSprout} href="/features#sync">
    Every record is on your device and syncs when you are back online.
  </FeatureCard>
  <FeatureCard
    id="feature-admin"
    title="An admin panel"
    href="/features#admin"
    wide
    media={<img src="/admin.png" alt="The admin panel's list of users" />}
  >
    Every feature has its operator view on a host of its own.
  </FeatureCard>
</div>

Steps

A numbered how-it-works list, each number drawn by CSS in an accent circle and the steps joined by a thin line.
  1. Sign up

    Create an account with your email; no card needed.
  2. Import

    Bring your records in from a CSV file.
  3. Invite

    Add the people you work with.
Props
PropType and defaultWhat it does
items{ title, text? }[]The steps, in order.
headingLevel3 | 4= 3The level of each step's heading.
orientation"vertical" | "horizontal"= "vertical"horizontal puts the steps in a row from lg up; they stack below it.
Code
<Steps
  orientation="horizontal"
  items={[
    { title: "Sign up", text: "Create an account with your email." },
    { title: "Import", text: "Bring your records in from a CSV file." },
    { title: "Invite", text: "Add the people you work with." },
  ]}
/>

Explorable

A frame for an interactive figure in an article: a title kept out of the table of contents, the result, its controls and a caption; the controls stay disabled until the page's code runs.

What a yearly fee takes from your savings

Change the fee and the horizon; the bars show where 100 a month ends up.

The fee costs 9,847 over 25 years.

No fee
69,646
1.0% fee
59,799
  • Paid in: 30,000
Index fundManaged fund
Assumes 6% a year before fees, compounded monthly. An illustration, not advice.
Props
PropType and defaultWhat it does
titlestringThe figure's name, set as a paragraph.
leadComponentChildrenOne line on what to try.
childrenComponentChildrenThe result the controls change.
controlsComponentChildrenThe controls, in a <fieldset> that is disabled until the figure is live.
captionComponentChildrenThe <figcaption>, left-aligned under everything.
liveboolean= true once hydratedWhether the controls work; the Interactive line shows only while they do.
noScriptNoteComponentChildrenShown inside <noscript>: what the figure shows without scripts.
Code
<Explorable
  title="What a yearly fee takes from your savings"
  lead="Change the fee; the bars show where 100 a month ends up."
  caption="Assumes 6% a year before fees."
  controls={<Slider label="Yearly fee" value={fee.value} onInput={(next) => fee.value = next} />}
>
  <Bars data={rows} reference={{ value: paidIn, label: "Paid in" }} />
</Explorable>

ThemePreview

A frame that shows real components in the library's dark or light palette, switched by a Dark / Light pair that works without JavaScript.
Theme
Live
Props
PropType and defaultWhat it does
childrenComponentChildrenWhat the frame shows, drawn in the checked palette.
defaultTheme"dark" | "light"= "dark"The palette checked in the server markup.
labels{ legend?, dark?, light? }= "Theme", "Dark", "Light"The switch's words.
name / hideLegend / class / frameClassstring / boolean / string / stringThe radios' name (generated by default), a screen-reader-only legend, and utilities.
Code
// The app's stylesheet imports the palettes after tokens.css:
// @import "@spy4x/preact-theme/theme-preview.css";
<ThemePreview>
  <Button variant="primary">Save</Button>
  <Badge text="Live" color="green" />
</ThemePreview>

ScreenTour

A product walkthrough: named steps as tabs beside one browser or phone frame whose screenshot swaps, with a caption, a line of text and View full size.
The dashboard, a map of devices

The dashboard, a map of devices

Every device on one map, with its state.

The alerts list, newest first

Failures raise an alert by push, chat or email.

The schedules calendar

Time-of-day rules, sensor triggers and manual overrides.

The users table with roles

View full size
A list of templates

A list of templates

The camera with a script

One scene at a time, with the script on screen.

The finished clip playing

View full size

onSelect: not called yet

Props
PropType and defaultWhat it does
steps{ id, label, text?, group?, image: { src, alt, width, height, srcset?, sizes? } }[]The steps in order. alt is also the caption; group labels a run of steps in the column.
frame"plain" | "browser" | "phone"The frame drawn around the screenshot: plain for product screens, browser when the address matters.
browserUrlstringThe address in the browser's bar.
numberedboolean= falseNumber the tabs, for steps a person follows in order.
fetchPriority"high" | "low" | "auto"Set on the first screenshot, when the tour is the page's main image.
onSelect(id: string) => voidCalled with the step a person picks, never on the first render.
labelstring= "Screens"The tab list's accessible name.
viewFullSizeLabelstring= "View full size"The words of the link that opens the screenshot in the lightbox.
viewFullSizeClassstringExtra utilities for View full size, appended after its secondary-button classes, for an app's own button look.
closeLabelstring= "Close"The lightbox's close control; previousLabel, nextLabel and counterLabel too.
Code
<ScreenTour
  frame="plain"
  onSelect={(id) => track("tour", { step: id })}
  steps={[
    {
      id: "tour-dashboard",
      label: "Dashboard",
      group: "Monitor",
      text: "Every device on one map, with its state.",
      image: { src: "/dashboard.webp", alt: "The dashboard, a map of devices", width: 1280, height: 800 },
    },
    {
      id: "tour-alerts",
      label: "Alerts",
      group: "Monitor",
      image: { src: "/alerts.webp", alt: "The alerts list, newest first", width: 1280, height: 800 },
    },
  ]}
/>
<ScreenTour frame="phone" numbered steps={steps} viewFullSizeClass="rounded-full" />

WindowFrame

A plain frame, a browser window, a terminal or a phone drawn around a screenshot or a snippet, with an optional caption.
Your screenshot
A plain frame, for product media
Your screenshot
A browser window
$ curl -fsSL https://example.com/install.sh | sh -s -- --version 1.0.0 Installed in /usr/local/bin
A terminal, dark in both themes
Your screenshot
A phone
Props
PropType and defaultWhat it does
variant"plain" | "browser" | "terminal" | "phone"The frame drawn around the content: plain for product media, browser when the address matters; the terminal is dark in both themes.
titlestringThe browser's address or the terminal's title; it also names the terminal's output.
captionComponentChildrenThe figure's caption.
Code
<WindowFrame variant="plain" caption="The dashboard">
  <img src="/dashboard.png" alt="The dashboard with this month's totals" />
</WindowFrame>
<WindowFrame variant="browser" title="app.example.com/pricing">
  <img src="/pricing.png" alt="The pricing page" />
</WindowFrame>
<WindowFrame variant="terminal" title="Install">
  {"$ docker compose up -d"}
</WindowFrame>
<WindowFrame variant="phone">
  <img src="/phone.png" alt="The app on a phone" />
</WindowFrame>

Prose

The type of an article: rendered Markdown set from the design tokens at about 65 characters a line, with no JavaScript.

A background job is work the server does after it has answered: an email, a report, a clean-up. This post shows how to add one, with a worked example and the enqueueJob call.

Why not do it in the request

The person waits for the slowest step, and a crash halfway loses the work. A job is written down first and run after, so a retry picks it up.

  • It runs once the transaction that queued it commits.
  • It retries with a growing pause, up to twenty times.
    • Each failure keeps its error type.

Queue it in the same transaction

await enqueueJob(tx, "blog.publish", post.id, { at: post.publishAt })

A job that sends email may send it twice if it partly ran before.

Limits

SettingDefaultWhere it is setWhat it changes
Attempts20JOB_ATTEMPTSHow often a failed run is tried again
Kept after it finished7 daysJOB_KEEP_DAYSWhen a finished run leaves the Jobs page
Alert sent tohttps://hooks.example.com/jobs/failedJOB_FAILURE_WEBHOOK_URLWhere a run that failed for good is reported

Files

FileWhat it holds
apps/api/features/jobs/transports.test.tsThe tests of the HTTP and WebSocket routes that queue and rerun a job
apps/api/features/jobs/repository.tsThe queries that claim, finish and retry a run

Runs per month

JobMayJunJulAugSepOct
Email1,2041,3181,2771,4021,5111,630
Report313031313031
  1. Define the job.
  2. Queue it.

That is all it takes.

Props
PropType and defaultWhat it does
htmlstringRendered article HTML, set as innerHTML. It must already be safe: Prose styles it and cleans nothing.
childrenComponentChildrenThe article as elements, when there is no html.
as"div" | "article"= "div"The wrapping element.
anchorsboolean= falseA “Link to this section” link beside every h2 and h3 with an id, shown on hover and on focus. Tables always scroll in their own focusable region, named after the heading before them.
copyCodeboolean= falseA copy button in every code block's corner, rendered hidden until ProseCodeCopy or enhanceCodeCopy wires it, so a page without JavaScript shows none. Code blocks always keep their lines and scroll sideways.
labels{ anchor?, table?, copy?, copied?, copyFailed? }The anchor's name, the name of a table region with no heading before it, and the copy button's name and announcements.
Code
<Prose as="article" html={renderedMarkdown} anchors copyCode />

ProseCodeCopy

Wires the copy buttons Prose draws with copyCode: renders nothing, so a Fresh app makes it the page's one island.

Add the package, then start the app:

deno add jsr:@spy4x/preact-ui
deno task dev --port=8000 --watch=static/,routes/ --env-file=.env.local
Props
PropType and defaultWhat it does
copy(text: string) => void | boolean | Promise<void | boolean>Clipboard port; false, a throw or a rejection is a failed copy. Left out, the browser clipboard.
Code
// islands/code-copy.tsx
export { ProseCodeCopy as default } from "@spy4x/preact-ui/prose-copy"

// a route
import CodeCopy from "../islands/code-copy.tsx"
<Prose html={guide.html} copyCode />
<CodeCopy />

TldrBox

The few points a reader takes from an article, in a hairline box headed “In short”, before the body.

In short

  • A job is written down first and run after, so a crash loses nothing.
  • Failed runs retry on their own, and the admin can run one again.
  • Repeating jobs follow a cron schedule in UTC.
Props
PropType and defaultWhat it does
itemsComponentChildren[]The points, one sentence each. An empty list renders nothing.
titleComponentChildren= "In short"The heading.
headingLevel / id2 | 3 | 4 / string= 2 / generatedThe heading's level and its id, which names the section.
Code
<TldrBox items={post.tldr} />

PostList

A blog's index or its “Read next”: each post's day and reading time, its title as a link and two lines of description.
Props
PropType and defaultWhat it does
posts{ href, title, description?, publishedAt, readMinutes?, lang?, dir?, image? }[]The posts, newest first. publishedAt is shown as its UTC day. An empty list renders nothing. lang marks the title and description, such as an English post on an Arabic page, with dir="auto" unless dir is given. image ({ src, width, height, srcset?, sizes? }) is a decorative cover: full width and 16:9 above the text on a phone, 192×108 beside it from 640 px; a post without one stays text only.
eagerFirstboolean= falseThe list is on the first screen: the first cover loads with loading="eager" and fetchpriority="high", the rest lazily.
headingLevel / label2 | 3 | 4 / string= 2Each title's heading level, and the list's accessible name.
labels{ readTime? }The reading time's words, "6 min read" by default.
Code
<PostList posts={posts.map((post) => ({ ...post, lang: "en" }))} eagerFirst />

DocsNav

The side list of a set of guides, grouped, with the guide on screen marked; a <details> below lg.
Props
PropType and defaultWhat it does
groups{ title, items: { href, title }[] }[]The groups and their guides, in reading order. An empty list renders nothing.
currentstringThe href of the guide on screen: aria-current="page", the bar on the inner edge, and the folded summary's title.
labels{ label?, summary?, summaryNone? }The landmark's name ("Docs") and the folded summary ("Guides: {title}").
Code
<DocsNav groups={groups} current="/docs/deploy" />

PrevNext

Two cards at the end of a guide, the previous one and the next one, each a whole-card link.
Props
PropType and defaultWhat it does
previous / next{ href, title }The neighbours. Either may be left out; the next card stays on the end side. Neither, nothing renders.
labels{ label?, previous?, next? }The landmark's name and the line over each title, "Previous" and "Next".
Code
<PrevNext previous={{ href: "/docs/apps", title: "The three apps" }} next={next} />

ChangeList

What changed, month by month: new things listed, fixes folded in a <details> that counts them, each with its pull request.

October 2026

4 changes

New

  • People can sign in with a passkey. #612
  • The admin panel lists every background job and runs a failed one again. #609

Fixed

2 fixes
  • The menu closes on Escape. #611
  • A long name wraps on a phone. #607
Props
PropType and defaultWhat it does
months{ id, title, added, fixed, note? }[]The months, newest first; added and fixed are { text, number?, href? }[]. An empty list renders nothing.
headingLevel2 | 3= 2The month's heading level; New and Fixed sit one below.
labels{ added?, fixed?, changes?, fixes?, pullRequest?, anchor? }The words shown, such as "Pull request 612" for each number link.
Code
<ChangeList months={months} />

SectionHeading

A heading written as elements with Prose's “Link to this section” anchor beside it, for a page that is not one article.

Pricing

One plan, free while you host it yourself. Compare plans

Props
PropType and defaultWhat it does
childrenComponentChildrenThe heading's text.
idstringThe heading's own id, such as the one its section's aria-labelledby names.
targetIdstring= idThe id the anchor links to, when not the heading's. Give that element scroll-mt-16, as the heading has, so a sticky header does not cover it.
headingLevel2 | 3 | 4= 2The heading's level; each is drawn at Prose's size for it.
labels{ anchor? }The anchor's name.
classstringUtilities on the heading, merged after the defaults.
Code
<section aria-labelledby="pricing">
  <SectionHeading id="pricing">Pricing</SectionHeading>
  …
</section>

FactList

Labels and their values, such as a record's details, as a description list: stacked on a phone, two columns from sm.
Job
Password reset email
Status
Failed
Attempts
20 of 20
Last error
TimeoutError
The error type only. The message is not stored.
Kind
auth.password-reset-mail-with-a-long-name
Props
PropType and defaultWhat it does
facts{ label, value, hint?, id? }[]The rows in reading order. A hint is a muted line under the value; id is the row's key and data-e2e.
Code
<FactList
  facts={[
    { label: "Status", value: "Failed" },
    { label: "Attempts", value: "20 of 20" },
    { label: "Last error", value: "TimeoutError", hint: "The error type only." },
  ]}
/>

Breadcrumbs

Where a page sits on a site, as a trail of links above its h1; the last step is the page itself.
Props
PropType and defaultWhat it does
items{ label, href? }[]The trail from the root. The last step without href is the current page, marked aria-current="page". An empty trail renders nothing.
labelstring= "Breadcrumb"The landmark's name.
Code
<Breadcrumbs
  items={[
    { label: "Seed", href: "/" },
    { label: "Features", href: "/features" },
    { label: "Sign-in" },
  ]}
/>

OnThisPage

The sections of a long page: a sticky list in a side column on a wide screen, a disclosure on a narrow one.
Props
PropType and defaultWhat it does
links{ label, href, ...anchor attributes }[]The sections, in order, each with its fragment. Any other anchor attribute, such as data-*, passes through. An empty list renders nothing.
labelstring= "On this page"The list's heading and the landmark's name.
openFrom"lg" | "xl"= "lg"The width from which the list stands open; below it, it folds.
Code
<OnThisPage
  openFrom="xl"
  links={[
    { label: "What you get", href: "#what-you-get" },
    { label: "Limits", href: "#limits" },
  ]}
/>

LookTile

One look of a shop's catalog: a tall 3:4 photo as the card, then the name, the price and the sizes, all one link.
A brown zip jacket, worn closed

Zip jacket

590.000 ₫

  • S
  • M, your size
  • L, sold out
  • Brown
  • Grey
Props
PropType and defaultWhat it does
href / name / pricestring / string / ComponentChildrenThe item's page, its name (the link's text) and its formatted price.
photo{ variants, alt, sizes? }The stored widths as responsiveImage takes them, cropped to 3:4; sizes defaults to the tile's width in a LookGrid.
sizes / chosenSize{ label, soldOut? }[] / stringA sold-out size is struck through and announced; the chosen one is marked in the accent.
colors{ name, color }[]Dots after the sizes, each named for screen readers.
soldOutboolean= falseStrikes the price and says “Sold out” beside it.
viewTransitionNamestringNames the photo, so it grows into the item page's photo of the same name in a cross-document view transition.
priority / headingLevel / labelsboolean / 2 | 3 | 4 / object= false / 2Load the photo first, the name's heading level, and the words.
Code
<LookTile
  href="/jackets/zip-brown"
  name="Zip jacket"
  price="590.000 ₫"
  photo={{ variants: photoVariants, alt: "A brown zip jacket, worn closed" }}
  sizes={[{ label: "S" }, { label: "M" }, { label: "L", soldOut: true }]}
  chosenSize="M"
  colors={[{ name: "Brown", color: "#7a5230" }, { name: "Grey", color: "#8a8d91" }]}
  viewTransitionName="look-zip-brown"
/>

StoryTile

One post of a photo-led blog, made to sit beside LookTiles: a 3:4 cover, the topic, the title and the date, all one link.
Props
PropType and defaultWhat it does
href / title / publishedAtstring / string / stringThe post's page, its title (the link's text and name) and the day it went live, shown in UTC.
topic / readMinutesComponentChildren / numberA small line above the title, and the reading time after the date.
cover{ variants, alt, sizes? }As LookTile's photo, cropped to 3:4. Left out, the title is drawn large on a plain panel instead.
leadboolean= falseThe post the list opens with: a larger title, and from 1024 px two columns of a LookGrid with a 3:2 cover.
navigate(href: string) => voidTakes a plain click instead of the browser, as Link's port does.
priority / viewTransitionName / headingLevel / labelsboolean / string / 2 | 3 | 4 / object= false / – / 2Load the cover first, let it grow into the post, the title's heading level, and PostList's words.
Code
<LookGrid label="Stories">
  {posts.map((post, index) => (
    <StoryTile
      key={post.id}
      href={post.href}
      title={post.title}
      topic={post.topic}
      publishedAt={post.publishedAt}
      readMinutes={post.readMinutes}
      cover={post.cover}
      lead={index === 0}
      priority={index === 0}
    />
  ))}
</LookGrid>

LookGrid

A catalog's looks in aligned columns, one on a phone, two from 768 px and three from 1024 px, with no script.

Made to move.

Props
PropType and defaultWhat it does
childrenComponentChildrenThe looks, usually LookTiles or StoryTiles, each put in its own list item; one with lead spans two columns from 1024 px. None renders nothing.
labelstringThe list's accessible name, such as “New in”.
Code
<h1 class="text-6xl leading-tight font-extrabold tracking-tighter text-balance lg:text-9xl">
  Made to move.
</h1>
<LookGrid label="New in">
  {looks.map((look, index) => <LookTile key={look.id} {...look} priority={index === 0} />)}
</LookGrid>

OrderBar

The order step of a shop that sells by chat: the message about to be sent, in a bubble, above the buttons that send it.
A brown zip jacket, worn closed

Zip jacket

590.000 ₫

Colour
Size
Props
PropType and defaultWhat it does
messageComponentChildrenThe message as it will be sent; line breaks are kept and it can be selected.
actions[OrderBarAction] | [OrderBarAction, OrderBarAction]The first is accent-filled, a second outlined. Each is { label, shortLabel?, icon?, href?, name?, value?, attributes? }: a link with href, else a submit button.
secondaryOrderBarActionA quieter action, such as “Add to list”: its icon alone on a phone.
noteComponentChildrenOne short muted line under the actions at every width, such as a delivery promise.
status / form / labelsComponentChildren / string / objectA line in a live region (“Sold out”, “Message copied”), the form the submit buttons send, and the words.
Code
<OrderBar
  form="order"
  message={message}
  actions={[
    { label: "Order on Telegram", shortLabel: "Telegram", href: telegramLink },
    { label: "Order on Zalo", shortLabel: "Zalo", name: "via", value: "zalo" },
  ]}
  secondary={{ label: "Add to list", icon: <IconPlus />, name: "add", value: item.id }}
  note="Same-day delivery in Da Nang if you order before 16:00."
/>

Table

The frame of a data table: you write the cells, it draws the dividers, the hover and the scroll.
February transactions
DateMerchantStatusAmount
2026-02-01Coffee & Copending-450
2026-02-02Salary transfercleared450000
2026-02-03Amazon purchasepending-12999
Net436551
Props
PropType and defaultWhat it does
headerSlotComponentChildrenThe header row's cells.
bodySlotsComponentChildren[]One entry per row, each that row's cells.
bodyKeys(string | number)[]Each row's own identity, in the same order, so a reordered row keeps its state. Left out, rows are keyed by position.
footerSlotComponentChildrenWhole footer rows, such as a total.
captionComponentChildrenThe table's name, and the scroll area's while the table is too wide for it and Tab reaches it.
captionClassstringClasses for the caption, such as sr-only to hide it visually.
Code
<Table
  caption="February transactions"
  captionClass="sr-only"
  headerSlot={<th scope="col">Merchant</th>}
  bodySlots={rows.map((row) => <td>{row.merchant}</td>)}
  bodyKeys={rows.map((row) => row.id)}
  footerSlot={<tr>…</tr>}
/>

DataTable

A table whose columns sort and whose rows page, while you keep the sort and the page — or a server does the sorting and paging.

sort: none

Invoices
Flag
2026-02-01Coffee & Co-450
2026-02-02Salary transfer450000
2026-02-03Amazon purchase-12999

server: sort none, page 1, 3 of 8 rows fetched

Invoices, sorted and paged by the server
2026-02-01Coffee & Co-450
2026-02-02Salary transfer450000
2026-02-03Amazon purchase-12999

This "server" orders text by code unit, so "eBay refund" sorts after every capitalised name. Server mode shows its order as given; client mode would re-sort it.

Props
PropType and defaultWhat it does
captionstringThe table's name, which only you know, so it is required.
columnsDataTableColumn[]Each column's key, header, alignment and whether it sorts.
sortSortRule[]The current sort, with onSortChange to change it.
paging{ page, pageSize, onChange }Pages the rows when given; left out, every row shows. In server mode it also takes total.
mode"client" | "server"= "client"Who sorts and pages. "server" shows rows as given — one page, already sorted — and sizes the pager from paging.total.
rowKey(row) => string | numberEach row's own identity, so its checkbox, focus or state moves with it when the rows sort or page.
Code
<DataTable
  caption="Invoices"
  columns={[
    { key: "date", header: "Date", sortable: true },
    { key: "merchant", header: "Merchant", sortable: true },
    { key: "amount", header: "Amount", sortable: true, align: "right" },
    { id: "flag", header: "Flag", render: (row) => <Checkbox aria-label={`Flag ${row.merchant}`} /> },
  ]}
  rows={invoices}
  rowKey={(row) => row.id}
  sort={sort.value}
  onSortChange={(next) => sort.value = next}
  paging={{ page: page.value, pageSize: 3, onChange: (next) => page.value = next }}
/>

// Server mode: the server sorts and pages; DataTable shows its page as given.
<DataTable
  mode="server"
  caption="Invoices"
  columns={columns}
  rows={response.rows}
  rowKey={(row) => row.id}
  sort={sort.value}
  onSortChange={(next) => sort.value = next}
  paging={{
    page: page.value,
    pageSize: 3,
    total: response.total,
    onChange: (next) => page.value = next,
  }}
/>

Card

A surface that groups related content in an optional header, body and footer, and gives a focus ring inside it a gap in its own colour.
Ada Lovelace

Administrator, since March 2026.

Code
<Card>
  <CardHeader title="Invoices" action={<Button size="sm">New</Button>} />
  <CardBody>…</CardBody>
  <CardFooter><Button variant="outline" size="sm">Dismiss</Button></CardFooter>
</Card>

// A ref reaches the card's own element:
<Card ref={cardRef} tabIndex={-1}>…</Card>

CardHeader

A card's top row: a title with an optional action, or your own markup instead.
Invoices
3 open

Payments

h3

Your own header

children
Props
PropType and defaultWhat it does
titlestringThe card's title.
actionComponentChildrenShown on the right of the title.
headingLevel2 | 3 | 4 | 5 | 6Draws the title as a heading of that level, so screen readers can jump to it; without it the title is plain text.
childrenComponentChildrenYour own header, in place of the title.
Code
<CardHeader title="Invoices" action={<Badge text="3 open" />} />

<CardHeader title="Payments" headingLevel={3} />

<CardHeader>
  <h4>Your own header</h4>
</CardHeader>

CardBody

The padded content area of a card.

Header, body and footer are each optional.

Code
<Card>
  <CardBody>
    <p>Header, body and footer are each optional.</p>
  </CardBody>
</Card>

CardFooter

A row under a card's content, divided from it by a line, usually for its buttons.

Save the draft before you leave?

Code
<CardFooter class="justify-end">
  <Button variant="outline" size="sm">Dismiss</Button>
  <Button size="sm">Open</Button>
</CardFooter>

CopyBlock

Monospace text in a box with a copy button, never clipped, and a screen-reader announcement.

Wraps inside its box by default.

deno add jsr:@spy4x/preact-ui jsr:@spy4x/preact-icons jsr:@spy4x/preact-theme

With singleLine, text wider than its box scrolls sideways inside it: Tab to the text, then press the arrow keys.

deno add jsr:@spy4x/preact-ui jsr:@spy4x/preact-icons jsr:@spy4x/preact-theme

With singleLine and text that fits, there is nothing to scroll, so the text is no Tab stop.

0192f7c1-4d5e
0192f7c1-4d5e-7a8b-9c0d-1e2f3a4b5c6d

copy port received: nothing yet

Props
PropType and defaultWhat it does
textstringThe text shown and copied.
singleLineboolean= falseKeeps the text on one line, scrolling inside the box instead of wrapping.
textLabelstring= "Text to copy"Names singleLine text that overflows its box: only then is it a Tab stop, which the arrow keys scroll.
copy(text) => void | boolean | Promise= the clipboardReplaces the clipboard; throwing, rejecting or returning false is a failure.
copyLabelstring= "Copy"The copy button's name.
copiedLabelstring= "Copied"What a screen reader hears after a copy.
failedLabelstring= "Couldn't copy"What a screen reader hears after a copy that failed.
Code
<CopyBlock text="deno add jsr:@spy4x/preact-ui" copyLabel="Copy command" />
<CopyBlock text={apiKey} singleLine textLabel="API key" copyLabel="Copy API key" />

AvatarGroup

Overlapping avatars of a group, with a count for the ones that do not fit.

3 members: nothing overflows, so no count

7 members, max 4: the chip counts the other 3

size "sm"

Props
PropType and defaultWhat it does
items{ name, src? }[]The members.
maxnumber= 4How many avatars show before the count.
labelstring= "Avatars"The group's name; the member count is added to it.
Code
<AvatarGroup items={members} label="Project members" max={4} size="sm" />

Avatar

A round picture of a person that falls back to their initials, then to an icon.
AL

xs

AL

sm

AL

md

AL

lg

Ada Lovelace

image

GH

initials

Alan Turing

broken image: initials

王小

CJK initials

no initial: icon

alt="": decorative

Props
PropType and defaultWhat it does
namestringWhose avatar it is; the initials come from it.
srcstringThe picture; a failed load shows the initials.
size"xs" | "sm" | "md" | "lg"= "md"The avatar's diameter.
altstring= nameIts accessible name; an empty string hides it from screen readers.
Code
<Avatar name="Ada Lovelace" size="lg" />
<Avatar name="Ada Lovelace" src={user.avatarUrl} />
<Avatar src={user.avatarUrl} alt="" />

Tabs

Tabs that switch between panels, in a row, with the arrow keys.

The left and right arrows move between tabs; Home and End jump to the ends. 1 to 4 select a tab; 3 does nothing, because Archived is disabled.

Totals for the current period.
Props
PropType and defaultWhat it does
tabsTabItem[]Each tab's id, label, panel content, whether it is disabled, and an optional hotkey that selects it.
activestringThe selected tab's id, with onChange to change it.
labelstringThe tab list's accessible name.
hotkeyHintboolean= trueShows each tab's hotkey in a Kbd hint after its label.
kbdLabelsPartial<KbdLabels>= EnglishThe words the hints show for a key.
Code
<Tabs
  tabs={[
    { id: "overview", label: "Overview", content: <Overview />, hotkey: "1" },
    { id: "detail", label: "Detail", content: <Detail />, disabled: true },
  ]}
  active={view.value}
  onChange={(id) => view.value = id}
  label="Report views"
/>

Pagination

Page numbers with previous and next buttons, a long run of pages shortened to an ellipsis.

5 pages, on page 1

Last page asked for through onChange: none yet

24 pages, on page 12: 1 … 10 11 12 13 14 … 24

0 pages: nothing rendered

Props
PropType and defaultWhat it does
pagenumberThe current page, counted from 1.
pageCountnumberHow many pages; 0 renders nothing.
onChange(page: number) => voidCalled with the page a reader asks for.
labelstring= "Pagination"The navigation's accessible name.
Code
<Pagination page={page.value} pageCount={24} onChange={(next) => page.value = next} />

Tooltip

A short hint that appears when its trigger is hovered or focused.

top (default)

end

bottom

start

Keeps its own tab stop

focusable=false, around a button

Props
PropType and defaultWhat it does
contentComponentChildrenThe hint.
labelstringThe trigger's accessible name.
placement"top" | "end" | "bottom" | "start" | "right" | "left"= "top"Which side of the trigger the hint appears on. end opens toward the end of the line, so it mirrors in a right-to-left page; right and left are the old names of end and start.
focusableboolean= truePass false when the trigger is already a button or a link.
Code
<Tooltip content="Supplements the trigger" label="Total revenue" placement="end">
  <span>Revenue</span>
</Tooltip>

<Tooltip content="Archive the invoice" label="Archive" focusable={false}>
  <button type="button">Archive</button>
</Tooltip>

ImageGallery

Thumbnails, or a snapping row of large images, that open each image full size in a lightbox.

5 images passed in, 4 shown: the one with a blank description is left out.

With layout="strip": one row of large images that scrolls sideways and snaps to each one. Tab moves through them, and Enter opens the one in focus.

  • The sign-in screen
  • The dashboard
  • The invoice list
  • The settings page, sent as WebP
  • The billing page

1 / 5

With hero, captions, navigation and snap="center": the first image loads first, each caption names its image, the counter follows the slide in the middle, and Previous slide and Next slide scroll one slide; navigationVariant="ghost" leaves them transparent inside their border. The lightbox is named after the open image and shows no second caption. The fourth image is sent as WebP.

1 / 3

With slideWidth="orientation" and portrait images: narrower slides. On a wide screen all three fit, so Previous and Next are not shown. With lazy, the first image loads lazily too, for a strip below the page's own hero image.

Props
PropType and defaultWhat it does
imagesImageGalleryImage[]Each with its alt; thumbSrc is the grid's small image, width and height the strip's intrinsic size, webpSrc a WebP source for the strip and the lightbox.
layout"grid" | "strip"= "grid"A wrapping grid of thumbnails, or one row that scrolls sideways and snaps.
heroboolean= falseStrip only: the first image loads eagerly with fetchpriority="high", the rest lazily.
lazyboolean= falseStrip only: every image loads lazily, the first included, for a strip below the page's own hero image. Ignored with hero.
captionsboolean= falseStrip only: each image's alt as a visible caption under it.
navigationboolean= falseStrip only: a counter under the row, and Previous and Next while the row overflows.
snap"start" | "center"= "start"Strip only: where a slide snaps to.
slideWidth"wide" | "orientation"= "wide"Strip only: narrower slides when the first image is portrait.
stripPreviousLabel / stripNextLabelstringStrip only: names of the row's Previous and Next; default previousLabel and nextLabel.
labelstring | ((image, position, total) => string)= "Image viewer"The lightbox's name; a function names it after the open image.
lightboxCaptionboolean= trueWhether the lightbox shows the image's alt as a visible caption.
controls"overlay" | "below"= "overlay"Where the lightbox puts its counter and buttons.
Code
<ImageGallery
  images={[
    { src: hero, alt: "A hero shot" },
    { src: team, alt: "The team", thumbSrc: teamThumb },
  ]}
/>

<ImageGallery
  layout="strip"
  images={[{ src: shot, alt: "The dashboard", width: 1600, height: 900 }]}
/>

<ImageGallery
  layout="strip"
  hero
  captions
  navigation
  snap="center"
  counterLabel={(position, total) => `${position} / ${total}`}
  images={[{ src: shot, webpSrc: shotWebp, alt: "The dashboard", width: 1600, height: 900 }]}
/>

Lightbox

A dialog that shows one image of a set at a time, with previous and next.

The "no description" button opens nothing: its one image has no description, so there is nothing to show.

Props
PropType and defaultWhat it does
imagesLightboxImage[]The set, each with its alt.
indexnumberThe image showing.
openbooleanWhether the dialog is open, with onClose to close it.
onIndexChange(index: number) => voidCalled when the reader moves to another image.
controls"overlay" | "below"Counter and previous/next over the image (default) or in a row below it.
labelstring | ((image, position, total) => string)= "Image viewer"The dialog's name; a function names it after the open image.
captionboolean= trueWhether the image's alt shows as a visible caption.
images[].webpSrcstringThe image as WebP, offered first in a <picture> with src as fallback.
Code
<Lightbox
  images={[{ src: "/shot.png", webpSrc: "/shot.webp", alt: "A hero shot" }]}
  index={index}
  open={open}
  onClose={() => setOpen(false)}
  onIndexChange={setIndex}
  controls="below"
/>

AttachmentList

The files attached to a record, each uploading, waiting, checking, failed or ready, with a menu per file and images that open in a lightbox.

Attachments 7 of 10

  • map.jpg
    Uploading, 70%
  • receipt.pdfWaiting to upload
  • invoice.pdf
    Checking…
  • scan.pdfUpload failed.

Last action: none

Props
PropType and defaultWhat it does
idstringPrefix of the rows' element ids.
itemsAttachmentItem[]The files: id, name, size, type, status, progress, thumbnailSrc, src, detail, error.
onDownload / onRemove(item) => voidA ready file's menu items; left out, the item goes and the list is read-only.
onCancel / onRetry(item) => voidCancel while uploading, Retry once failed; left out, no button.
removingId / focusAfterLaststring | null / () => HTMLElementThe removal running, so focus moves to the next row; where it goes after the last.
labelsPartial<AttachmentListLabels>Every word it shows, English by default.
Code
<AttachmentList
  id="note-files"
  items={[
    { id: "1", name: "tickets.png", size: 839680, type: "image/png", status: "ready",
      thumbnailSrc: thumb, src: full },
    { id: "2", name: "map.jpg", size: 1153434, type: "image/jpeg", status: "uploading",
      progress: 70 },
  ]}
  removingId={removingId}
  onDownload={download}
  onRemove={confirmRemove}
  onCancel={cancel}
  onRetry={retry}
/>

ZoomableImages

Lets a reader open any image inside a container at full size, by click or keyboard, and page through the others.

Click an image, or Tab to it and press Enter. The second sits in a link, which opening the lightbox does not follow. The third is a crop that opens the whole screen.

A placeholder imageA placeholder image inside a linkThe top-left corner of a placeholder screen

A second container with the same attribute zooms too. Focus moves here: 0, images opened: 0.

A placeholder image in the second container

With fallbackAlt="", an image with no description stays a plain image, and its link still works.

(the link's target)
Code
<ZoomableImages
  containerSelector="[data-lightbox]"
  fallbackAlt="Figure"
  zoomLabel="Zoom"
  onOpen={(image) => analytics.track("lightbox", image.src)}
/>
<img src="crop.png" alt="The sign-in form"
  data-lightbox-src="full.png" data-lightbox-webp="full.webp"
  data-lightbox-alt="The whole sign-in screen" />

Timeline

Events in order, as a dot per entry on a line that joins them, each with its title, time and an optional description.
  1. Blocked by an operator
    Sam Rivera, from 203.0.113.7
  2. Sessions ended
  3. Email confirmed
  4. Signed up
Props
PropType and defaultWhat it does
itemsTimelineItem[]Each entry's id, title, at (an ISO instant) and when (the time as it reads), with an optional description, tone and dataE2E.
labelstring= "Timeline"The list's accessible name.
emptyComponentChildren= "Nothing yet."What shows when there are no entries.
Code
<Timeline
  label="Activity"
  items={[
    { id: 3, title: "Blocked", at: "2026-03-04T10:15:00Z", when: "today", tone: "danger" },
    { id: 2, title: "Sessions ended", at: "2026-03-03T18:40:00Z", when: "yesterday", tone: "warning" },
    { id: 1, title: "Signed in", at: "2026-03-03T08:00:00Z", when: "yesterday" },
  ]}
/>

Isolated

Puts a value, such as a name someone typed, into a translated sentence in its own direction: a Latin file name in an Arabic sentence stays whole, and the question mark stays at the sentence's end.

Delete report-v2.pdf?

هل تريد حذف report-v2.pdf؟

Props
PropType and defaultWhat it does
message(value: string) => stringThe sentence, as a label function of the value.
valuestringThe value, such as a name the user gave.
Code
<Isolated message={(name) => `Delete ${name}?`} value="report-v2.pdf" />
<p dir="rtl">
  <Isolated message={(name) => `هل تريد حذف ${name}؟`} value="report-v2.pdf" />
</p>

Kbd

Shows a key, a key combination or a sequence of presses, with ⌘ on Apple platforms and Ctrl elsewhere, and names each glyph for screen readers.
keysapple={false}applemod+kCtrl+KCommandKmod+shift+pCtrl+Shift+PShiftCommandPalt+upAlt+Up arrowOptionUp arrow???escEscEscg tG then TG then T

On this device:

Ctrl+K

Written by hand:

Tab
Props
PropType and defaultWhat it does
keysstringA combination such as mod+k, written the way useHotkeys takes it, or a sequence such as g t, read as "G then T".
applebooleanDraw Apple's glyphs. Left out, an effect reads the platform after hydration.
labelsPartial<KbdLabels>The words for each key, such as Ctrl and Command.
Code
<Kbd keys="mod+k" />
<Kbd keys="mod+shift+p" apple />
<Kbd keys="g t" />
<Kbd>Tab</Kbd>

ShortcutsDialog

A dialog that lists keyboard shortcuts by group, which useHotkeys opens when you press ? on this page.

Press ? to open the list. Plain I pressed 0 times, Ctrl+I pressed 0 times. Enter pressed 0 times, Escape pressed 0 times.

Props
PropType and defaultWhat it does
openbooleanWhether it is open, with onClose.
shortcutsShortcut[]{ keys, description, group? } rows; the same array useHotkeys takes. keys may be a sequence such as g t.
titlestring= "Keyboard shortcuts"The dialog's title.
footerComponentChildrenBelow the list, after a divider: a note on when the shortcuts work, or a switch that turns them off.
Code
const open = useSignal(false)
const bindings = [
  { keys: "?", description: "Show keyboard shortcuts", handler: () => open.value = true },
  { keys: "mod+k", description: "Search", group: "Navigation", handler: openSearch },
]
useHotkeys(bindings)
// A sequence row, bound by a sequence matcher of your own
const rows = [...bindings, { keys: "g t", description: "Go to Today", group: "Navigation" }]

<ShortcutsDialog
  open={open.value}
  onClose={() => open.value = false}
  shortcuts={rows}
  footer={<p class="text-sm text-muted">Shortcuts don't work while you type in a field.</p>}
/>

KanbanBoard

Columns of cards a reader moves by touch, mouse or keyboard; the board reports each move through onMove and never reorders the data itself.
  • To do3

    • Write the release notes
    • Draw the empty-state icon
    • Rank search results
  • Doing1

    • Export to CSV
  • Review1

    • Sign in with a passkey
  • Done0

    Press Enter to open the card, or Space to pick it up. Use the arrow keys to move it, Space to drop it, and Escape to put it back.

    Last move through onMove: none yet

    Last card opened through onOpen: none yet

    Props
    PropType and defaultWhat it does
    items{ id: string; column: string }[]Every card, in the order each column shows them.
    onMove(move: KanbanMove) => voidCalled with itemId, toColumn and toIndex once per drop; moveKanbanItem applies it.
    onOpen(item) => voidOptional. A click that is not a drag, or Enter, opens the card; Space picks it up.
    itemLabel(item) => stringA card's name in the announcements.
    labelsPartial<KanbanBoardLabels>Overrides for the board's name, the instructions and every announcement.
    Code
    <KanbanBoard
      columns={[
        { id: "todo", title: "To do" },
        { id: "doing", title: "Doing" },
        { id: "done", title: "Done" },
      ]}
      items={tasks.value}
      renderItem={(task) => task.title}
      itemLabel={(task) => task.title}
      onMove={(move) => tasks.value = moveKanbanItem(tasks.value, move)}
      onOpen={(task) => openEditor(task)}
    />

    SortableList

    A list a reader reorders by dragging a handle, by touch or mouse, or from the keyboard; it reports each move through onMove and never reorders the data itself.
    • Buy milk
    • Call the plumber
    • Take the bins out
    • Water the plants
    • Post the parcel
    Press Space or Enter to pick the item up. Use the arrow keys to move it, Space or Enter to drop it, and Escape to cancel.

    Moves through onMove: 0, the last none yet

    Props
    PropType and defaultWhat it does
    items{ id: string }[]The items, in the order they are shown.
    onMove(from: number, to: number) => voidCalled once per drop that changes the order, with the old and new index.
    itemLabel(item) => stringAn item's name: its handle reads Reorder <name>, and so do the announcements.
    labelsPartial<SortableListLabels>Overrides for the handle name, the instructions and every announcement.
    Code
    <SortableList
      items={tasks.value}
      renderItem={(task) => task.title}
      itemLabel={(task) => task.title}
      onMove={(from, to) => {
        const next = [...tasks.value]
        next.splice(to, 0, ...next.splice(from, 1))
        tasks.value = next
      }}
    />

    CommandPalette

    A search button and the dialog it opens, with results grouped under headings that the arrow keys move through.

      • New invoiceAction
      • Export to CSVAction
      • InvoicesBilling
      • CustomersSales
      • Crème brûléeRecipes
      • SettingsAccount

    Nothing matches.

        • ComboboxFormsTypo fixed

      Jumped to nothing yet; fruit nothing yet; searches started 0; answered 0; aborted none; component nothing yet. One letter answers slowly, longer queries quickly, “slow” takes twenty seconds, “dragon” finds a long name, and “boom” fails. The field above opens on “combobx”; try “ещфые” or “календарь”.

      Props
      PropType and defaultWhat it does
      options{ id, label, detail?, hint?, group?, aliases? }[]Options it filters itself. The default filter, matchOptions, forgives a typo, any word order, the other keyboard layout and the other alphabet, and finds an option by its aliases. Or search, not both.
      search(query, signal) => Promise<options>Asks a server after a pause in typing; a stale call is aborted and its answer ignored. The last results stay while the next loads, with “Searching…” beside the field after 300 ms; Enter waits for the answer.
      onSelect(option) => voidThe picked option.
      trigger"button" | "field"= "button"field draws the field-shaped trigger at every width, phones included, as wide as its container; a letter typed on it opens the palette with that letter.
      defaultQuerystringThe query the field holds, selected, each time the palette opens.
      showMatchboolean= falseSays beside each result how it matched: a typo fixed, the other layout, another name. Every result carries the kind in data-match either way.
      matchLabelsPartial<Record<CommandPaletteMatchKind, string>>The words showMatch shows; see defaultCommandPaletteMatchLabels.
      hotkeysstring[]= ["/", "mod+k"]What opens it, by the rule of a button's hotkey: each clicks the trigger, which announces them all in aria-keyshortcuts and shows the first as its hint. [] turns them off. In a text field only a Control, Command or mod chord fires, never Alt, and never the field's editing keys (A, C, V, X, Z, Y).
      kbdLabelsPartial<KbdLabels>The words the trigger's hint shows for a key; see KBD_LABELS.
      debouncenumber= 200Quiet milliseconds before search.
      labelsPartial<CommandPaletteLabels>The field's name, placeholder, close button and the empty, loading and error words.
      Code
      const options = [
        { id: "new", label: "New invoice", hint: "Action", group: "Actions" },
        { id: "invoices", label: "Invoices", detail: "Billing", group: "Pages" },
      ]
      <CommandPalette options={options} hotkeys={["mod+p"]} onSelect={(option) => run(option.id)} />
      
      <CommandPalette
        options={[{ id: "toast", label: "Toast", aliases: ["notification"] }]}
        trigger="field"
        defaultQuery="ещфые"
        showMatch
        onSelect={(option) => run(option.id)}
      />
      
      <CommandPalette
        search={(query, signal) => fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal }).then((r) => r.json())}
        hotkeys={[]}
        onSelect={open}
      />

      WidgetCard

      One dashboard widget's card, with a title, a muted caption, its own menu, and a body that loads, fails with a retry or goes empty on its own.

      Notes

      In the group now

      31

      +4 vs previous 30 days

      Retries: 0

      Props
      PropType and defaultWhat it does
      status"loading" | "error" | "empty" | "ready"Which body to draw; the skeleton waits 300 ms before it shows.
      titlestringThe heading; with href, a link.
      menuComponentChildrenDropdownItems for the card's three-dots menu.
      onRetry() => voidReads the widget again from the error body's button.
      Code
      <WidgetCard
        title="Notes"
        caption="In the group now"
        status={count.status}
        loadingLabel="Loading the note count"
        errorMessage="We could not load the note count."
        onRetry={count.reload}
        menu={<DropdownItem onClick={hide}>Hide widget</DropdownItem>}
      >
        <WidgetStat value={count.value} lines={["+4 vs previous 30 days"]} />
      </WidgetCard>

      WidgetStat

      A stat widget's body: one large number in tabular figures and muted lines under it, never coloured as good or bad.

      9

      Added or edited in this range

      −2 vs previous 30 days

      Props
      PropType and defaultWhat it does
      valuestring | numberThe number, already formatted.
      linesComponentChildren[]What it counts, and its change against the period before.
      Code
      <WidgetStat value="1.2M" valueLabel="1,234,567" lines={["In the group now"]} />

      DashboardGrid

      Lays widgets out in order, consecutive stats sharing one row that fills the width and each full widget on a row of its own.

      Notes

      12

      Notes changed

      12

      Notes over time

      …

      Props
      PropType and defaultWhat it does
      items{ id, size: "stat" | "full", content }[]The widgets, in the order they are shown.
      labelstringThe region's accessible name.
      Code
      <DashboardGrid label="Widgets" items={widgets.map((w) => ({ id: w.id, size: w.size, content: w.render() }))} />

      LayoutCustomiser

      A dialog that reorders and hides a page's parts with a sortable list and a switch per row, reporting every change at once so there is nothing to save.

      Shown: Notes, Notes changed, Notes over time

      Changes: 0

      Notes

      12

      Notes changed

      12

      Notes over time

      …

      Props
      PropType and defaultWhat it does
      items{ id, title, hidden }[]Every part the person may see, in the current order.
      onChange(layout: { id, hidden }[]) => voidThe whole new layout after one move or one switch.
      onReset() => voidPuts the default layout back.
      errorstring | nullWhy the last change could not be saved.
      Code
      <LayoutCustomiser
        open={open.value}
        onClose={() => open.value = false}
        title="Customise overview"
        items={mergeLayout(saved, available).map((e) => ({ ...e, title: titleOf(e.id) }))}
        onChange={save}
        onReset={() => save(mergeLayout(null, available))}
      />

      Feedback

      What a page shows while it is busy, empty or broken, and the dialogs and notifications that speak to the user.

      EmptyState

      What a list, a table or a search shows when it has no rows yet.

      No invoices yet

      Invoices appear here once a customer is billed.

      No filters applied

      No matches

      This title is an h4, under the card's h3; a whole-page empty state passes 1.

      Devices

      No devices yet

      Inset: no dashed box inside the card, and a card body's padding.

      Props
      PropType and defaultWhat it does
      titlestringWhat is missing.
      headingLevel1 | 2 | 3 | 4= 3The title's heading tag; 1 when the empty state is the whole page. Same size.
      descriptionstringWhy, or what to do next.
      iconComponentChildrenA glyph above the title.
      actionComponentChildrenA button that fills the list.
      insetboolean= falseNo box, a card body's padding: for an empty section inside a Card.
      Code
      <EmptyState
        icon={<IconFolder class="size-5" />}
        title="No invoices yet"
        description="Invoices appear here once a customer is billed."
        action={<Button size="sm">New invoice</Button>}
      />
      
      // A 404 page whose whole content is the empty state:
      <EmptyState headingLevel={1} title="Page not found" />
      
      // An empty section inside a card, in place of its CardBody:
      <Card>
        <CardHeader title="Devices" />
        <EmptyState inset title="No devices yet" />
      </Card>

      ErrorBoundary

      Catches a view that throws while it renders and shows a reload screen instead of a blank page.

      Errors reported: 0, the last nothing yet. Reloads asked for: 0. This demo's reload mends the view instead of reloading the page.

      Props
      PropType and defaultWhat it does
      onError(error: unknown, errorInfo: ErrorInfo) => voidCalled once with each caught error and, when Preact has one, its component stack, for your reporter.
      onReload() => void= location.reload()What the button does. Afterwards the boundary shows its children again.
      titlestring= "Something went wrong."The headline.
      descriptionstring= "Reloading the page usually fixes it."The line under it.
      reloadLabelstring= "Reload the page"The button's text.
      headingLevel1 | 2 | 3 | 4= 3The title's heading tag; 1 when the boundary guards the whole page.
      Code
      <ErrorBoundary onError={reportError} headingLevel={1}>
        <App />
      </ErrorBoundary>

      ErrorState

      The message for an expected failure, where its result would have been; nothing when there is none.

      The second one has an empty message, so nothing shows.

      Each row starts with
      • Ada
      • Grace
      • Linus

      on the list: Ada, Grace, Linus · removing nobody

      Code
      <ErrorState message={error.value} />
      
      // A refusal inside the dialog that asked: it takes focus, and is gone when the dialog opens again.
      const [shown, opened] = useFreshError(error, pending)
      <ErrorState focusOnAppear message={shown} />
      
      // A removed row hands focus to the row that took its place.
      useFocusAfterRemoval(list, ids, removingId, { fallback: () => addButton.current })

      ErrorSummary

      A titled list of problems in what the reader is editing, each row a button that takes them to it.

      2 problems

      2 problems in this post

      Readers won't see these lines. The rest of the post shows as written.
      • There's no component called CostCalc.
      • There's no component called Gauge.
      Props
      PropType and defaultWhat it does
      titlestringThe panel's title; it names the region.
      childrenComponentChildrenOne line under the title.
      itemsreadonly ErrorSummaryItem[]{ key, label, message } per problem: the row's button and what is wrong. With none, only the status line is drawn.
      onSelect(item: ErrorSummaryItem, index: number) => voidTakes the reader to the problem, such as with selectTextareaLine.
      countMessage(count: number) => string= "2 problems"The polite status line's text while there are problems.
      noneMessagestring= "No problems left"The status line's text once the last problem is gone.
      Code
      const textarea = useRef<HTMLTextAreaElement>(null)
      
      <ErrorSummary
        title="2 problems in this post"
        items={problems.map((p) => ({ key: `${p.line}`, label: `Line ${p.line}`, message: p.message }))}
        onSelect={(_, index) => selectTextareaLine(textarea.current!, problems[index].line)}
      >
        Readers won't see these lines. The rest of the post shows as written.
      </ErrorSummary>

      Notice

      A page-wide banner: a trial that ends soon, a change that saved, a failed payment.

      Scheduled maintenance

      Sync pauses for ten minutes on Sunday at 02:00.

      Your trial ends in 3 days

      Add a payment method to keep your data.

      Export ready

      The file is in your downloads.

      Payment failed

      Your card was declined, so the plan renews on the free tier.
      This account lives only in this browser. Add a sign-in method to keep it.
      Props
      PropType and defaultWhat it does
      tone"info" | "warning" | "success" | "danger"= "info"The border, the tint and the glyph.
      titlestringThe first line, in a heavier weight.
      childrenComponentChildrenThe body under the title.
      actionComponentChildrenA button or link beside it.
      urgentboolean= falseInterrupts the screen reader (role="alert"). It wins over live.
      liveboolean= trueA polite status (role="status") that a screen reader reads out when it appears. false drops the role, for standing instructions that the reader should find while reading.
      onDismiss() => voidDraws a close button in the top-right corner and calls this when it is pressed. The caller hides the notice.
      dismissLabelstring= "Dismiss"The close button's accessible name.
      Code
      <Notice
        tone="warning"
        title="Your trial ends in 3 days"
        action={<Button size="sm">Add a payment method</Button>}
      >
        Add a payment method to keep your data.
      </Notice>
      
      // A failure the reader must act on, heard now:
      <Notice
        tone="danger"
        urgent
        title="Payment failed"
        action={<Button size="sm" href="/billing">Update card</Button>}
      >
        Your card was declined, so the plan renews on the free tier.
      </Notice>
      
      // Standing instructions that arrive after a fetch, found while reading:
      <Notice live={false} title="Public demo">
        Anyone can sign in with this account.
      </Notice>
      
      // One the reader can close; the caller hides it and remembers that:
      {shown.value && (
        <Notice
          onDismiss={() => shown.value = false}
          dismissLabel="Hide this message"
          action={<Button size="sm" variant="outline" href="/keep">Keep your account</Button>}
        >
          This account lives only in this browser. Add a sign-in method to keep it.
        </Notice>
      )}

      LoadingSpinner

      A spinning circle for something that is loading, with an optional caption, which stands still when the system asks for reduced motion.

      Small

      Medium

      Large

      Loading your workspace…

      The full-page recipe, held to a fixed height here; in an app, min-h-dvh makes it the whole window.

      Props
      PropType and defaultWhat it does
      labelstringA caption under the spinner, which a screen reader also hears.
      loadingLabelstring= "Loading"The hidden word a screen reader hears when there is no label.
      size"sm" | "md" | "lg"= "md"The spinner's size.
      Code
      <LoadingSpinner size="lg" label="Loading transactions…" />
      
      // An app's first load: the whole screen
      <LoadingSpinner size="lg" label="Loading your workspace…" class="min-h-dvh bg-canvas" />

      LoadingSkeleton

      Grey placeholder cards that hold a page's shape while its content loads, or a form's fields inside a card, and stop pulsing when the system asks for reduced motion.

      With fields, a form's shape and no card of its own, for a card body whose form is on its way.

      Code
      <LoadingSkeleton rows={1} />
      
      // Inside a card, in place of a form's fields: no card of its own.
      <LoadingSkeleton fields={2} />

      Modal

      A dialog that holds focus until it is closed, on the browser's own <dialog>.

      Step 1; onClose last read nothing yet

      Props
      PropType and defaultWhat it does
      openbooleanWhether it is open; leave it out and use defaultOpen to let it keep its own.
      onClose() => boolean | voidCalled on every close; return false to keep it open.
      titleComponentChildrenThe heading, and its name.
      cancelLabelstringThe close button's name; without it there is no close button.
      footerComponentChildrenThe buttons at the end, laid out as DialogActions lays them out.
      tone"default" | "danger"= "default"danger tints the dialog red.
      Code
      <Modal
        open={open.value}
        onClose={() => open.value = false}
        title="Rename the list"
        cancelLabel="Close"
        footer={<Button onClick={save}>Save</Button>}
      >
        <p>Modal body.</p>
      </Modal>

      DialogActions

      A dialog's button row: Cancel then the submit, stacked full width on a phone with the submit at the bottom, right-aligned from sm.
      Props
      PropType and defaultWhat it does
      classstringUtilities added after the row's own, for anything that is not its layout.
      Code
      <form onSubmit={rename}>
        <Stack>
          <Input aria-label="List name" value={name} />
          <DialogActions>
            <Button variant="outline" onClick={close}>Cancel</Button>
            <Button type="submit">Rename</Button>
          </DialogActions>
        </Stack>
      </form>

      ConfirmDialog

      Asks the user to confirm an action before it happens, with two labelled buttons.

      Outcome: nothing confirmed yet

      Props
      PropType and defaultWhat it does
      titleComponentChildrenThe question.
      messageComponentChildrenWhat will happen.
      onConfirm(event) => boolean | voidCalled on confirm; the dialog stays open until you unmount it.
      onCancel() => boolean | voidCalled on cancel; unmount the dialog to close it. Return false to keep Escape or the cross from closing it.
      confirmLabelstring= "Confirm"The confirming button's text.
      cancelLabelstring= "Cancel"The cancelling button's text.
      closeLabelstring= "Close"The name of the cross in the corner, which cancels too. Left out when a footer button has the same name.
      tone"default" | "danger"= "default"danger confirms in red.
      busy / busyLabelboolean / string= falseWhile the confirmed work runs: a spinner and the busy label on confirm, presses ignored, and no way to cancel.
      confirmDataE2E / cancelDataE2Estring= "confirm-dialog-confirm" / "confirm-dialog-cancel"The buttons' data-e2e hooks.
      Code
      // Mounted to open, unmounted to close.
      {confirming.value && (
        <ConfirmDialog
          title="Delete invoice INV-0007?"
          message="This cannot be undone."
          confirmLabel="Delete"
          cancelLabel="Keep it"
          tone="danger"
          busy={deleting.value}
          busyLabel="Deleting…"
          onConfirm={() => deleteInvoice().then(() => confirming.value = false)}
          onCancel={() => confirming.value = false}
        />
      )}

      UnsavedGuard

      Asks before leaving a page with unsaved changes: the browser's question on close or reload, and a dialog on an in-app link, on a navigation the app starts from code, and on the browser's Back and Forward buttons.

      The buttons navigate from code, as a sidebar does. With the focus in this card, Alt+L and Alt+U do the same, as a keyboard shortcut does.

      A save that finishes while the dialog is open closes it and goes where the person was going: type a change, press "Save in 3 seconds", then "Go to Lists", and wait.

      Back and Forward are held too. A card never changes the address, so that is shown at the end of this page, under "UnsavedGuard, Back and Forward".

      Navigations from code: 0

      Outcome: nothing yet

      A form of its own, with no leave guard. Leaving drops the change and removes the guard from the page in the same step.

      Unsaved: no. Outcome: nothing yet

      Props
      PropType and defaultWhat it does
      whenbooleanWhether there are unsaved changes. When it turns false while the dialog is open, because a save finished, the dialog closes and the navigation runs.
      navigate(href: string) => voidThe router port, called with the link's path, query and hash on Leave.
      owns(url: URL) => booleanWhich addresses the router handles; any other link is the browser's.
      onDiscard() => voidCalled on Leave, before the navigation, to drop the changes.
      leaveGuardLeaveGuardThe app's createLeaveGuard(). A button or shortcut that calls leaveGuard.navigate(go) gets the same dialog: Leave runs go, Stay drops it. With nothing unsaved, go runs at once.
      labelsPartial<UnsavedGuardLabels>Replaces the dialog's English words: title, message, leave, stay, and close for the cross in its corner.
      Code
      // Once for the app, outside any component.
      export const leaveGuard = createLeaveGuard()
      
      <UnsavedGuard
        when={draft.value !== saved.value}
        navigate={(href) => setLocation(href)}
        owns={(url) => url.pathname.startsWith("/notes/")}
        onDiscard={() => draft.value = saved.value}
        leaveGuard={leaveGuard}
      />
      <a href="/notes/7?latest" data-unsaved-ok>Load the latest version</a>
      <Button onClick={() => leaveGuard.navigate(() => setLocation("/lists"))}>Lists</Button>

      Toastr

      Short notifications stacked in a corner that go away on their own or when dismissed.
      Where the stack sits

      In the store: 0. A toast with no duration of its own stays 5000 ms, or 10000 ms with an action; the Undo toast here asks for 3000 ms. Undone: 0. The Undo button carries data-e2e="guide-toast-undo-action", from its action's dataE2E. Each toast shows the title it was pushed with, or its kind's default. Rename puts focus in the name field, and it stays there once the toast closes. Hover the stack to pause every timer.

      Nothing pushed yet.

      Props
      PropType and defaultWhat it does
      toastsToastItem[]The stack: each toast's id, type, title (shown above the body), body, duration, an optional action button ({ label, onAction }, run once, then the toast is dismissed), and a dataE2E test hook on that toast.
      onDismiss(id: ToastId) => voidRemoves a toast, when its timer ends or it is dismissed; a store's remove fits as it is.
      corner"top-left" | "top-right" | "bottom-left" | "bottom-right"= "top-right"The window corner the stack sits in; toasts slide in from that side. Below 640 px it picks the top or the bottom and the stack spans the width.
      labelstring= "Notifications"The stack's accessible name.
      dismissLabelstring= "Dismiss"The dismiss button's name.
      Code
      app.toast.info({
        body: "Draft deleted",
        action: { label: "Undo", onAction: () => restore(draft) },
      }) // no duration: stays 10 s, paused while hovered or focused
      
      <Toastr
        toasts={app.toast.list.value}
        onDismiss={app.toast.remove}
        corner="bottom-right"
      />

      OnboardingChecklist

      A card of first steps for a new user, with progress, the next step's action and Dismiss.

      Get started

      A few steps to a working team space.

      2 of 6 done
      1. Done: Fill in your profile
      2. Done: Create a project
      3. To do: Invite a teammate

        Work is easier with two.

      4. To do: Turn on notifications

        A link to the settings page.

      5. To do: Confirm your e-mail

        Done when you open the link we sent; there is nothing to press here.

      6. To do: Import your data

        Bring in what you already have.

      onDismiss calls: 0.

      Props
      PropType and defaultWhat it does
      steps{ id, title, description?, done, action? }[]The steps in order; done comes from the app's own data.
      onDismiss() => voidHides the checklist; without it the card has no Dismiss.
      titlestring= "Get started"The heading.
      headingRefRefObject<HTMLHeadingElement>Points at the heading, so the app can move focus to the card.
      progressLabel(done: number, total: number) => string= `${done} of ${total} done`The progress caption.
      finishLabelstring= "Finish"The primary button once every step is done; it calls onDismiss.
      Code
      <OnboardingChecklist
        steps={[
          { id: "profile", title: "Fill in your profile", done: user.hasProfile },
          {
            id: "invite",
            title: "Invite a teammate",
            description: "Work is easier with two.",
            done: members.length > 1,
            action: { label: "Invite a teammate", onClick: openInvite },
          },
          {
            id: "project",
            title: "Create a project",
            done: projects.length > 0,
            action: { label: "Create a project", href: "/projects/new", navigate },
          },
        ]}
        onDismiss={() => settings.hideOnboarding()}
      />

      Tour

      A guided tour: one hint at a time beside the control it explains, with Back, Next and Skip.

      Search looks through every project.

      onClose last said: nothing yet.

      Props
      PropType and defaultWhat it does
      steps{ id, target, title, body?, placement? }[]The stops in order; target is a ref, or a CSS selector, which names its first visible match.
      openbooleanWhether the tour is shown. The app owns it.
      indexnumberThe step shown. The app owns it, so it can move the tour on by itself.
      onIndexChange(next: number) => voidCalled by Next and Back.
      onClose(reason: "done" | "skip" | "escape") => voidCalled by Done, Skip, × and Escape; the app sets open to false.
      nextLabelstring= "Next"Next's label.
      backLabelstring= "Back"Back's label.
      skipLabelstring= "Skip tour"Skip's label.
      doneLabelstring= "Done"The last step's primary button.
      stepLabel(step: number, total: number) => string= `Step ${step} of ${total}`The step count.
      closeLabelstring= "Close"The × button's accessible name.
      goToLabel(targetName: string) => string= `Go to ${targetName}`The button that moves focus to the step's target.
      Code
      const open = useSignal(false)
      const index = useSignal(0)
      
      <Tour
        steps={[
          { id: "search", target: "#search", title: "Search everything", body: "Find anything." },
          { id: "new", target: newButton, title: "Start a project", placement: "right" },
        ]}
        open={open.value}
        index={index.value}
        onIndexChange={(next) => index.value = next}
        onClose={() => open.value = false}
      />

      Coachmark

      One hint anchored to one control, for a new feature; Tour is a list of them.
      Exports this month: 12
      Props
      PropType and defaultWhat it does
      openbooleanWhether it is shown. The app owns it.
      targetstring | RefObject<HTMLElement>The control it points at: a ref, or a CSS selector, which names its first visible match.
      titlestringThe heading, which also names it.
      childrenComponentChildrenThe text; it describes the target.
      placement"top" | "right" | "bottom" | "left"= "bottom"The side of the target; it flips when there is no room and stays inside the window.
      onClose(reason: "escape" | "close") => voidCalled on Escape and on ×.
      closeLabelstring= "Close"The × button's accessible name.
      goToLabel(targetName: string) => string= `Go to ${targetName}`The button that moves focus to the target.
      footerComponentChildrenControls of the app's own.
      describedBystringIds of more elements that describe the dialog, after its text.
      Code
      <Coachmark
        open={showHint.value}
        target={exportButton}
        title="New: export to CSV"
        placement="right"
        onClose={() => showHint.value = false}
      >
        Download every row as a spreadsheet.
      </Coachmark>

      ResendButton

      The "Not there?" line under "Check your inbox": a countdown sentence, then a button that sends the mail again.

      Check your inbox

      We sent a sign-in link to ana@example.com. It works once, for 15 minutes.

      Not there? Check your spam folder, or

      resends: 0

      Props
      PropType and defaultWhat it does
      onResend() => voidCalled on a press. The caller sends the mail again.
      busyboolean= falseThe resend is running: the button shows busyLabel and keeps focus. Set the new readyAt no later than the render that clears it.
      readyAtnumberWhen the button may be pressed again, in milliseconds since the epoch. Until then a sentence counts down, kept out of live regions. Left out, the button shows at once.
      labelstring= "Send it again"The button's text.
      busyLabelstring= "Sending…"The button's text while busy.
      waitingText(seconds: number) => ComponentChildrenThe countdown sentence, given the whole seconds left. Defaults to "Not there? Check your spam folder. You can send it again in 45 seconds."
      readyTextComponentChildren= "Not there? Check your spam folder, or"The words before the button.
      sentTextComponentChildren= "Sent again. Check your inbox."The role="status" line that takes focus once a resend went out, since the button is gone.
      Code
      <ResendButton
        label="Send a new link"
        readyAt={sentAt + 60_000} // a sentence counts down until then
        busy={sending}
        onResend={async () => {
          sending = true
          await api.sendLink(address)
          sentAt = Date.now() // set it before clearing busy
          sending = false
        }}
      />

      Inputs

      The controlled controls ui/ owns: switches, the dropdown's trigger-panel pair, and the composite pickers built from them. The Fields section below is the text-and-form half of the same story.

      ToggleSwitch

      An on/off switch for one setting that applies at once, such as showing archived rows.
      archived: off
      notifications: on
      disabled
      Props
      PropType and defaultWhat it does
      valuebooleanWhether the switch is on.
      onToggle(value: boolean) => voidCalled with the value a press asks for; the caller stores it.
      labelstringThe switch's accessible name.
      disabledboolean= falseGreys it out.
      aria-invalidbooleanMarks the switch invalid; Field sets it while it carries an error.
      Code
      <ToggleSwitch
        value={archived.value}
        onToggle={(next) => archived.value = next}
        label="Show archived"
      />

      Slider

      A labelled range slider on the native range input: the value printed beside the label, a graphite fill up to the thumb, and up to two marks.
      Inflation targetLong-run stock average

      Arrow keys move half a point; Page Up and Page Down move further.

      Props
      PropType and defaultWhat it does
      labelstringThe visible label, which names the input.
      valuenumberThe current value; the caller stores it.
      min / max / stepnumber= 0 / 100 / 1The range and the step between values.
      onInput(value: number) => voidCalled with the new value on every move, by pointer or keyboard.
      format(value: number) => string= StringHow the value prints beside the label.
      valueText(value: number) => string= formatWhat a screen reader announces, as aria-valuetext.
      marks{ value, label }[]Up to two labelled ticks under the track.
      hintstringOne line under the slider, read with it.
      Code
      <Slider
        label="Yearly rate"
        value={rate.value}
        min={0}
        max={10}
        step={0.5}
        onInput={(next) => rate.value = next}
        format={(value) => `${value.toFixed(1)}%`}
        valueText={(value) => `${value.toFixed(1)} percent a year`}
        marks={[{ value: 2, label: "Inflation target" }]}
      />

      LanguagePicker

      Every language by its own name: a radio group for an app's settings, or plain links that work without JavaScript, in a row for a website's footer or behind a globe menu for its header.

      List, for an app

      Props
      PropType and defaultWhat it does
      languages / currentstring[] / string | nullThe tags offered, in order, and the one in use; null follows the device (automatic).
      mode"list" | "links" | "menu"= "list"list is a radio group: one tab stop, arrow keys move and choose. links is a <nav> of <a href hreflang lang>. menu puts those links behind a globe Dropdown named "Language: <name>", showing the name from xl.
      onSelect / href(tag | null) => void / (tag) => stringWhat a choice does: the list calls onSelect; each link, in links and menu, goes to href(tag).
      automaticstringThe device's language. Given, the list starts with "Automatic: <language>, like this device", chosen when current is null.
      pending / unavailable / failedstring | null / string[] / stringThe language loading (a spinner, read aloud), the ones that cannot load offline (disabled), and one that failed (an error under the list).
      labels{ label?, automatic?, loading?, error?, offline? }The words; each language's own name always comes from Intl.DisplayNames.
      Code
      <LanguagePicker
        languages={["en", "de", "es", "pt-BR", "ru", "ar"]}
        current={language}
        automatic={navigator.language}
        pending={loading}
        onSelect={(tag) => switchLanguage(tag)}
      />
      
      <LanguagePicker
        mode="links"
        languages={["en", "de", "ar"]}
        current="en"
        href={(tag) => `/${tag}/`}
      />
      
      <LanguagePicker
        mode="menu"
        languages={["en", "de", "zh-CN", "ar"]}
        current="en"
        href={(tag) => `/${tag}/`}
      />

      LanguageDialog

      The language list in a dialog: a choice applies at once and the dialog stays open in the new language, with focus on the chosen row.
      Props
      PropType and defaultWhat it does
      open / onCloseboolean / () => voidOn screen, and closed by Done, Escape, the close control or the backdrop.
      languages / current / onSelectstring[] / string | null / (tag | null) => voidAs on LanguagePicker's list.
      automatic / pending / unavailable / failedstring / string | null / string[] / stringAs on LanguagePicker: the device's language, and the loading, offline and error states.
      labels / pickerLabels{ title?, description?, done?, closeLabel? } / LanguagePickerLabelsThe dialog's words, and the list's.
      Code
      <LanguageDialog
        open={open}
        onClose={() => setOpen(false)}
        languages={["en", "de", "es", "pt-BR", "ru", "ar"]}
        current={language}
        automatic={navigator.language}
        pending={loading}
        onSelect={(tag) => switchLanguage(tag)}
      />

      LanguageButton

      A globe and the language in use, named in itself, that opens LanguageDialog: the language switch of a signed-out header.
      Props
      PropType and defaultWhat it does
      languages / current / onSelectstring[] / string | null / (tag | null) => voidAs on LanguageDialog. The button shows current, or automatic while it follows the device.
      automatic / pending / unavailable / failedstring / string | null / string[] / stringPassed to the dialog, as on LanguagePicker.
      labels / pickerLabels{ title?, description?, done?, closeLabel? } / LanguagePickerLabelsThe dialog's words and the list's. title is also read aloud before the language's name.
      buttonDataE2Estring= "language-button"data-e2e on the button; dataE2E stays the dialog's.
      Code
      <LanguageButton
        languages={["en", "de", "es", "pt-BR", "ru", "ar"]}
        current={language}
        automatic={navigator.language}
        pending={loading}
        onSelect={(tag) => switchLanguage(tag)}
      />

      OnOffButtons

      A two-button ON/OFF filter that can also start with neither half chosen, with optional counts.
      value: undefined
      with counts and custom labels
      Props
      PropType and defaultWhat it does
      valueboolean | undefinedThe chosen half; undefined chooses neither.
      onSwitch(on: boolean) => voidCalled with the half that was pressed.
      amount{ on: number; off: number }A count shown under each half.
      onLabel / offLabelstring= "ON" / "OFF"The halves' text.
      labelstring= the two labels, `ON / OFF`The pair's name for a screen reader, which also hears which half is pressed.
      Code
      <OnOffButtons
        label="Rows to show"
        onLabel="Active"
        offLabel="Archived"
        value={showActive.value}
        amount={{ on: 128, off: 14 }}
        onSwitch={(on) => showActive.value = on}
      />

      SegmentedControl

      One choice among a few short options, drawn as joined segments that the arrow keys move through, such as a period picker in a page header.
      days: 30
      Props
      PropType and defaultWhat it does
      options{ value, label }[]The segments, in order.
      valuestringThe chosen segment's value.
      onChange(value: string) => voidCalled with the segment a click or an arrow key chose.
      ariaLabelstringThe group's name for a screen reader; required.
      Code
      <SegmentedControl
        ariaLabel="Period"
        options={[
          { value: "7", label: "7 days" },
          { value: "30", label: "30 days" },
          { value: "90", label: "90 days" },
        ]}
        value={days.value}
        onChange={(value) => days.value = value}
      />

      ToggleField

      A settings row: a label and a ToggleSwitch on one line, with a description or an error under them.

      Hides the row from everyone without the administrator role.

      Send an email when a run fails.

      The caller ignores the change, so the switch stays off.

      Disabled: the label dims with the switch.

      Your role cannot change this setting.

      archived: off · admin only: on · notifications: on

      Props
      PropType and defaultWhat it does
      idstringThe switch's id; the messages' ids derive from it.
      labelComponentChildrenThe row's visible name.
      valuebooleanWhether the switch is on.
      onToggle(value: boolean) => voidCalled with the value a press asks for.
      descriptionComponentChildrenA hint under the row.
      errorstring | nullAn error under the row, announced.
      Code
      <ToggleField
        id="show-archived"
        label="Show archived"
        value={archived.value}
        onToggle={(next) => archived.value = next}
        description="Hides the row from the list"
        required
      />

      Dropdown

      A button that opens a menu of actions, which the arrow keys, Home, End and Escape work through, and which opens as a plain disclosure without JavaScript.
      Icon trigger
      Text trigger
      Select action…
      Always opens up
      Edit item
      Lines up at the start
      Props
      PropType and defaultWhat it does
      triggerComponentChildrenWhat the button shows.
      triggerLabelstringNames a trigger that shows only an icon.
      menuLabelstringThe menu's accessible name.
      vertical / horizontal"auto" | "up" | "down" / "start" | "end" | "left" | "right"= "auto" / "end"Which way the menu opens and which edge it lines up with. auto prefers down: it flips up only when the menu fits above but not below, and otherwise scrolls the page to show it. start and end follow the reading direction; left and right are their old names.
      Code
      <Dropdown
        trigger={<IconEllipsisVertical />}
        triggerLabel="Row actions"
        menuLabel="Row actions"
      >
        <DropdownItem href={editHref}>Edit</DropdownItem>
        <DropdownItem onClick={archive}>Archive</DropdownItem>
      </Dropdown>

      DropdownItem

      One entry of a Dropdown menu: a link when it has an href, a button when it does not.
      navigate not called yet
      chosen 0, disabled chosen 0, routed to nowhere
      Item hotkeys
      Route to System
      Group: Family
      Family
      Props
      PropType and defaultWhat it does
      hrefstringMakes the item a link to this address.
      hreflang / langstring / stringThe language of the page the link leads to, and of the item's own text, as in a language switcher.
      navigate(href: string) => voidThe app's router, called on a plain click on a link item instead of loading the page; never for a link to another origin.
      onClick() => voidWhat a button item does.
      type"button" | "submit"= "button"submit makes the item the submit button of a <form role="none"> around it, for an action that posts.
      disabledboolean= falseDims it, and drops the red of danger; the arrow keys skip it.
      dangerboolean= falseColours it red, for an action that destroys or archives something.
      hotkeystringA key such as mod+e that chooses the item from anywhere on the page, with its menu open or closed, while the trigger is shown. A disabled item ignores it. A hint at the right edge shows it.
      hotkeyHintboolean= trueWhether the hint shows the key. It never shows for a coarse pointer.
      kbdLabelsPartial<KbdLabels>The words the hint shows for a key; see KBD_LABELS.
      keyshortcutsstring[]Keys the app binds itself to the same action, such as ?: announced in aria-keyshortcuts, the first shown as the hint, none bound by the item.
      checkedbooleanMakes it one choice of a set: a menuitemradio with aria-checked, a check before its text when true and an empty space as wide when false. Without JavaScript a checked link says aria-current="page".
      classstringExtra classes, merged over its own.
      Code
      <DropdownItem href="/regions/1/edit">Edit</DropdownItem>
      <DropdownItem href="/regions/1" navigate={router.navigate} hotkey="mod+o">Open</DropdownItem>
      <DropdownItem href="/regions/1" navigate={router.navigate}>Open</DropdownItem>
      <DropdownItem danger onClick={archive}>Archive</DropdownItem>
      <DropdownItem disabled onClick={archive}>Archive</DropdownItem>
      <DropdownItem danger disabled onClick={remove}>Delete</DropdownItem>
      <DropdownItem onClick={duplicate} hotkey="shift+e">Duplicate</DropdownItem>
      <form method="post" action="/sign-out" role="none">
        <DropdownItem type="submit">Sign out</DropdownItem>
      </form>
      {groups.map((group) => (
        <DropdownItem checked={group.id === currentId} onClick={() => switchTo(group.id)}>
          {group.name}
        </DropdownItem>
      ))}
      <DropdownItem href="/de/" hreflang="de" lang="de" checked={false}>Deutsch</DropdownItem>

      Combobox

      A text field that filters a list as you type and picks one item from it, by keyboard or pointer.

      Strings

      selected BTC

      Groups, aliases and Create

      category: Eating out

      Try “out eat”, “eatng out”, “restaurant” or “щге уфештп”, then “Gym” and Enter.

      Objects, one disabled

      nothing selected

      Your own search

      controlled query: (empty) · nothing selected

      A long list

      nothing selected

      Options still loading

      no options yet

      A spoken count in your words

      nothing selected

      Fetched per keystroke

      5 options · nothing selected

      Props
      PropType and defaultWhat it does
      itemsreadonly T[]Everything that can be picked.
      valueT | nullThe picked item.
      onChange(value: T | null) => voidCalled with the new pick, or null when cleared.
      getLabel(item: T) => string= StringThe text shown and searched for an item; pass it for objects.
      filter(items, query) => T[]= searchEntriesReplaces the built-in search, which takes words in any order, one typo and the other keyboard layout, best match first. Groups still apply.
      aliasesOf(item: T) => readonly string[]Other names the built-in search finds an item by; never shown.
      groupOf / isGroupLead(item: T) => { id, label } | null / (item: T) => booleanPuts items in named groups. A lead (a parent category) is its group's first row and name, and stays choosable; a group without one gets a heading the arrow keys skip.
      groupedLabel(label, group) => string= "Eating out (Food)"How the closed field reads a chosen group member.
      onCreate(query: string) => T | void | Promise<T | void>Adds a last row, Create “Gym”, highlighted when nothing else matches. A returned item is chosen; a pending promise shows a spinner, a rejected one a field error.
      createLabel / createOnlyMessage / createErrorMessage(query: string) => string= "Create “Gym”" / "No matches. You can create “Gym”." / "Couldn't create “Gym”. Try again."The Create row, what the live region says when it is alone, and the error.
      query / onQueryChangestring / (query: string) => voidHands the typed text to the caller, as a server-side search needs. Closing the list leaves a controlled query alone.
      emptyMessagestring | ((query: string) => ComponentChildren)= "No matches"What the list says when nothing matches.
      loadingboolean= falseSet while the options are being fetched; the field then never says the list is empty.
      loadingMessagestring= "Loading…"What the field says while loading is set.
      requiredboolean= falseAn item must be chosen: with none the browser refuses the submit; Field sets it.
      requiredMessagestring= "Select an option"What the browser shows on a submit with nothing chosen.
      aria-describedby / aria-invalidstring / booleanSet by Field: its messages describe the input, and an error marks it.
      Code
      <Combobox
        items={cities}
        value={city.value}
        onChange={(next) => city.value = next}
        getLabel={(city) => `${city.name} — ${city.country}`}
        isItemDisabled={(city) => city.retired === true}
        placeholder="Search a city…"
        emptyMessage={(query) => `No city matches “${query}”`}
      />

      TagInput

      A text field that collects several tags, typed or picked from suggestions, each shown as a removable chip.

      In a Field, with suggestions

      • work

      Enter or a comma adds a tag.

      tags: work

      Backspace in the empty field removes the last tag. A Backspace held down clears the typed text and stops there.

      Disabled

      • work
      • phone
      Props
      PropType and defaultWhat it does
      valuereadonly string[]The chosen tags, in order.
      onChange(value: string[]) => voidCalled with the whole list after a tag is added or removed.
      suggestionsreadonly string[]= []Tags to offer while typing; chosen ones are hidden.
      label / ariaLabelComponentChildren / stringA visible label, or a name with none; inside Field, leave both out.
      errorstring | nullA message under the field; inside Field, pass it to Field instead.
      disabledboolean= falseNo typing, no removing.
      requiredboolean= falseAt least one tag: with none the browser refuses the submit; Field sets it.
      requiredMessagestring= "Add at least one tag"What the browser shows on a submit with no tag.
      removeLabel(tag: string) => string= `Remove tag ${tag}`Name of each chip's remove button.
      Code
      <Field id="task-tags" label="Tags">
        <TagInput
          value={tags.value}
          onChange={(next) => tags.value = next}
          suggestions={["work", "home", "errand"]}
        />
      </Field>

      DateRangePicker

      A filter-bar control that picks a date range from presets such as Last 7 days, or from two date fields.

      Presets

      preset: last-7-days

      range: 2026-02-09 → 2026-02-15

      Nothing chosen

      range: none

      withTime

      range: none

      instants: none

      Try 2026-10-25 02:30: the clocks in Paris go back that night, so the field asks which 02:30 you mean.

      Weeks from Sunday

      range: none

      The first picker's presets, resolved against a fixed now so every build prints the same dates:

      • Last 7 days: 2026-02-09 … 2026-02-15
      • This month: 2026-02-01 … 2026-02-28
      • This year: 2026-01-01 … 2026-12-31
      • Custom…: 2026-02-09 … 2026-02-15
      Props
      PropType and defaultWhat it does
      range / onChangeDateRange | null / (range) => voidThe chosen range, held by the caller.
      timeZonestringThe zone presets resolve in, because the server's is not the visitor's.
      presetsDateRangePresetOption[]The preset buttons, in order; a custom entry opens the date fields.
      nowDate= the clock, read on a pressThe instant presets resolve against.
      firstWeekdaynumber= 1The day a week starts on for the this-week and last-week presets, 1 for Monday to 7 for Sunday.
      withTimeboolean= falseDate and time fields, last-hour presets, and a timed range that carries the exact instant of each end; a time the clocks repeat asks which one is meant.
      labelsDateRangePickerLabels= EnglishEvery word in the panel, one key at a time.
      Code
      <DateRangePicker
        range={range.value}
        onChange={(next) => range.value = next}
        timeZone="Europe/Paris"
        presets={[
          { preset: "last-7-days", label: "Last 7 days" },
          { preset: "custom", label: "Custom…" },
        ]}
        selectedPreset="last-7-days"
      />
      
      // Every label is English by default; override only the ones you need to.
      <DateRangePicker {...props} labels={{ placeholder: "All time" }} />
      
      // Week presets: weeks start on Monday unless firstWeekday says otherwise (7 is Sunday).
      <DateRangePicker
        {...props}
        firstWeekday={7}
        presets={[
          { preset: "this-week", label: "This week" },
          { preset: "last-week", label: "Last week" },
        ]}
      />
      
      // withTime: datetime-local fields, two sub-day presets — no presets prop — and a range whose
      // fromInstant / toInstant say exactly which moment each end means, even in a repeated hour.
      <DateRangePicker
        withTime
        range={timedRange.value}
        onChange={(next) => {
          timedRange.value = next
          query({ after: next.fromInstant, before: next.toInstant })
        }}
        timeZone="Europe/Paris"
        labels={{ repeatedTime: "This time happens twice that day" }}
      />

      PeriodStepper

      Steps a screen through weeks or months, with a button back to the current one and the keys [, ] and T.

      Months, one month back

      October 2026

      period: 2026-10-01 … 2026-10-31

      Weeks from Sunday, keys off

      Nov 1 – 7, 2026

      period: 2026-11-01 … 2026-11-07

      Props
      PropType and defaultWhat it does
      periodPeriodThe week or month on screen.
      onChange(period: Period) => voidCalled with the period to show next.
      nowDateThe current instant; the stepper never reads the clock.
      timeZonestringThe zone whose calendar holds today.
      optionsPeriodOptionsThe week's first day, as the period was made with.
      labelsPartial<PeriodStepperLabels>Overrides for the words, one set per unit.
      localestringLocale of the period's name; defaults to the translator's language.
      currentHotkeystring= "t"The key back to the current period.
      singleKeysboolean= trueWhether [, ] and the current key work; off, the tooltips show no key.
      Code
      import { currentPeriod, PeriodUnit } from "@spy4x/time/period"
      
      const period = useSignal(currentPeriod(PeriodUnit.Month, { now, timeZone }))
      
      <PeriodStepper
        period={period.value}
        onChange={(next) => period.value = next}
        now={now}
        timeZone={timeZone}
      />

      FileInput

      A file picker with a drop zone, or a plain button, that previews images and refuses the wrong type or size, without uploading anything.

      Images only, up to 2 MB

      Choose filesor drag and drop

      PNG or JPEG, up to 2 MB each

      chosen: none

      refused: none

      One file, PNG only

      Choose filesor drag and drop

      refused: none

      Inside a Field

      Choose filesor drag and drop

      Up to 2 MB each

      Disabled

      Choose filesor drag and drop

      As a button, keeping no list

      last choice: none

      refused: none

      A plain form post

      Choose filesor drag and drop

      Props
      PropType and defaultWhat it does
      idstringThe native input's id.
      accept / maxSizestring / numberWhat it keeps: file types, and the largest size in bytes.
      multipleboolean= falseAllows several files.
      onFiles(files: File[]) => voidThe files it kept.
      onReject(reasons: FileRejection[]) => voidThe files it refused, each with its reason.
      previewsboolean= trueShows a thumbnail beside each image.
      appearance"zone" | "button"= "zone"button draws one button that opens the chooser and hands each choice to onFiles, keeping no list.
      buttonVariant / busy / busyLabelButtonVariant / boolean / ComponentChildren= "secondary"The button's variant, and its busy state while the caller uploads.
      Code
      <FileInput
        id="attachments"
        label="Attachments"
        hint="PNG or JPEG, up to 2 MB each"
        accept="image/png,image/jpeg"
        maxSize={2 * 1024 * 1024}
        multiple
        onFiles={(files) => chosen.value = files}
        onReject={(reasons) => refused.value = reasons}
      />
      <FileInput
        id="attach"
        appearance="button"
        label="Attach files"
        accept="image/*,application/pdf"
        multiple
        onFiles={(files) => upload(files)}
        onReject={(reasons) => refused.value = reasons}
      />

      Fields

      The controlled form primitives: Field owns the label wiring and the messages, and Input/Textarea/Select/Checkbox/Radio are the native elements with the preset's class on them.

      Field

      One form row: a label, its control, and the hint or error under it, all wired to each other.

      Enter a valid address

      Work address only

      Hides the row from the list

      Notification method

      name: Ada Lovelace · email: not-an-address · role: admin · archived: off · channel: email · query: (empty)

      Currency: Euro (EUR)

      At least one.

      Delivery (required)

      submits: 0

      Props
      PropType and defaultWhat it does
      idstringThe control's id; the label and messages point at it.
      labelComponentChildrenThe visible label.
      hintComponentChildrenHelp text under the control.
      errorstring | nullAn error under the control, which also marks it invalid.
      requiredboolean= falseHands required to the control, which enforces it, and marks the label with a *. The * also shows for a control that is required itself.
      suffixboolean= falsePuts the label after the control, as a checkbox wants.
      labelForboolean | string= trueWhat the label points at; false for a control that names itself.
      Code
      <Field id="email" label="Email" required error={emailError} hint="Work address only">
        <Input value={email.value} onInput={(event) => email.value = event.currentTarget.value} />
      </Field>
      
      // A checkbox names itself with its own text, so Field adds only the hint and the wiring:
      <Field id="archived" hint="Hides the row from the list">
        <Checkbox checked={archived.value}>Archived</Checkbox>
      </Field>
      
      // Each control enforces required in its own way: at least one tag, an item chosen, a radio picked.
      <Field id="tags" label="Tags" required>
        <TagInput value={tags.value} onChange={(next) => tags.value = next} />
      </Field>

      Input

      A text field that shows the caller's value and reports every keystroke through onInput.

      (empty)

      Code
      <Input
        type="email"
        name="email"
        value={email.value}
        placeholder="you@example.com"
        required
        onInput={(event) => email.value = event.currentTarget.value}
      />

      Textarea

      A multi-line text field, with the same value in and onInput out as Input, and a ref that reaches the <textarea>.

      0 characters

      Code
      <Textarea rows={4} value={notes.value} onInput={(event) => notes.value = event.currentTarget.value} />

      Select

      A native drop-down list whose options are passed as data, with a ref that reaches the <select>.

      role: editor

      Code
      <Select
        value={role.value}
        onChange={(event) => role.value = event.currentTarget.value}
        options={[
          { value: "admin", label: "Administrator" },
          { value: "editor", label: "Editor" },
        ]}
        placeholder="Choose a role"
      />

      Checkbox

      A square or round checkbox and its text, where clicking either one ticks the box.

      archived: off · plants: to do

      Code
      <Checkbox
        checked={archived.value}
        onChange={(event) => archived.value = event.currentTarget.checked}
      >
        Show archived
      </Checkbox>
      
      <Checkbox
        shape="round"
        checked={done.value}
        onChange={(event) => done.value = event.currentTarget.checked}
      >
        Water the plants
      </Checkbox>

      Radio

      One radio button and its text, which makes one choice with the other radios of its name.
      Code
      <Radio name="channel" value="email" checked={channel.value === "email"}>Email</Radio>

      RadioGroup

      A set of radio buttons under one heading, of which one is picked.
      Notification method

      channel: email

      Props
      PropType and defaultWhat it does
      legendComponentChildrenThe group's name.
      namestringThe name every radio shares.
      optionsRadioOption[]The choices.
      value / onChangenumber | string | null / (value: string, event) => voidThe picked value, and the port that reports a new pick.
      requiredboolean= falseOne choice must be picked before the form submits; Field sets it.
      Code
      <RadioGroup
        legend="Notification method"
        name="channel"
        value={channel.value}
        onChange={(value) => channel.value = value}
        options={[
          { value: "email", label: "Email" },
          { value: "sms", label: "Phone (SMS)" },
          { value: "push", label: "Push notification", disabled: true },
        ]}
      />

      InputButton

      A text field with a button inside its right edge, such as a search box's search button.

      query: (empty)

      Props
      PropType and defaultWhat it does
      iconComponentChildrenWhat the button shows.
      iconLabelstringThe button's accessible name.
      onClick() => voidWhat the button does.
      value / onInputstring / (event) => voidThe field's text, as on Input; every other input attribute passes through.
      refRef<HTMLInputElement>Reaches the text field, so a form can focus it.
      Code
      <InputButton
        type="search"
        icon={<IconSearch class="size-4" />}
        iconLabel="Search"
        placeholder="Search users"
        value={query.value}
        onInput={(event) => query.value = event.currentTarget.value}
        onClick={() => query.value = ""}
      />

      MoneyInput

      An amount field that reads what people type in their own number format and hands back the amount in cents.
      Currency: Euro (EUR)

      amount: 1999

      submits: 0, posted: (none)

      BTC
      Currency: Japanese Yen (JPY)

      Type 0.5 to see the decimals message.

      Props
      PropType and defaultWhat it does
      valuenumber | nullThe amount in the currency's smallest unit, such as cents.
      onChange(value: number | null) => voidCalled when the typed text reads as a new amount.
      currencystringThe ISO code, such as EUR, shown inside the field.
      localestring= useFormat().localeHow the amount is shown and typed text is read; by default the region.
      decimalsnumberA coin's own decimals, such as 8 for BTC.
      min / maxnumberThe allowed range, in the smallest unit.
      namestringPosts that integer in a hidden field of this name.
      requiredboolean= falseThe browser refuses a submit while the field is empty; Field sets it.
      Code
      <Field id="price" label="Price">
        <MoneyInput
          value={amount.value}
          currency="EUR"
          name="price"
          onChange={(value) => amount.value = value}
        />
      </Field>
      <MoneyInput value={sats.value} currency="BTC" decimals={8} onChange={setSats} />

      CurrencyField

      Picks a currency by its code, its name or its symbol, with the ones used lately first.

      code: (none)

      Props
      PropType and defaultWhat it does
      valuestring | nullThe chosen code, such as EUR.
      onChange(code: string | null) => voidCalled with the chosen code, or null when cleared.
      optionsCurrencyOption[]ts-libs' currencyOptions(locale), plus any coin the app adds.
      recentstring[]Codes listed first while nothing is typed.
      Code
      <Field id="currency" label="Currency">
        <CurrencyField
          value={code.value}
          onChange={(next) => code.value = next}
          options={currencyOptions("en")}
          recent={["EUR", "USD"]}
        />
      </Field>

      InlineEdit

      A value shown as text that turns into a text field in place, for renaming something where it stands.

      saved: Groceries, saves: 0

      The second field's save always fails.

      Props
      PropType and defaultWhat it does
      valuestringThe saved text.
      onSave(value: string) => void | Promise<void>Called with the trimmed new text on Enter or blur; not for an empty or unchanged text.
      editLabel(value: string) => string= `Edit ${value}`The accessible name of the button that opens the field.
      inputLabelstring= "New name"The field's accessible name.
      savingLabelstring= "Saving…"Shown while a returned promise runs.
      disabledboolean= falseTurns the button off; turning it on while the field is open cancels the edit.
      errorMessage(error: unknown) => string= "Couldn't save. Try again."Words a rejected save; the field stays open with the typed text.
      textIdstringId of the shown text. Inside a heading, point its aria-labelledby here, so the heading reads as the value and not as the button's name.
      dataE2EstringSets data-e2e on the outer element.
      Code
      <h1 aria-labelledby="group-name">
        <InlineEdit value={group.name} textId="group-name" onSave={rename} />
      </h1>
      <InlineEdit
        value={list.value}
        inputLabel="List name"
        onSave={async (next) => {
          await api.renameList(id, next)
          list.value = next
        }}
      />

      QuickAdd

      A one-line field that adds an item to a list and shows, while you type, the tags, date and priority it understood.

      Added to Inbox

      added 0:

      Read at 01:00 on Sunday 8 March 2026 in Ho Chi Minh

      added:

      Props
      PropType and defaultWhat it does
      onAdd(parsed: QuickAddResult) => voidCalled on Enter or the button with the title and what was taken out of it; not while busy, and not for a line that leaves no title.
      zonestring= the device's zoneThe IANA time zone "tomorrow" and "3pm" are read in.
      now() => Date= () => new Date()The clock, for a test to fix.
      labelstring= "New item"The field's accessible name.
      placeholderstring= "Add an item"Shown in the empty field.
      hintstring | ((parsed: QuickAddResult) => string)A quiet line under the field; a function follows what is typed.
      busyboolean= falseMakes the field read-only and the button off; a send adds nothing.
      labelsPartial<QuickAddLabels>= defaultQuickAddLabelsThe button's name, the empty-title message, and the words of each kind of badge.
      dueLabel(date: string, now: Date, zone: string) => string= "Today", "Tomorrow", "Mon 5 Jan"Words the due day; the time is always shown as typed.
      dataE2Estring= "quick-add"The form's data-e2e, and the prefix of its parts'.
      Code
      <QuickAdd
        label="New note"
        placeholder="Call Anna #work tomorrow 3pm !high"
        hint={(parsed) => parsed.due ? "Added to Scheduled" : "Added to Inbox"}
        busy={saving.value}
        onAdd={(parsed) => createNote(parsed)}
      />
      
      // A fixed clock and zone, as a test passes them:
      <QuickAdd zone="Asia/Ho_Chi_Minh" now={() => new Date("2026-03-07T18:00:00Z")} onAdd={addReminder} />
      
      // Every word is the caller's to change:
      <QuickAdd
        label="New expense"
        labels={{ submit: "Add expense", tag: (name) => `Category ${name}`, due: (day) => `Paid ${day}` }}
        dueLabel={(date) => date.split("-").reverse().join(".")}
        onAdd={addExpense}
      />

      ColorField

      A colour from 16 readable swatches or a custom hex, with a live sample of the thing being coloured.
      Tag colour
      Groceries

      Text on the tag turns black or white, whichever reads better.

      Stored: #0369a1

      Group colour

      saves: 0, saved: (none)

      Props
      PropType and defaultWhat it does
      value / onChangestring | null / (value: string | null) => voidThe chosen #rrggbb, or null for none; a custom hex arrives in lower case.
      presets{ value, label }[]= COLOR_PRESETSThe swatches before Custom: a gray and 15 hues.
      allowNone / noneLabelboolean / string= false / "No colour"A first swatch that sends null.
      legend / hint / errorComponentChildren / ComponentChildren / string | null= "Colour"The group's name, and messages tied to it with aria-describedby.
      preview(color, textColor) => ComponentChildrenThe caller's live sample, such as a tag chip, drawn while a colour is chosen.
      labels{ custom?, picker?, hexCode?, hexPlaceholder?, hexError? }Every word of the Custom swatch and its hex field, each with an English default.
      name / classstringThe radios' shared name (generated when left out), and utilities on the fieldset.
      Code
      <ColorField
        value={color.value}
        onChange={(next) => color.value = next}
        legend="Tag colour"
        preview={(background, text) => (
          <span style={{ backgroundColor: background, color: text }}>Groceries</span>
        )}
      />

      IconPicker

      An icon for a category or a list, from 42 named icons, with a search that speaks the reader's language.
      Language
      Icon

      Shown next to the category in lists and reports.

      Stored: car, saves: 0

      Props
      PropType and defaultWhat it does
      value / onChangeIconName | null / (value: IconName | null) => voidThe chosen id from @spy4x/preact-icons/registry, which an app stores, or null for none.
      iconsreadonly IconName[]= CATEGORY_ICONSThe ids offered, in the order shown.
      allowNone / noneLabelboolean / string= false / "No icon"A first tile that sends null, hidden while a search is typed.
      legend / hint / errorComponentChildren / ComponentChildren / string | null= "Icon"The group's name, followed by the chosen icon's name, and messages tied to it with aria-describedby.
      labels{ search?, empty?(query), clearSearch?, count?(n) }The search box's words, each with an English default.
      name / disabled / classstring / boolean / stringThe radios' shared name (generated when left out), so a form submits the id; disables everything; utilities on the fieldset.
      Code
      <IconPicker
        value={category.icon}
        onChange={(icon) => category.icon = icon}
        allowNone
        name="icon"
      />

      RadioChips

      One choice drawn as ToggleChips chips, on native radios, so it works with JavaScript off.
      Which tools the server offers

      list_events · delete_event

      Props
      PropType and defaultWhat it does
      legendComponentChildrenThe group's accessible name, rendered as the <legend>.
      hideLegend / legendClassboolean / string= false / "pc-label mb-2"Keep the legend for screen readers only, or replace its classes.
      namestringShared by every radio: one tab stop, arrow keys, and the submitted field.
      options{ value, label, disabled?, id?, inputAttributes? }[]One chip per option. id lets page CSS read :has(#id:checked); inputAttributes go on the radio.
      defaultValuestringThe option checked in the server markup; the browser keeps the choice after.
      value / onChangestring | null / (value, event) => voidOptional: control the choice from an island.
      class / optionsClassstringUtilities on the fieldset, and added to the chips' row.
      Code
      <RadioChips
        legend="Which tools the server offers"
        name="mode"
        defaultValue="all"
        options={[
          { value: "all", label: "All tools", id: "mode-all" },
          { value: "read-only", label: "Read only", id: "mode-read-only" },
        ]}
      />

      SwatchChips

      A choice of colour as chips with a swatch before each name, on native radios, so it works with JavaScript off.
      Colour
      Props
      PropType and defaultWhat it does
      legend / nameComponentChildren / stringThe group's name, and the radios' shared name the form submits.
      options{ value, name, color, disabled?, id?, inputAttributes? }[]One chip per colour: its written name, and any CSS colour for the swatch. A colour with none left is disabled.
      defaultValue / value / onChangestring / string | null / (value, event) => voidThe colour checked in the server markup, or control it from an island.
      Code
      <SwatchChips
        legend="Colour"
        name="colour"
        defaultValue="brown"
        options={[
          { value: "brown", name: "Brown", color: "#7a5230" },
          { value: "grey", name: "Grey", color: "#8a8d91" },
          { value: "white", name: "White", color: "#ffffff", disabled: true },
        ]}
      />

      ToggleChips

      A row of pressable chips for filtering a list, in the colours of Badge, one or many pressed at once.

      tags: work · status: open

      Props
      PropType and defaultWhat it does
      options{ value, label, disabled?, color? }[]One chip per option. An option's own color (#rrggbb, such as a tag's) fills it when pressed and shows as a dot before its text when not.
      value / onChangestring[] or string | nullThe pressed chips: a list, or one value or null with mode="single".
      mode"multiple" | "single"= "multiple"How many chips can be pressed at once.
      labelstring= "Filters"The group's accessible name.
      colorBadgeColor= "purple"A pressed chip's colour, with a check before its text; an unpressed chip is plain, on the surface.
      Code
      <ToggleChips
        label="Tags"
        options={[{ value: "work", label: "work" }, { value: "home", label: "home" }]}
        value={tags.value}
        onChange={(next) => tags.value = next}
      />
      
      <ToggleChips
        mode="single"
        label="Status"
        options={[{ value: "open", label: "Open" }, { value: "done", label: "Done" }]}
        value={status.value}
        onChange={(next) => status.value = next}
      />

      ListField

      An ordered list of short lines, such as a post's key points: an input per line, add and remove, up to a limit.
      Key points
      2 of 5

      Shown in a box above the post. Enter adds a point.

      ["Jobs are written down first.","A failed run retries on its own."]; submitted 0 times

      Props
      PropType and defaultWhat it does
      id / labelstring / ComponentChildrenThe first line's id, and the group's <legend>.
      value / onChangestring[] / (value: string[]) => voidThe lines, and the whole new list after every change.
      max / maxLengthnumberThe most lines, and the most characters one line takes.
      hint / error / disabled / name / placeholderComponentChildren / string / boolean / string / stringMessages tied to the group, and attributes on every line.
      labels{ item?, remove?, add?, count? }Each line's name, the remove button's, "Add item" and "3 of 5".
      Code
      <ListField
        id="tldr"
        label="Key points"
        value={points.value}
        onChange={(next) => points.value = next}
        max={5}
      />

      DateTimeField

      One date and time typed in a named time zone, which shows beside the input with its offset, read and written as an instant.
      Time zone: Berlin, Europe · GMT+1

      The post goes live on its own at that time.

      Stored: 2026-10-25T08:30:00.000Z

      Props
      PropType and defaultWhat it does
      value / onChangestring | null / (value: string | null) => voidThe moment as an ISO 8601 instant, or null for none.
      timeZonestringThe IANA zone the person types in; an unknown one reads as UTC.
      min / maxstringThe range the browser's picker offers, as instants.
      id / label / hint / error / required / disabledstring / ComponentChildren / ComponentChildren / string / boolean / booleanAs on Field.
      labels{ zone? }The zone line, "Time zone: Berlin, Europe · GMT+1" by default.
      Code
      <DateTimeField
        id="publish-at"
        label="Goes live at"
        value={at.value}
        onChange={(next) => at.value = next}
        timeZone="Europe/Berlin"
      />

      ImportMapper

      The step of an import where a person says which column of their file holds what, checked against the first rows as the app read them.
      File settings

      We guessed these from the file. Change one if the preview looks wrong.

      Columns

      First values: 03.11.2026, 04.11.2026, 05.11.2026

      First values: COFFEE CORNER 0423, SALARY NOVEMBER, <b>x</b> MARKET HALL

      First values: -4,20, 2.450,00, -56,13

      First values: EUR, EUR, EUR

      First values: RF18 0001, RF18 0002, =SUM(A1)

      2 problems

      2 of 6 rows have a problem

      • Can't read "n/a" as an amount.
      • The date "31.02.2026" doesn't exist. Check Date format in File settings.
      Preview. The first 6 of 340 rows, as they will be imported.
      LineDateDescriptionAmountCurrencyReference
      23 Nov 2026COFFEE CORNER 0423-€4.20EURRF18 0001
      34 Nov 2026SALARY NOVEMBER€2,450.00EURRF18 0002
      45 Nov 2026<b>x</b> MARKET HALL-€56.13EUR=SUM(A1)
      5Problem5 Nov 2026TO SAVINGSEmptyEUREmpty
      • Can't read "n/a" as an amount.
      6ProblemEmptyBAKERY 12-€3.20EUREmpty
      • The date "31.02.2026" doesn't exist. Check Date format in File settings.
      77 Nov 2026HARDWARE STORE, ORDER 55-2231, PAID IN 3 PARTS, PART 1 OF 3, SEE THE INVOICE SENT ON 1 NOVEMBER FOR THE FULL LIST OF ITEMS-€1,234.56EUREmpty

      onChange received: nothing yet

      Props
      PropType and defaultWhat it does
      headerreadonly string[] | nullThe file's column names, or null when its first row is data.
      samplereadonly (readonly string[])[]The first rows, as text. The component never reads a file.
      fieldsreadonly ImportField[]{ key, label, required?, numeric? } per thing the app reads from a row.
      valueColumnMapA field key to a column index, or null for “Not in this file”.
      onChange(key: string, column: number | null) => voidCalled when a field's select changes.
      errorsRecord<string, string | undefined>A field key to a message for the app's own rules. Wins over the built-in one for a required field left out.
      settingsreadonly ImportSetting[]{ key, label, value, options, error? }: how the file was read, guess chosen.
      onSettingsChange(key: string, value: string) => voidCalled when a setting changes; the app reads the file again.
      previewreadonly ImportPreviewRow[]{ line, values, errors? } per row, each value formatted as the app read it.
      rowCountnumberThe file's whole row count, for the line over the preview.
      pendingboolean= falseThe app is reading again: placeholders after 300 ms, selects stay usable.
      idstringThe root's id; a field's select is ${id}-${key}. Generated when left out.
      labelsPartial<ImportMapperLabels>Replaces any English word the component draws.
      Code
      <ImportMapper
        header={["Booking date", "Purpose", "Amount"]}
        sample={[["03.11.2026", "COFFEE CORNER", "-4,20"]]}
        fields={[
          { key: "date", label: "Date", required: true },
          { key: "amount", label: "Amount", numeric: true },
        ]}
        value={map.value}
        onChange={(key, column) => map.value = { ...map.value, [key]: column }}
        settings={settings}
        onSettingsChange={(key, value) => reread(key, value)}
        preview={[{ line: 2, values: { date: "3 Nov 2026", amount: "-€4.20" } }]}
        rowCount={340}
        pending={reading.value}
      />

      Enhanced forms

      Whole forms built on the Fields primitives above, that post on their own before a script has run and stay on the page once one has: EnhancedForm is the building block, shown with a sign-up form and a contact form built from it.

      EnhancedForm

      A form that posts like a plain HTML form before its script loads, and afterwards sends through onSubmit and stays on the page.

      submits: 0

      With status passed in, the caller drives the form, and empty labels make it say nothing.

      submits: 0

      Focus on error and on a step change: a refused address puts focus on the field, an accepted one on the next step's line.

      Step 1 of 2: where should the link go?

      step ask · submits 0

      A sign-up form built from Field, Input and Button.

      subscribes: 0

      A contact form built the same way.

      leads: 0

      Props
      PropType and defaultWhat it does
      actionstringWhere the form posts while no script runs.
      onSubmit(data: FormData) => Promise<void> | voidSends the form once the script runs; a rejection shows failed.
      done / failed / sendingComponentChildrenWhat shows in each state. done and failed replace the fields; sending hides them, so a failure brings back what was typed.
      status"idle" | "sending" | "done" | "failed"Hands the submit cycle to the caller: the form renders from it, and refuses a submit while it reads sending.
      labels{ sending?, done?, failed? }What the form announces; "" for a key says nothing in that state.
      classstringExtra classes for the form. The failure text carries enhanced-form-error, a hook for styling it.
      method"get" | "post"= "post"The plain form's method.
      Code
      <EnhancedForm
        action="/api/subscribe"
        onSubmit={async (data) => api.subscribe(data.get("email"))}
        done={<p>Thanks.</p>}
      >
        <Stack gap="md">
          <Field id="email" label="Email">
            <Input type="email" name="email" required />
          </Field>
          <Cluster>
            <Button type="submit">Subscribe</Button>
          </Cluster>
        </Stack>
      </EnhancedForm>

      Billing

      A pricing table, the current plan's card and an upgrade prompt. Every choice is a plain form that posts before any script runs; prices are integers in the currency's smallest unit. It ends with PageHero, the band at the top of a pricing page.

      PricingTable

      Plans side by side, each with a Choose form that posts its ID, and a monthly or yearly toggle that needs no script.
      Billing period
      • Free

        For trying it out.

        €0.00

        • 1 project
        • Community support
      • Pro

        Most popular

        For a working team.

        €12.00per month

        • Unlimited projects
        • Email support
      • Pro

        Most popular

        For a working team.

        €120.00per year

        • Unlimited projects
        • Email support
        • Two months free
      • Business

        For several teams.

        €49.00per seat / month

        • Everything in Pro
        • Single sign-on
        • Priority support
      • Business

        For several teams.

        €490.00per seat / year

        • Everything in Pro
        • Single sign-on
        • Priority support

      onChoose received: nothing yet, called 0 times

      Props
      PropType and defaultWhat it does
      plansPricingPlan[]Each price its own entry, with an amount in the smallest unit; one with no interval shows under both, and one with a unit reads "per seat / month".
      onChoose(plan: PricingPlan) => unknownTakes the choice over from the form post once the script runs. That form refuses another submit until a returned promise settles; a throw or a rejection goes to reportError and shows the failed label.
      pendingboolean= falseDisables every plan's form and shows a spinner on each Choose; a second choice is refused, with or without a script. Focus parked by the wait returns to Choose when it ends. Clear it on a back/forward-cache restore (pageshow with persisted).
      actionstringWhere each plan's form posts; left out, the page's own URL.
      fieldNamestring= "planId"The hidden field that carries the plan's ID.
      defaultIntervalBillingIntervalValue= BillingInterval.MonthThe interval shown first.
      labelsPartial<PricingTableLabels>Replaces any of the English words; intervals and per merge key by key, perUnit words a plan with a unit, chooseName follows choose unless given, pending is announced while the table waits, and failed after onChoose throws.
      Code
      <PricingTable
        plans={[
          { id: "free", name: "Free", amount: 0, currency: "EUR" },
          { id: "pro-month", name: "Pro", amount: 1200, currency: "EUR",
            interval: BillingInterval.Month, highlighted: true },
          { id: "pro-year", name: "Pro", amount: 12000, currency: "EUR",
            interval: BillingInterval.Year, highlighted: true },
          { id: "team-month", name: "Team", amount: 900, currency: "EUR",
            interval: BillingInterval.Month, unit: "member" },
        ]}
        action="/billing/checkout"
        onChoose={(plan) => startCheckout(plan.id)}
        pending={checkout.pending}
      />

      PlanCard

      A plan: the current one with its status, renewal date, payment warning and a Manage billing form, or, with no status, one on offer with two price lines, a checklist, a link button and footnotes.

      The first card's onSubmit was called 0 times

      Pro

      Active

      €12.00per month

      Renews on October 31, 2026

      Pro

      Past due

      €12.00per month

      Your last payment failed. Update your payment method to keep your plan.

      Pro

      Trial

      €12.00per month

      Trial ends on October 31, 2026

      Pro

      Active

      €12.00per month

      Ends on October 31, 2026

      Pro

      Incomplete

      €12.00per month

      Your first payment hasn't gone through yet. Complete it to start your plan.

      Pro

      Canceled

      Ended on October 31, 2026

      Pro

      Paused

      €12.00per month

      Cloud

      Hosted and updated for you

      $4 a month

      or $40 a year, 2 months free

      • Up to 6 people, each with their own login
      • Readable offline on every device
      • Export everything to a file at any time

      Self-host

      Free, on your own server

      • The same code as the cloud
      • No limit on members
      Props
      PropType and defaultWhat it does
      statusSubscriptionStatusValueTrialing, active, past due, canceled, incomplete or paused, shown in words; another package's same-valued enum passes too. Left out, the card is a plan on offer: no pill, date line or warning, and a larger price.
      descriptionstringOne short line under the name.
      pricePlanPrice | stringAn amount in the smallest unit, formatted with its "per month", or a string the caller formatted, such as "Free", shown as given.
      priceNotestringA second price line, such as "or $40 a year".
      featuresstring[]What the plan includes, a list with a check mark per line.
      actionButtonLinkPropsA full-width link button in the footer, named by its text; primary on a highlighted card, outline otherwise, unless variant says.
      notesComponentChildrenSmall muted text under the footer's actions, one <p> per line; links are allowed.
      highlightedboolean= falseA stronger border, to set the card apart from those beside it.
      subgridboolean= falseJoins the parent grid's rows, header, body and footer, so the actions of every card in a row start at the same height.
      periodEndDateWhen the period ends; the card words it by status.
      cancelAtPeriodEndboolean= falseSays the plan ends rather than renews.
      manageActionstringWhere Manage billing posts: the app's billing-portal route. Left out, the card has no form.
      onSubmit() => unknownTakes Manage billing over from the form post once the script runs. The form refuses another submit until a returned promise settles; a throw or a rejection goes to reportError and shows the failed label.
      pendingboolean= falseDisables the form and shows a spinner on Manage billing; a second submit is refused, with or without a script. Focus parked by the wait returns to Manage billing when it ends. Clear it on a back/forward-cache restore (pageshow with persisted).
      timeZonestring= "UTC"The zone the date is shown in.
      labelsPartial<PlanCardLabels>Replaces any of the English words, the date lines as functions; status and per merge key by key, pending is announced while the card waits, and failed after onSubmit throws.
      Code
      <PlanCard
        planName="Pro"
        status={SubscriptionStatus.PastDue}
        price={{ amount: 1200, currency: "EUR", interval: BillingInterval.Month }}
        periodEnd={subscription.currentPeriodEnd}
        manageAction="/billing/portal"
        onSubmit={openPortal}
        pending={portal.pending}
      />
      
      <PlanCard
        planName="Cloud"
        description="Hosted and updated for you"
        price="$4 a month"
        priceNote="or $40 a year, 2 months free"
        features={["Up to 6 people", "Readable offline"]}
        action={{ href: "/signup", children: "Create an account" }}
        notes={<p>30-day money-back guarantee.</p>}
        highlighted
        subgrid
      />

      UpgradePrompt

      A short message and an upgrade link with no box of its own, in place of a feature the plan does not include.

      Upgrade to use this feature

      Exports come with the Pro plan.

      See plans
      Props
      PropType and defaultWhat it does
      hrefstringWhere the upgrade link goes.
      navigate(href: string) => voidThe app's router, called on a plain click instead of following the link.
      labelsPartial<UpgradePromptLabels>Replaces the title, the message or the link's text.
      classstringExtra classes, such as a border and padding to frame it on a bare page.
      Code
      <Card>
        <CardBody>
          <UpgradePrompt
            href="/pricing"
            labels={{ message: "Exports come with the Pro plan." }}
          />
        </CardBody>
      </Card>

      PageHero

      The top band of a public site's inner page: an eyebrow, the page's heading, a lead, its buttons and a quiet byline with the updated day, in the marketing column.

      Pricing

      One price per seat

      Every feature on every plan. Pay monthly, cancel any time.

      By Anton ShubinUpdated

      Prices in euros, before VAT.

      Props
      PropType and defaultWhat it does
      titleComponentChildrenThe page's <h1>.
      eyebrowComponentChildrenA short line above the title.
      leadComponentChildrenOne or two sentences under it.
      childrenComponentChildrenThe page's buttons.
      author{ name: string; href?: string }Who wrote the page, linked when href is given, in the meta line.
      updatedstringThe day the page last changed, YYYY-MM-DD, as a <time datetime> in the meta line.
      noteComponentChildrenA short caveat right under the meta line, in the same quiet text.
      labelsPartial<PageHeroLabels>The meta line's words: by (By) and updated (Updated).
      classstringThe band's background, such as a glow.
      Code
      <PageHero
        eyebrow="Pricing"
        title="One price per seat"
        lead="Every feature on every plan. Pay monthly, cancel any time."
        author={{ name: "Anton Shubin", href: "/about" }}
        updated="2026-10-19"
        note="Prices in euros, before VAT."
      >
        <Button href="/sign-up">Start free</Button>
      </PageHero>

      Filters in the address bar

      useUrlFilters from @spy4x/preact-signals binds a set of signals to the query string both ways: the links below change the address and the filters follow, the buttons change the filters and the address follows. It is a hook rather than a component, so it has no card in the catalogue.

      Arriving, reading and remounting leave the address exactly as it was — including ?size=huge, which the size field's parser refuses and the address keeps anyway. Changing a filter is what writes, and that write changes the query string and nothing else: the path, a parameter belonging to something else on the page, and this page's own #/… route all stay where they were.

      status
      (any)
      page
      1
      size
      md
      Filters → address
      Carda fresh card starts from whatever the address says

      DataTable, sorted from the address bar

      DataTable's sort prop is caller-owned state, the same as Pagination's page. Here it is a ?sort= parameter, bound through useUrlFilters: press a column header and the address changes with it.

      Orders
      Grace340
      Ada120
      Katherine75

      The current day

      useNow from @spy4x/preact-signals holds the current time on a signal that moves at each midnight in a given zone (UTC here) and whenever this tab becomes visible again. Switch tabs and come back: the time below moves.

      UnsavedGuard, Back and Forward

      Open the editor, type, then press the browser's Back button. UnsavedGuard keeps the editor and its address and asks first: "Stay" keeps what you typed, "Leave" goes where Back was going. With nothing typed, Back leaves at once. A save that finishes while the question is open closes it and goes where Back was going too: press "Save in 3 seconds", then Back, and wait.

      A checklist that remembers

      enhanceChecklist from @spy4x/preact-ui keeps these ticks in this browser: reload the page or open it in a second tab and they are still there.

      Security

      • Restore last night's backup once, for real.

      • Every admin account has it turned on.

      Legal

      • Linked from the footer and the sign-up form.

      • It names every service you use.

      0 of 4 checked

      @spy4x/preact-icons

      Icons

      Every glyph the icon package exports. Search by name, click a glyph to copy its JSX.

      Icon gallery

      Each glyph is a component that draws in the text colour and takes a class for its size and colour. It is hidden from screen readers unless it gets an aria-label or a title. Click one to copy its JSX.

      126 of 126 shown

      <IconStar filled />

      Under a right-to-left page, the glyphs that point along the line flip; the others stay as drawn.

      dir="ltr"
      dir="rtl"
      Code
      import { IconSearch, IconStar } from "@spy4x/preact-icons"
      
      <IconSearch class="size-5 text-muted" />
      <IconSearch aria-label="Search" />
      <IconStar filled aria-label="Rated" />

      @spy4x/preact-theme

      Theme

      The design tokens and the classes the preset stylesheet ships, for the markup an app writes itself.

      Forms

      The classes that style native form controls, for a form written without a component around each field.

      Input

      Styles a native text field, so a form needs no input component.
      • .pc-input
      • .pc-label
      • .text-muted

      email: (empty)

      Code
      <input
        class="pc-input"
        type="email"
        placeholder="you@example.com"
        value={email.value}
        onInput={(event) => email.value = event.currentTarget.value}
      />

      Select

      Styles a native drop-down to match the text field beside it.
      • .pc-select
      • .pc-label
      • .text-muted

      role: editor

      Code
      <select class="pc-select" value={role.value} onChange={(event) => role.value = event.currentTarget.value}>
        <option value="admin">Administrator</option>
        <option value="editor">Editor</option>
      </select>

      Textarea

      Styles a native multi-line field, with a floor height so a short note still looks deliberate.
      • .pc-textarea
      • .pc-label
      • .text-muted

      2 lines

      Code
      <textarea
        class="pc-textarea"
        rows={3}
        placeholder="Anything the next person should know"
        value={notes.value}
        onInput={(event) => notes.value = event.currentTarget.value}
      />

      Input with an inline button

      Puts a small square button, such as search or clear, inside the right edge of a text field.
      • .btn-input-icon
      • .pc-input
      • .text-muted

      query: (empty)

      Code
      <div class="relative">
        <input class="pc-input pe-12" type="search" placeholder="Search users" />
        <button class="btn-input-icon absolute inset-y-0 end-1.5 my-auto" type="button" aria-label="Search">
          <IconSearch class="size-4" />
        </button>
      </div>

      Checkbox

      Styles a native checkbox, with its label wrapped around it so the text is part of the hit area.
      • .pc-checkbox
      • .pc-label
      • .text-muted

      archived: off · email: on

      Code
      <label class="pc-label" for="archived">
        <input
          id="archived"
          class="pc-checkbox"
          type="checkbox"
          checked={archived.value}
          onChange={(event) => archived.value = event.currentTarget.checked}
        />
        Show archived rows
      </label>

      Radio

      Styles native radio buttons, grouped in a fieldset whose legend names the choice.
      • .pc-radio
      • .pc-label
      • .text-muted
      Notification method

      channel: email

      Code
      <fieldset>
        <legend class="pc-label">Notification method</legend>
        <label class="pc-label" for="sms">
          <input id="sms" class="pc-radio" type="radio" name="channel" value="sms"
            checked={channel.value === "sms"}
            onChange={() => channel.value = "sms"} />
          Phone (SMS)
        </label>
      </fieldset>

      Labels and their placement

      Names a field from above it or below it, with a required marker, a hint and an error shown as plain text.
      • .pc-label
      • .pc-input
      • .pc-checkbox
      • .text-muted
      • .text-danger

      Full name, as on the contract.

      Code
      <div class="grid gap-2">
        <label class="pc-label" for="name">Name <span class="text-danger">*</span></label>
        <input id="name" class="pc-input" aria-describedby="name-hint" value={name.value} />
        <p id="name-hint" class="text-xs text-muted">As it appears on the contract.</p>
      </div>
      
      <!-- the suffix placement: the label follows the control -->
      <input id="ref" class="pc-input" value="Ref 2024-0917" />
      <label class="pc-label" for="ref">Reference</label>

      Surfaces and utilities

      The classes an app puts on its own markup: cards, a sideways scroller, the type scale, number tiles and the colours.

      Card

      Frames a block of content, with an optional header and footer that bring their own padding and dividers, and gives a focus ring inside it a gap in the card's colour (pc-focus-offset-surface does that for any other surface).
      • .pc-card
      • .pc-card-header
      • .pc-card-body
      • .pc-card-footer
      • .pc-link
      • .text-muted

      Meter 4417

      Last read 4 minutes ago

      online

      The body holds the content. The header and the footer bring their own padding and the line between them, so the parts need no spacing classes.

      Code
      <div class="pc-card">
        <div class="pc-card-header">
          <p class="font-medium">Meter 4417</p>
          <span class="text-xs text-muted">online</span>
        </div>
        <div class="pc-card-body">…</div>
        <div class="pc-card-footer">
          <a class="pc-link" href={meterHref}>Open the meter</a>
        </div>
      </div>

      Scroll container

      Lets a row wider than its box, such as a strip of chips, scroll sideways behind a thin themed scrollbar.
      • .pc-scrollbar
      • .border-subtle
      • .rounded-primary
      • .text-muted
      JanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecember

      Twelve months in one 28rem row: the row scrolls, the page does not. It is a named Tab stop, so the arrow keys scroll it too.

      Code
      <div class="pc-scrollbar flex gap-3" role="group" aria-label="Months" tabindex={0}>
        {months.map((month) => (
          <span class="rounded-primary border border-subtle px-3 py-1 text-sm">{month}</span>
        ))}
      </div>

      Type scale

      Gives any element a heading size, a bulleted list style or a link style, whatever its tag.
      • .pc-h1
      • .pc-h2
      • .pc-h3
      • .pc-h4
      • .pc-h5
      • .pc-link
      • .list-ul
      • .page-layout
      • .text-muted

      h1 — page title

      h2 — section

      h3 — sub-section

      h4 — card title

      h5 — field group

      • A bulleted list, with its markers inside.
      • A link, underlined until the pointer is on it.

      .page-layout frames this demo; a new page uses Page instead.

      Code
      <div class="page-layout">
        <p class="pc-h2">Section</p>
        <ul class="list-ul">
          <li>Disc markers, inside, one step down in size.</li>
          <li><a class="pc-link" href={reportHref}>A link that underlines itself</a></li>
        </ul>
      </div>

      KPI tiles and numbers

      Shows a headline number in a tile with an optional bar, and lines up a column of numbers in a table.
      • .pc-kpi
      • .pc-kpi-label
      • .pc-kpi-value
      • .pc-bar
      • .pc-num
      • .border-subtle
      • .text-muted
      Consumed1 284
      Budget1 800
      MonthReadings
      January1 284
      February986
      Code
      <div class="pc-kpi">
        <span class="pc-kpi-label">Consumed</span>
        <span class="pc-kpi-value">1 284</span>
        <span class="pc-bar" style="width: 72%" />
      </div>
      
      <td class="pc-num">1 284</td>

      Colour atoms

      Paints text, fills, borders, the two surfaces and the corner radius from the theme's tokens.
      • .text-primary
      • .text-muted
      • .text-danger
      • .text-warning
      • .text-success
      • .bg-primary
      • .bg-primary-muted
      • .bg-danger
      • .bg-danger-fill
      • .bg-warning
      • .bg-success
      • .border-primary
      • .border-subtle
      • .border-control
      • .bg-canvas
      • .bg-surface
      • .rounded-primary
      • .text-primary-foreground
      • .text-danger-fill-foreground
      text-primarytext-mutedtext-dangertext-warningtext-success
      bg-primarybg-danger-fillbg-warningbg-success
      bg-primary-mutedbg-danger

      These two fills are for marks, such as a bar or a dot, and carry no label: no one foreground token reads on them at 4.5:1 in both palettes. A danger label goes on bg-danger-fill.

      border-primaryborder-subtleborder-control
      bg-canvas — pagebg-surface — cardrounded-primary
      Code
      <span class="text-danger">text-danger</span>
      <span class="bg-success rounded-primary px-2 py-1 text-xs text-(--color-success-foreground)">bg-success</span>
      <span class="border-control border px-2 py-1 text-xs">border-control</span>
      <span class="bg-canvas border-subtle border px-2 py-1 text-xs">bg-canvas</span>

      Token classes

      Replaces a fixed gray, white or black class: what is chosen, hovered or dimmed, the text on each fill, the focus ring, the card corner and the two shadows.
      • .bg-selected
      • .text-selected-foreground
      • .bg-selected-soft
      • .text-selected
      • .border-selected
      • .bg-selected-hover
      • .bg-selected-soft-hover
      • .bg-hover
      • .bg-track
      • .bg-foreground
      • .text-canvas
      • .bg-scrim
      • .text-scrim-foreground
      • .bg-primary
      • .bg-surface
      • .bg-canvas
      • .text-danger
      • .border-subtle
      • .bg-info
      • .text-info
      • .border-info
      • .text-info-foreground
      • .bg-danger-fill
      • .bg-danger-fill-hover
      • .bg-danger
      • .text-danger-foreground
      • .text-primary-foreground
      • .text-accent-foreground
      • .text-foreground
      • .text-placeholder
      • .font-heading
      • .ring-focus
      • .ring-offset-focus
      • .ring-subtle
      • .ring-control
      • .divide-subtle
      • .rounded-card
      • .rounded-control
      • .shadow-raised
      • .shadow-popover
      • .text-danger-fill-foreground
      • .bg-danger-soft
      • .bg-info-soft
      • .bg-warning-soft
      • .bg-success-soft
      • .border-danger
      • .text-warning
      • .border-warning
      • .border-success
      • .bg-surface-overlay
      • .bg-scrim-strong
      • .bg-on-scrim
      • .bg-on-scrim-strong
      • .border-on-scrim
      • .text-on-scrim-muted
      • .border-strong
      • .decoration-control
      • .border-surface
      • .ring-surface
      • .ring-offset-surface
      bg-selectedbg-selected-softborder-selectedbg-selected-hoverbg-selected-soft-hoverbg-hoverbg-trackbg-foregroundbg-scrim
      bg-infotext-infoborder-warningbg-danger-fillbg-danger-fill-hovertext-danger-foregroundtext-primary-foregroundtext-accent-foreground
      bg-danger-softbg-info-softbg-warning-softbg-success-softtext-danger-fill-foregroundbg-surface-overlayborder-strongdecoration-controlborder-surfacering-surface
      bg-scrim-strong bg-on-scrimbg-on-scrim-strong text-on-scrim-muted
      text-foregroundtext-placeholderfont-heading
      ring-focusring-subtlering-controlrounded-card, shadow-raisedrounded-control, shadow-popover

      divide-subtle

      between rows

      Code
      <span class="bg-selected text-selected-foreground rounded-control px-2 py-1">
        Chosen day
      </span>
      <button class="ring-2 ring-focus ring-offset-2 ring-offset-focus">Focus</button>
      <div class="rounded-card shadow-raised border border-subtle">…</div>

      A warm dark palette from tokens

      One wrapper that overrides custom properties only: warm surfaces, a serif heading, a light orange accent with dark text, flat cards and 600 for medium weight.
      • .bg-canvas
      • .bg-surface
      • .pc-focus-offset-surface
      • .text-muted
      • .border-subtle
      • .rounded-card
      • .bg-danger-fill
      • .bg-selected
      • .bg-selected-soft
      • .border-selected
      • .font-heading
      • .pc-h3
      • .ring-focus
      • .ring-offset-focus
      • .rounded-control
      • .shadow-raised
      • .text-accent-foreground
      • .text-danger-fill-foreground
      • .text-foreground
      • .text-placeholder
      • .text-selected
      • .text-selected-foreground

      Winter readings

      The heading is serif and the weight is 600 because of --font-heading and --font-weight-medium. Nothing else was styled.

      Meter 4417

      A flat card: --shadow-raised is none.

      Chosen dayActive tab
      A focus ring: accent, with a card-coloured gap
      Code
      <style>
      :root {
        --color-canvas: oklch(0.19 0.008 60);
        --color-surface: oklch(0.235 0.01 60);
        --color-foreground: oklch(0.94 0.012 80);
        --color-accent: oklch(0.705 0.19 47); /* the label turns dark by itself */
        --color-selected: oklch(0.33 0.012 60); /* keep the accent for the primary button */
        --font-heading: Georgia, serif; /* unset, headings inherit */
        --font-weight-medium: 600;
        --shadow-raised: none;
      }
      </style>

      Ink palette

      An opt-in dark palette that repaints the same tokens with a quieter surface scale and its own focus colour.
      • .text-muted
      • .rounded-primary
      • .border-subtle

      Dark only: set data-theme="ink" together with .dark on <html>.

      surface-page
      surface-rail
      surface-card
      surface-active
      hairline
      text
      text-muted
      links (accent)
      nav-active
      focus-ring
      Code
      <html class="dark" data-theme="ink">
        <body class="theme-base">
          <nav style="background: var(--color-surface-rail)">…</nav>
        </body>
      </html>

      @spy4x/preact-charts

      Charts

      Charts that render on the server as plain SVG and HTML and add tooltips in the browser.

      Charts

      Every chart renders complete on the server from its data alone; the line chart and the donut add tooltips once the browser runs them.

      Bars

      A labelled bar chart drawn as a table of proportions: no d3, no client JavaScript, no width to measure.

      Orders by plan

      Starter
      41
      Team
      27
      Business
      12
      Enterprise
      6

      Spending by month

      January
      1450 (Rent: 900, Food: 420, Transport: 130)
      February
      1490 (Rent: 900, Food: 380, Transport: 210)
      March
      1500 (Rent: 900, Food: 510, Transport: 90)
      • Rent
      • Food
      • Transport
      • Budget: 1480
      Props
      PropType and defaultWhat it does
      data(BarDatum | StackedBarDatum)[]The rows: a label and a value each, or a label and stacked segments.
      maxnumber= the largest valueThe value the longest bar stands for, to compare two lists.
      format(value: number) => string= StringHow the trailing number is printed.
      titlestringThe heading, and the table's name.
      headingLevel2 | 3 | 4 | 5 | 6= 3The level of the title's heading, so it fits the page's outline.
      reference{ value, label }A dashed line at one value across every bar, named in the legend.
      Code
      <Bars data={[{ label: "Starter", value: 41 }]} title="Orders by plan" />
      <Bars
        title="Spending by month"
        data={[{ label: "March", segments: [{ series: "Rent", value: 900 }, { series: "Food", value: 510 }] }]}
        reference={{ value: 1500, label: "Budget" }}
      />

      DonutChart

      A donut drawn with one CSS conic-gradient whose slices show their value and share on hover or by the arrow keys, and whose legend rows turn into links when a slice has an href.

      Traffic sources

      the same donut, drawn once it scrolls into view

      Props
      PropType and defaultWhat it does
      dataDonutDatum[]The slices: a label and a value each.
      valueFormat(value: number) => string= StringHow a slice's value is printed in its tooltip.
      centerValuestring | numberThe figure in the hole, usually the total.
      axisColorstring= the control border tokenThe tooltip's border colour, as LineChart's axisColor.
      headingLevel2 | 3 | 4 | 5 | 6= 3The level of the title's heading, so it fits the page's outline.
      Code
      <DonutChart
        data={[{ label: "Organic", value: 52 }, { label: "Referral", value: 24, href: "/referral" }]}
        centerValue="12.4k"
        centerLabel="sessions"
        title="Traffic sources"
      />

      DotPlot

      A dot plot read as a list: one row per item, a dot on a shared scale, shaded bands named in a legend, and a bolder summary row.
      • 18 to 241,204 people7.6 h
      • 25 to 342,310 people6.9 h
      • 35 to 491,876 people6.4 h
      • 50 to 641,102 people6.8 h
      • 65 and overOnly 48 people7.9 h
      • Under 18Not surveyedNo data
      • Everyone7 h
      • Recommended, 7 to 9 hours
      Props
      PropType and defaultWhat it does
      rows{ id, label, value, detail?, muted?, note? }[]The rows; a null value draws no dot and a muted one a hollow dot.
      summaryDotPlotRowA bolder last row under a hairline.
      domain[number, number]The ends of the scale.
      bands{ from, to, label, strength }[]Shaded stretches behind the dots, each named in the legend.
      zeroboolean= falseA rule at zero, when zero is inside the domain.
      format(value: number) => string= StringHow a value and the scale's ends print.
      Code
      <DotPlot
        ariaLabel="Hours of sleep by age"
        domain={[5, 9]}
        rows={[{ id: "18-24", label: "18 to 24", value: 7.6, detail: "1,204 people" }]}
        summary={{ id: "all", label: "Everyone", value: 7 }}
        bands={[{ from: 7, to: 9, label: "Recommended, 7 to 9 hours", strength: 1 }]}
        format={(value) => `${value} h`}
      />

      LineChart

      The line chart: drawn complete on the server, with a crosshair and a tooltip that follow the pointer or the arrow keys, over categories or over time.

      Revenue, k€

      TodayA week earlierDashed grey lines mark gaps in the data.
      Revenue, k€
      PointTodayA week earlier
      1 Mar 2026, 00:006.15.2
      1 Mar 2026, 02:005.44.9
      1 Mar 2026, 04:007.26.1
      1 Mar 2026, 06:009.88.4
      1 Mar 2026, 08:0011.69.9
      1 Mar 2026, 10:0012.411.1
      1 Mar 2026, 12:0012.911.8
      1 Mar 2026, 14:00–10.9
      1 Mar 2026, 16:0010.29.6
      1 Mar 2026, 18:008.88.1
      1 Mar 2026, 20:007.36.8
      1 Mar 2026, 22:006.66.0

      Orders per month

      OrdersReturns
      Orders per month
      PointOrdersReturns
      Jan4128
      Feb2719
      Mar129
      Apr64
      May4833
      Jun2114

      Sign-ups per day

      Sign-ups per day
      PointSign-ups
      Oct 112
      Oct 216
      Oct 311
      Oct 417
      Oct 522
      Oct 627
      Oct 723
      Oct 819
      Oct 923
      Oct 1018
      Oct 1124
      Oct 1229
      Oct 1334
      Oct 1430
      Oct 1526
      Oct 1630
      Oct 1725
      Oct 1831
      Oct 1936
      Oct 2041
      Oct 2137
      Oct 2233
      Oct 2337
      Oct 2432
      Oct 2538
      Oct 2643
      Oct 2748
      Oct 2844
      Oct 2940
      Oct 3044
      Props
      PropType and defaultWhat it does
      seriesLineSeries[]Each line: a name and its points.
      xAxis"category" | "time"= "category"Evenly spaced labels, or instants placed by time with round ticks.
      yFormat(value: number) => string= at most two decimalsHow a Y tick and a tooltip value are printed.
      referenceValuenumberA dashed target line, always inside the Y axis.
      ignoreZeroesboolean= falseTreat a zero as missing: the line breaks and a dashed bridge spans the gap.
      headingLevel2 | 3 | 4 | 5 | 6= 3The level of the title's heading, so it fits the page's outline.
      Code
      <LineChart
        title="Revenue, k€"
        xAxis="time"
        series={[
          { name: "Today", points: today.map((p) => ({ x: p.time, y: p.value })) },
          { name: "A week earlier", dashed: true, points: lastWeek.map((p) => ({ x: p.time, y: p.value })) },
        ]}
        referenceValue={12}
        referenceLabel="Target"
      />

      Kpi

      One key figure with its label and caption, in five tones that say what the number is for, or with a change line that alone takes the tone.
      Accent
      42
      tone="accent"
      Positive
      42
      tone="positive"
      Warning
      42
      tone="warning"
      Negative
      42
      tone="negative"
      Neutral
      42
      tone="neutral"
      Visits
      1,240
      +120 vs previous 30 days
      Order clicks
      52
      −3 vs previous 30 days
      Order rate
      4.2%
      No change vs previous 30 days
      52 of 1,240 visits
      Props
      PropType and defaultWhat it does
      labelstringWhat the number counts.
      valuestring | numberThe number, in tabular figures.
      tone"accent" | "positive" | "warning" | "negative" | "neutral"= "accent"The value's colour; with change the value stays the foreground colour.
      change{ text, direction: "up" | "down" | "flat", tone? }A line under the value with a trend icon and the change in words; only it takes tone, muted by default.
      Code
      <Kpi label="Uptime" value="99.95%" sub="last 30 days" tone="positive" />
      <Kpi
        label="Visits"
        value="1,240"
        change={{ text: "+120 vs previous 30 days", direction: "up", tone: "positive" }}
      />

      KpiGrid

      A responsive grid of Kpi cards whose column width is the minWidth prop; the two grids below differ only in it.
      Uptime
      99.95%
      last 30 days
      Errors
      3
      Pending
      18
      Users
      1204
      Latency
      84 ms

      the same five cards at minWidth="16rem"

      Uptime
      99.95%
      last 30 days
      Errors
      3
      Pending
      18
      Users
      1204
      Latency
      84 ms
      Code
      <KpiGrid minWidth="12rem">
        <Kpi label="Uptime" value="99.95%" tone="positive" />
        <Kpi label="Errors" value={3} tone="negative" />
      </KpiGrid>

      RankedBars

      A ranked list with several numbers per row, such as visits per source: a bar under each label, one to three more right-aligned columns, and rows that are links or buttons.

      Where visits come from

      SourceVisitsOrder clicksOrder rate
      Order clicks 24 · Order rate 5.0%
      480245.0%
      Order clicks 14 · Order rate 4.5%
      310144.5%
      Order clicks 2 · Order rate 2.2%
      9022.2%
      Order clicks 0 · Order rate –
      80–
      Order clicks 12 · Order rate 3.3%
      360123.3%

      Chart shows: All sources

      Most ordered

      ItemOrder clicksViews
      Linen shirt, sandViews 210 · mostly Zalo A
      9210
      Áo dài lụa hoa senViews 150 · mostly Facebook
      6150
      Cotton dressViews 96
      496

      Opened in place: nothing yet

      Props
      PropType and defaultWhat it does
      data{ label, value, values?, href?, onSelect?, lowSample?, image?, note? }[]The rows, ranked by the caller; values fills the extra columns, null printing a dash. image ({ src, alt }) draws a 40 px thumbnail before the label, note a muted line under it. A row with onSelect needs JavaScript.
      columns{ label, value, extra }The headers of the label, the bar's number and one to three extra columns, each with an optional format.
      format(value: number) => string= StringHow the bar's own number is printed.
      titlestringThe heading, and the table's name.
      navigate(href: string) => voidThe app's router: a plain click on a row's href calls it instead of loading the page, as Link does. A modified click or another origin stays the browser's.
      Code
      <RankedBars
        title="Where visits come from"
        columns={{ label: "Source", value: "Visits", extra: [{ label: "Order clicks" }] }}
        data={[{ label: "Zalo group A", value: 480, values: [24], onSelect: () => filter("zalo") }]}
      />

      @spy4x/preact-map

      Map

      Markers on a Leaflet map, plotted from plain data, with a list of the same places. The page loads Leaflet only when it opens, so until then the card shows a placeholder.

      Map

      Markers on a Leaflet map, plotted from plain data, with a list of the same places. The page loads Leaflet only when it opens, so until then the card shows a placeholder.

      Map

      Markers on a map, from plain data, with a list of the same places under it; you pick the tile provider and give its credit line.
      Map: drawn in the browser with Leaflet

      onMarkerClick: none yet

      Props
      PropType and defaultWhat it does
      center{ lat, lng }The point the map centres on.
      zoomnumberThe starting zoom level.
      markersMapMarker[]The places: an id, lat, lng, a label and an optional status. Pins are matched by id, so one that stays keeps its focus.
      onMarkerClick(id: string) => voidCalled with a marker's id when its pin is clicked or pressed.
      tileUrlstringThe tile provider's URL template, with {z}, {x} and {y}.
      attributionstringThe credit line the tile provider requires.
      statusLabels{ on?, off?, unknown? }The status word each pin's name and list row say after the label. Defaults to on, off and status unknown.
      listHeadingLevel2 | 3 | 4 | 5 | 6The level of the list's heading. Defaults to 3.
      Code
      // A constant, or state replaced only when the places change: not a new array per render.
      const PLACES = [{ id: "depot", lat: 51.5, lng: -0.13, label: "London depot", status: "on" }]
      
      <Map
        center={{ lat: 50, lng: 5 }}
        zoom={4}
        markers={PLACES}
        onMarkerClick={(id) => select(id)}
        tileUrl="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
        attribution="© OpenStreetMap contributors"
      />

      @spy4x/preact-system

      System

      The frames an app is built in, its sign-in form and calendar, and the pieces that work with the browser: the page head, state handed over from the server, the service-worker update prompt, offline sync status and conflicts, the install offer, and the notifications switch.

      System

      The frames an app is built in, its sign-in form and calendar, and the pieces that work with the browser: the page head, state handed over from the server, the service-worker update prompt, offline sync status and conflicts, the install offer, and the notifications switch.

      Shell

      The frame of a signed-in app: a header with the brand and a user menu, a sidebar from lg up, and the same navigation in a drawer below it.
      Skip to content

      Synced a minute ago

      Acme
      Connected
      AL
      Your profile

      The page for /dashboard goes here.

      navigate calls: 0. "New project" presses: 0. "Sign out" submits taken over: 0.

      Props
      PropType and defaultWhat it does
      navItemsShellNavItem[]The navigation, drawn in both the sidebar and the drawer. An item's action adds a control of its own beside it.
      navigate(href: string) => voidYour router. A plain click on a link calls it instead of loading the page; Ctrl, Meta, Shift or Alt clicks and links to another origin stay the browser's.
      sidebarTop(place) => ComponentChildrenContent above the navigation, drawn in the sidebar and the drawer (place is "sidebar" or "drawer"); sidebarBottom is pinned below it.
      collapsedboolean= falseHides the desktop sidebar. With onCollapsedChange, the header shows a button that toggles it; you store the value.
      currentPathstringMarks the item whose href equals it as the current page.
      brandComponentChildrenThe header's left-hand side.
      userShellUser | nullWho is signed in; the user menu opens from their avatar.
      userMenuItemsShellUserMenuItem[]Links, buttons and form posts in the user menu. An item with action is a real <form method="post">, so it posts without client code; the menu itself still needs JavaScript to open. Its onClick, if given, takes the submit over.
      statusComponentChildrenA small slot in the header, such as a connection indicator.
      Code
      const collapsedStore = makeStorage(localStorage, "sidebar-collapsed", {
        schema: type("boolean"),
      })
      const collapsed = useSignal(false)
      useEffect(() => {
        const stored = collapsedStore.get()
        if (stored.status === "ok") collapsed.value = stored.value
      }, [])
      
      <Shell
        brand={<Logo />}
        currentPath={url.pathname}
        navigate={(href) => router.navigate(href)}
        collapsed={collapsed.value}
        onCollapsedChange={(value) => {
          collapsed.value = value
          collapsedStore.set(value)
        }}
        sidebarTop={(place) => <WorkspaceSwitcher id={`workspace-${place}`} />}
        sidebarBottom={() => <Filters />}
        navItems={[
          { name: "Dashboard", href: "/dashboard", Icon: IconHome },
          { name: "Projects", href: "/projects", Icon: IconFolder,
            action: { label: "New project", onClick: () => openCreateForm() } },
          { name: "Docs", href: "/docs", Icon: IconBookOpen },
          { name: "Settings", children: [
            { name: "Billing", href: "/settings/billing" },
            { name: "Team", href: "/settings/team", counter: unreadInvites },
          ] },
        ]}
        user={session ? { name: session.name, email: session.email } : null}
        userMenuItems={[
          { label: "Your profile", href: "/profile" },
          { label: "Sign out", action: "/sign-out", onClick: () => auth.signOut(), dataE2E: "signout" },
          { label: "Sign out everywhere", action: "/sign-out-everywhere" },
        ]}
        status={<ConnectionIndicator />}
      >
        <PageContent />
      </Shell>

      Rail

      The house frame's navigation: a column from md (72 px for icons, 88 px for labels) with three zones, of which only the middle one scrolls, and a bottom tab bar on a phone. icons names each cell in a tooltip; labels prints the name under the icon and works with no script.
      Skip to content
      Variant

      Current: work

      Each label is printed under its icon, so the rail needs no script: every cell is a link.

      © Uma Lee. The footer sits under the page, outside its main.
      Props
      PropType and defaultWhat it does
      variant"icons" | "labels"icons (the default) for apps, whose people learn the icons; labels for a site read by first-time visitors.
      itemsRailItem[]The destinations. An item's counter draws a badge and joins its name; attributes passes data-* attributes to its link; sheetOnly draws it only as a row of the phone sheet, which then marks More current while it is; keyshortcuts names the keys the app binds to it, announced in aria-keyshortcuts and shown in the icons variant's tooltip, such as "Search (Ctrl K)".
      primaryRailItemThe one accent-filled action, pinned first in the column and in the tab bar's centre slot.
      currentPathstringMarks an exact href match with aria-current="page", and the item whose href is the longest section prefix with aria-current="true". currentKey marks by key.
      navigateLink(key: string, href: string) => voidCalled on a plain left click on an item's same-origin link, instead of following it.
      top(place: RailPlace) => ComponentChildrenThe column's top zone and the sheet's first section, called with "rail" or "sheet". RailEntry draws a link there exactly like an entry, such as a portrait linking home; a place that renders nothing gets no wrapper.
      bottom(place: RailPlace) => ComponentChildrenThe column's bottom zone and the sheet's last section.
      tabBarNoticeComponentChildrenA strip drawn right above the phone tab bar, such as an offline notice.
      footerComponentChildrenDrawn after <main> and outside it, under the page, such as a site's <footer>, which keeps its contentinfo landmark.
      Code
      const Portrait = () => <img src="/me.jpg" alt="" class="size-8 rounded-full" />
      
      <Rail
        variant="labels"
        items={[
          { key: "work", label: "Work", href: "/work", Icon: IconBriefcase },
          // The app binds W itself; the entry announces it and shows it in its tooltip.
          { key: "writing", label: "Writing", href: "/writing", Icon: IconDocumentText, keyshortcuts: ["w"] },
          // Phone sheet only: the column reaches Home through the portrait.
          { key: "home", label: "Home", href: "/", Icon: IconHome, sheetOnly: true },
          { key: "about", label: "About", href: "/about", Icon: IconUsers, sheetOnly: true },
        ]}
        currentPath={url.pathname}
        primary={{ key: "book", label: "Book", href: "/book", Icon: IconCalendar }}
        top={(place) =>
          place === "rail" && (
            <RailEntry
              item={{ key: "home", label: "Home", href: "/", Icon: Portrait }}
              place="rail"
              variant="labels"
              current={url.pathname === "/" ? "page" : undefined}
            />
          )}
        footer={<SiteFooter groups={groups} />}
      >
        <Page />
      </Rail>

      AppShell

      All the chrome around an app's page, for a product and its admin panel alike: the icon rail with the bell and the account at its foot, the phone tab bar and sheet, the connection, and a centred page column. An app passes data, not markup.
      Online
      Skip to content
      App

      Current: #app-home, theme: system

      The page starts here, in a column centred in the space beside the rail: 48 rem wide by default.

      Account

      Uma Leeuma@example.com
      Profile
      Theme
      Props
      PropType and defaultWhat it does
      itemsAppShellItem[]The daily destinations, each a link with an icon. Four reach the phone bar; the rest go to the sheet.
      accountAppShellAccountThe signed-in person, the menu's items, the language (AccountMenuLanguage: a Language row in the menu and the phone sheet, opening LanguageDialog), the theme, Sign out, and an optional tag such as "Admin", drawn as a neutral badge.
      notificationsAppShellNotificationsThe bell's href and unread count, spoken in its name.
      connectionAppShellConnectionThe realtime connection. Not live, it draws a warning cell, a phone strip and a polite announcement.
      navigate(href: string) => voidThe app router's port: a plain click on any shell link calls it.
      switcher(place: RailPlace) => ComponentChildrenThe workspace switcher, in the column's top zone and the sheet. In the column, wrap its control in RailCell and give the control railFocusRing, so it gets the rail's tooltip and focus ring.
      width"default" | "wide"The page column: 48 rem, or 64 rem for tables.
      bannerComponentChildrenContent above the page's own header, in the same column.
      Code
      <AppShell
        items={[
          { key: "home", label: "Dashboard", href: "/", Icon: IconHome },
          { key: "notes", label: "Notes", href: "/notes", Icon: IconBookOpen },
        ]}
        currentPath={location.pathname}
        navigate={(href) => router.go(href)}
        notifications={{ href: "/notifications", unread }}
        account={{
          user: { name: session.name, email: session.email },
          tag: isAdminApp ? "Admin" : undefined,
          items: [{ label: "Profile", href: "/profile" }],
          language: { languages: ["en", "de", "ar"], current: language, onSelect: switchLanguage },
          theme,
          onThemeChange: setTheme,
          signOut: { label: "Sign out", action: "/sign-out" },
        }}
        connection={socketState}
        switcher={(place) =>
          place === "rail"
            ? (
              <RailCell tip={group.name}>
                <Dropdown
                  trigger={<GroupMark group={group} />}
                  triggerLabel={`Group: ${group.name}`}
                  triggerClasses={`size-10 rounded-md ${railFocusRing}`}
                >
                  {groupItems}
                </Dropdown>
              </RailCell>
            )
            : <GroupList />}
      >
        <Page />
      </AppShell>

      RailShell

      Rail's icons variant under its earlier name and slot props, kept so an app can move to AppShell in its own time: a 72 px icon rail with tooltips from md, and a bottom tab bar on a phone whose last slot holds the rest.
      Skip to content

      Navigated to: home through start

      Workspace: acme, theme: system

      Paragraph 1 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

      Paragraph 2 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

      Paragraph 3 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

      Paragraph 4 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

      Paragraph 5 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

      Paragraph 6 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

      Paragraph 7 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

      Paragraph 8 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

      Paragraph 9 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

      Paragraph 10 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

      Paragraph 11 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

      Paragraph 12 of the page. It is here so the frame scrolls and the rail and the tab bar can be seen staying in place while it does.

      The last line of the page.

      Account

      Acme StudioOwner
      Notifications
      Uma Leeuma@example.com
      Profile
      Props
      PropType and defaultWhat it does
      itemsRailShellItem[]The destinations; past five, the phone bar grows a More slot. An item's counter draws a badge and joins its name.
      primaryRailShellItemThe one action pinned apart from the rest, such as Write.
      currentPathstringMarks the item whose href equals it; currentKey does the same by key.
      navigate(key: string) => voidCalled for an item with no href, instead of following a link.
      navigateLink(key: string, href: string) => voidCalled on a plain left click on an item's same-origin link, instead of following it; a modified or middle click stays the browser's.
      sidebarTop(place: RailShellSidebarPlace) => ComponentChildrenDrawn above the items, such as a workspace switcher; called with "rail" or "overlay", so it can draw a compact form in the 72 px rail.
      sidebarBottom(place: RailShellSidebarPlace) => ComponentChildrenDrawn at the column's foot, such as notifications and AccountMenu; on a phone both slots move into the last tab's overlay.
      moreTabRailShellMoreTabThe phone bar's last slot: an icon node, such as an avatar, and a counter. Its label is labels.more.
      Code
      <RailShell
        items={[
          { key: "home", label: "Home", href: "/", Icon: IconHome },
          { key: "projects", label: "Projects", href: "/projects", Icon: IconFolder },
          // … more than five entries in all, and the phone bar grows a "More" slot
        ]}
        currentPath={location.pathname}
        primary={{ key: "compose", label: "Write", href: "/new", Icon: IconPencilSquare }}
        navigateLink={(key, href) => navigate(href)}
        sidebarTop={(place) => <WorkspaceSwitcher compact={place === "rail"} />}
        sidebarBottom={(place) => (
          <>
            <NotificationsLink compact={place === "rail"} />
            <AccountMenu
              user={{ name: session.name, email: session.email }}
              display={place === "rail" ? "avatar" : "full"}
              theme={theme}
              onThemeChange={setTheme}
              signOut={{ label: "Sign out", action: "/sign-out" }}
            />
          </>
        )}
        labels={{ more: "Account", moreDialog: "Account", counter: (n) => `${n} unread` }}
        moreTab={{ icon: <Avatar name={session.name} size="sm" alt="" />, counter: unread }}
      >
        <Page />
      </RailShell>

      AccountMenu

      The signed-in person's menu: who is signed in on the trigger, then the caller's items, a Language item, a theme choice and Sign out.
      Uma Leeuma@example.com
      Uma Leeuma@example.comProfile

      Theme: system

      Routed to: none

      Props
      PropType and defaultWhat it does
      userAccountMenuUserThe person: name, email and avatarUrl.
      itemsAccountMenuItem[]The caller's entries first: a link, a form post (action) or a button. keyshortcuts announces the keys the app binds to the same action and shows the first at the item's right.
      languageAccountMenuLanguageLanguageDialog's props without open and onClose. Given, a Language item with the language in use, named in itself, opens that dialog.
      theme / onThemeChangeAccountMenuTheme / (theme) => voidWith onThemeChange, the menu draws System, Light and Dark as radio items and marks theme.
      signOutAccountMenuItemThe last entry, after a divider; a form post, so it is never a link.
      navigate(href: string) => voidThe app's router: a plain click on a link item calls it instead of loading the page; a Ctrl-click stays the browser's.
      display"full" | "avatar"full is the avatar, name and e-mail on a wide button for a sidebar; avatar is the avatar alone.
      vertical"up" | "down" | "auto"Which way the menu opens; up by default, for the foot of a sidebar.
      Code
      <AccountMenu
        user={{ name: "Uma Lee", email: "uma@example.com" }}
        items={[
          { label: "Profile", href: "/profile" },
          // The app binds "?" itself; the item announces it and shows it
          { label: "Keyboard shortcuts", onClick: openShortcuts, keyshortcuts: ["?"] },
        ]}
        navigate={(href) => router.navigate(href)}
        language={{ languages: ["en", "de", "ar"], current: language, onSelect: switchLanguage }}
        theme={theme}
        onThemeChange={setTheme}
        signOut={{ label: "Sign out", action: "/sign-out" }}
      />

      SiteHeader

      The top bar of a public site: the brand, its links, and a menu that holds the links on a narrow screen and works without JavaScript.
      Props
      PropType and defaultWhat it does
      linksSiteHeaderLink[]The links, each a label, an href, an optional icon and an optional external mark. With none, the bar has no navigation and no menu button.
      currentPathstringMarks the link whose href equals it as the current page.
      brandComponentChildrenThe bar's left-hand side.
      actionsComponentChildrenButtons that stay in view at every width.
      Code
      <SiteHeader
        brand={<Logo />}
        currentPath={url.pathname}
        links={[
          { label: "Product", href: "/product" },
          { label: "Pricing", href: "/pricing" },
          { label: "Docs", href: "/docs", Icon: IconBookOpen },
        ]}
        actions={<Button size="sm" onClick={() => navigate("/get-started")}>Get started</Button>}
      />

      AuthForm

      Sign-in, sign-up and one-time-code forms that hand what was typed to your callbacks and post natively before the page's script has loaded.

      Sign in and sign up, side by side

      Driven by buttons in place of a server

      sign-ins: 0, sign-ups: 0, codes: 0

      After a refused submit, focus goes to the error

      With the field names your API expects

      login: , password length: 0, code:

      With the mode switch as a link

      onModeChange calls: 0

      With a login handed back, and reported as typed

      login as typed: ana@example.com, reports: 0

      With passkeys offered in the login field's autofill

      With a secondary submit, under the page's one primary action

      With a link below the form

      Props
      PropType and defaultWhat it does
      mode"sign-in" | "sign-up"Which form to show.
      step"credentials" | "one-time-code"The login and password, or the code sent to the user.
      onSignIn({ login, password }) => voidCalled on submit; onSignUp and onOneTimeCode match the other forms.
      busyboolean= falseDisables the form while a request is running.
      errorstring | { message, field? }Shown above the form, and beside the field it names.
      names{ login?, password?, code? }= "login", "password", "code"The inputs' name attributes, so a post without script sends the field names your API expects. The callbacks keep their shape.
      modeHrefs{ "sign-in": string; "sign-up": string }Each mode's page. The mode switch becomes a link that works without script; a plain click still calls onModeChange when given.
      actionstringWhere the form posts when no script is running.
      passkeyAutofillboolean= falseOn sign-in, the login field's autofill also offers saved passkeys (autocomplete "username webauthn"). Your page starts the conditional WebAuthn request.
      defaultLoginstringThe login field's starting value, so a page that sends a person back can hand their address back. Typing replaces it.
      onLoginInput(login: string) => voidCalled on every input of the login field with its value as typed, for an action that needs the address first, such as "Email me a sign-in link". Never the password.
      submitVariantButtonVariant= "primary"The credentials submit's variant. "secondary" when the page's one primary action is outside the form, such as "Try it now" above sign-up. The code step stays primary.
      footerComponentChildrenShown below the form, inside it: a link back to sign-in, for instance.
      Code
      // End-to-end tests find each control by a fixed data-e2e, whatever the labels say:
      // auth-form (the form), auth-form-login, auth-form-password, auth-form-password-toggle,
      // auth-form-code, auth-form-submit, auth-form-mode-switch and auth-form-error (a message).
      // When busy ends, the focus the disabled submit dropped goes to the error the answer brought.
      <AuthForm
        mode={mode}
        step={step}
        onModeChange={setMode}
        modeHrefs={{ "sign-in": "/sign-in", "sign-up": "/sign-up" }} // the switch is a link
        onSignIn={({ login, password }) => auth.signIn(login, password)}
        onSignUp={({ login, password }) => auth.signUp(login, password)}
        onOneTimeCode={(code) => auth.verify(code)}
        busy={pending}
        error={error} // string, or { message, field: "login" | "password" | "code" }
        names={{ login: "username", code: "otp" }} // what a post without script sends
        defaultLogin={address} // handed back by a page that sent the person here
        onLoginInput={(login) => typedLogin = login} // the login as typed, never the password
        action="/auth/sign-in"
        footer={<a class="pc-link" href="/sign-in">Back to sign in</a>}
      />
      
      // The page's one primary action is above the form, so the form's submit steps down:
      <Button onClick={tryAsGuest}>Try it now</Button>
      <AuthForm mode="sign-up" step="credentials" onSignUp={signUp} submitVariant="secondary" />

      Calendar

      A month grid of days to pick from, each showing how many places are left, as links or as buttons with full keyboard support.

      Links, in three locales

      March 2026

      Mon
      Tue
      Wed
      Thu
      Fri
      Sat
      Sun
      1
      2
      3
      4
      5
      6
      7
      8
      16
      17
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31

      locale: en-GB

      Buttons and the keyboard

      March 2026

      Mon
      Tue
      Wed
      Thu
      Fri
      Sat
      Sun
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      13
      14
      15
      16
      17
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31

      onSelectDate: nothing picked · onSelectMonth: 2026-03-01

      The cells before the 1st and after the last day are blank: a month shows no date of a neighbouring month.

      Tab once to reach the grid; the arrow keys, Home, End, Page Up and Page Down move inside it.

      An owner that refuses every month

      March 2026

      Mon
      Tue
      Wed
      Thu
      Fri
      Sat
      Sun
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      12
      13
      14
      15
      16
      17
      18
      20
      21
      22
      23
      24
      26
      27
      28
      29
      30
      31

      Last month asked for: nothing yet; requests refused: 0.

      Focus a day and press Page Down: the count rises, the grid stays on March, and the focus stays on your day. Click a month arrow from a day instead and the focus stays on the arrow, while Tab back into the grid still lands on your day — until the owner selects another day.

      An owner that answers late

      March 2026

      Mon
      Tue
      Wed
      Thu
      Fri
      Sat
      Sun
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31

      Answers by timer. Last month asked for: nothing yet; answers drawn: 0; showing: 2026-03-01.

      Focus a day and press Page Down: the month changes a moment later and the focus follows it to the same day number.

      Props
      PropType and defaultWhat it does
      monthAnchorstringThe first day of the month on screen, as YYYY-MM-DD.
      minDatestringThe first day that can be picked; maxDate is the last.
      availableByDateRecord<string, number>Places left per day; a day left out or at 0 cannot be picked.
      onSelectDate(date: string) => voidTurns the days into buttons; without it they are links.
      onSelectMonth(monthAnchor: string) => voidAsked for another month; the owner decides whether to show it.
      todaystring= the clock's datePins today, with timeZone, so a render does not depend on the clock.
      localestring= "en-GB"Sets the first day of the week and the weekday names.
      Code
      <Calendar
        monthAnchor="2026-03-01"
        minDate="2026-03-01"
        maxDate="2026-04-30"
        today="2026-03-10"
        timeZone="UTC"
        availableByDate={{ "2026-03-12": 3, "2026-03-13": 0 }}
        selectedDate="2026-03-12"
        onSelectDate={(date) => picked.value = date}
      />

      StateInit

      Hands data from the server render to the browser as JSON in the page, which readStateInit reads back.

      Written into the page by the server render:

      {
        "userId": 42,
        "features": [
          "</script>",
          "<!--",
          "

"
        ]
      }
      (not read yet)
      Code
      // Wherever the server renders the page:
      <StateInit data={{ userId: user.id, features: enabledFeatures }} />
      
      // Anywhere on the client:
      const state = readStateInit<{ userId: string; features: string[] }>()

      SEOHead

      The title, description, social-card and structured-data tags a page needs in its head, from one object; this card shows them as seoHeadTags returns them.

      22 tags, in document order: the array <SEOHead /> renders.

      [
        {
          "tag": "title",
          "attrs": {},
          "text": "Blue widget — Acme"
        },
        {
          "tag": "meta",
          "attrs": {
            "name": "description",
            "content": "Specifications, prices and reviews for the blue widget."
          }
        },
        {
          "tag": "link",
          "attrs": {
            "rel": "canonical",
            "href": "https://example.com/products/widgets/12"
          }
        },
        {
          "tag": "meta",
          "attrs": {
            "name": "robots",
            "content": "index, follow"
          }
        },
        {
          "tag": "meta",
          "attrs": {
            "name": "twitter:card",
            "content": "summary_large_image"
          }
        },
        {
          "tag": "meta",
          "attrs": {
            "name": "twitter:site",
            "content": "@acme"
          }
        },
        {
          "tag": "meta",
          "attrs": {
            "name": "twitter:creator",
            "content": "@jane"
          }
        },
        {
          "tag": "meta",
          "attrs": {
            "name": "twitter:title",
            "content": "Blue widget — Acme"
          }
        },
        {
          "tag": "meta",
          "attrs": {
            "name": "twitter:description",
            "content": "Specifications, prices and reviews for the blue widget."
          }
        },
        {
          "tag": "meta",
          "attrs": {
            "name": "twitter:image",
            "content": "https://example.com/og/widgets.png"
          }
        },
        {
          "tag": "meta",
          "attrs": {
            "name": "twitter:image:alt",
            "content": "A blue widget on a desk"
          }
        },
        {
          "tag": "meta",
          "attrs": {
            "property": "og:type",
            "content": "article"
          }
        },
        {
          "tag": "meta",
          "attrs": {
            "property": "og:title",
            "content": "Blue widget — Acme"
          }
        },
        {
          "tag": "meta",
          "attrs": {
            "property": "og:description",
            "content": "Specifications, prices and reviews for the blue widget."
          }
        },
        {
          "tag": "meta",
          "attrs": {
            "property": "og:url",
            "content": "https://example.com/products/widgets/12"
          }
        },
        {
          "tag": "meta",
          "attrs": {
            "property": "og:image",
            "content": "https://example.com/og/widgets.png"
          }
        },
        {
          "tag": "meta",
          "attrs": {
            "property": "og:image:width",
            "content": "1200"
          }
        },
        {
          "tag": "meta",
          "attrs": {
            "property": "og:image:height",
            "content": "630"
          }
        },
        {
          "tag": "meta",
          "attrs": {
            "property": "og:image:alt",
            "content": "A blue widget on a desk"
          }
        },
        {
          "tag": "meta",
          "attrs": {
            "property": "og:site_name",
            "content": "Acme"
          }
        },
        {
          "tag": "meta",
          "attrs": {
            "property": "og:locale",
            "content": "en_GB"
          }
        },
        {
          "tag": "script",
          "attrs": {
            "type": "application/ld+json"
          },
          "text": "{\"@context\":\"https://schema.org\",\"@graph\":[{\"@type\":\"Organization\",\"name\":\"Acme\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https://example.com/products/widgets/12#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https://example.com/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Widgets\",\"item\":\"https://example.com/products/widgets\"},{\"@type\":\"ListItem\",\"position\":3,\"name\":\"Blue widget\",\"item\":\"https://example.com/products/widgets/12\"}]}]}"
        }
      ]
      Props
      PropType and defaultWhat it does
      titlestringThe page's title.
      descriptionstringOne or two sentences for results.
      canonicalstringThe page's one address; a fragment is dropped.
      crumbsCrumb[]The breadcrumb trail, root first, published as a BreadcrumbList.
      jsonLdunknown[]More structured data for the graph.
      imageAltstringThe preview image described in words; published only with an ogImage.
      imageWidthnumberThe preview image's width in pixels; published only with an ogImage.
      imageHeightnumberThe preview image's height in pixels; published only with an ogImage.
      twitterCreatorstringThe author's Twitter/X handle, as twitter:creator.
      Code
      <SEOHead
        title="Blue widget — Acme"
        description="Specifications, prices and reviews for the blue widget."
        canonical="https://example.com/products/widgets/12"
        ogImage="https://example.com/og/widgets.png"
        imageAlt="A blue widget on a desk"
        imageWidth={1200}
        imageHeight={630}
        siteName="Acme"
        twitterCreator="@jane"
        jsonLd={[{ "@type": "Organization", name: "Acme" }]}
        crumbs={[
          { name: "Home", href: "/" },
          { name: "Widgets", href: "/products/widgets" },
          { name: "Blue widget" },
        ]}
      />
      
      // The same tag set as data, for an app whose head is not a component tree:
      const tags = seoHeadTags(head)

      SWUpdater

      Registers the service worker and, once a new version is waiting, offers the visitor a reload.

      Mounted with nothing waiting

      Mounted now with nothing waiting, it renders an empty live region. The first button makes an update ready inside that same element; the second fires that update's events again, which leaves a dismissed bar dismissed.

      announced 0 times, repeated 0 times, reload port called 0 times

      The function underneath, on a fake registration

      Each button runs the real function against a registration it builds on the spot.

      Against a real service worker

      reload port called 0 times

        <SWUpdater> is not mounted: it registers a worker, so it waits for the button.

        Props
        PropType and defaultWhat it does
        scriptUrlstring= "/sw.js"The worker's script.
        reload() => void= reloads the pageRuns once the new version has taken over.
        messagestring= "A new version is ready"The bar's text.
        updateMessageunknown= { action: "skipWaiting" }What the button posts to the waiting worker; it must recognise it.
        onUpdate() => voidCalled when an update is found; onError when registering fails.
        Code
        <SWUpdater
          scriptUrl="/sw.js"
          reload={() => globalThis.location.reload()}
          onUpdate={() => app.toast.info({ body: "Updating…" })}
          onError={(error) => app.report(error)}
        />
        
        // Only when the worker speaks another dialect — the default is { action: "skipWaiting" }:
        <SWUpdater scriptUrl="/sw.js" updateMessage={{ type: "SKIP_WAITING" }} />

        SyncStatus

        One compact line for a header that says whether offline changes reached the server: offline, waiting, syncing, or failed with a Retry.
        2 changes waiting to sync
        Props
        PropType and defaultWhat it does
        onlinebooleanWhether the browser has a network.
        pendingnumberHow many changes wait to be sent.
        syncingboolean= falseA sync is running.
        failedboolean= falseThe last sync failed; with onRetry, a Retry button shows.
        labelsPartial<SyncStatusLabels>Replaces any of the words; the counted ones are functions of pending.
        Code
        <SyncStatus
          online={connection.online.value}
          pending={pending.value}
          syncing={syncing.value}
          failed={failed.value}
          onRetry={() => runSync()}
        />

        SavedCopyNotice

        The banner above a list that says it is a copy saved on this device, and when; announced once when the copy arrives.
        • Laptop, signed in on 3 October
        • Phone, signed in on 9 October
        Rendered again 0 times

        "Render again" words the same time differently on purpose. The notice keeps the words it had when the copy arrived, so a screen reader hears it once.

        Props
        PropType and defaultWhat it does
        savedAtDate | number | nullWhen the copy on screen was saved. null while the list is fresh: the notice is then an empty live region.
        formatTime(savedAt: Date) => string= date and clock timeWords the time, once for each copy. Return a clock time, which stays true, not a relative one.
        labelsPartial<SavedCopyNoticeLabels>Replaces title and body(time).
        classstringUtilities for the banner, applied only while it shows. Any other attribute, such as id or data-e2e, lands on the element in both states.
        Code
        <SavedCopyNotice savedAt={tokens.savedAt.value} />
        
        // Reworded and in another language
        <SavedCopyNotice
          savedAt={tokens.savedAt.value}
          formatTime={(savedAt) => timeOfDay.format(savedAt)}
          labels={{
            title: "Copie enregistrée",
            body: (time) => `Serveur injoignable. Copie enregistrée à ${time}.`,
          }}
        />

        ConflictChooser

        The changes the server did not take, each explained in plain words, with Keep mine and Use theirs; inline, or inside a Modal.

        3 changes need your choice

        • Buy milk

          Changed elsewhere while you were offline.

        • Call the plumber

          Deleted elsewhere while you were offline.

        • Rename the shared list

          The server refused this change: you can no longer edit this list.

          Before any conflict arrives

          All conflicts resolved

          Props
          PropType and defaultWhat it does
          conflictsConflictItem[]Each with an id, a label and a reason: version, gone or rejected, which pick the words and the buttons.
          onKeepMine(id: string) => void | PromiseKeeps this person's version; a promise keeps the buttons busy.
          onUseTheirs(id: string) => void | PromiseTakes the server's version, or drops the change.
          labelsConflictChooserLabelOverridesReplaces any of the words, per reason for the buttons.
          Code
          <ConflictChooser
            conflicts={entries.filter((e) => e.conflict).map((e) => ({
              id: String(e.seq),
              label: e.payload.title,
              reason: e.conflict.reason,
            }))}
            onKeepMine={(id) => outbox.keepMine(byId(id)).then(refresh)}
            onUseTheirs={(id) => outbox.useTheirs(byId(id)).then(refresh)}
          />

          UnsentChangesDialog

          Asks before signing out while changes wait to reach the server: send them first, sign out anyway, or stay; then shows the wait in the same dialog.
            Props
            PropType and defaultWhat it does
            unsentnumberChanges still waiting, those that need a choice included.
            conflictsnumberHow many of them the server refused. Once only these are left, the primary becomes Review changes.
            sentnumberSent since Send was pressed, for the progress bar.
            state"ask" | "sending" | "offline" | "retrying"Where the wait stands; the caller owns the sending.
            onSend / onSignOut / onCancel / onReview() => voidOne per action. Escape, the close control and the backdrop call onCancel.
            labelsPartial<UnsentChangesDialogLabels>Replaces any of the words.
            Code
            {asking.value && (
              <UnsentChangesDialog
                unsent={entries.value.length}
                conflicts={entries.value.filter((e) => e.conflict).length}
                sent={sent.value}
                state={sending.value ? (online.value ? "sending" : "offline") : "ask"}
                onSend={startSending}
                onSignOut={signOut}
                onCancel={() => (asking.value = false)}
                onReview={openConflicts}
              />
            )}

            MailedCodeStep

            The step after an app mailed a code: Check your inbox, type the code, Continue, with a resend and the way back to another address.

            onSubmit received: nothing yet

            Props
            PropType and defaultWhat it does
            sentenceComponentChildrenWhere the code went, with the address in bold.
            minutesnumberHow long the code works, said under it.
            onSubmit(code: string) => voidGets the code without whitespace; dashes and underscores stay.
            onResend() => voidSends a new code.
            onChangeEmail() => voidGoes back to the step that asks for the address.
            busyboolean= falseThe code is being checked; a second submit is ignored.
            errorstring | nullWhy the code was refused; the field takes focus with its text selected.
            patternRegExpA whole code's shape. A refused code never reaches onSubmit; a paste or autofill it accepts submits at once.
            readyAtnumberWhen a new code may be sent, for ResendButton's countdown.
            resendingboolean= falseA new code is being sent.
            headingLevel1 | 2 | 3= 2The heading's level; 1 when the step is the page's main content.
            Code
            // data-e2e: mailed-code-step, mailed-code-step-code, mailed-code-step-submit,
            // mailed-code-step-change-email, plus ResendButton's resend-button-*.
            <MailedCodeStep
              sentence={<>We sent a code to <strong>{email}</strong>.</>}
              minutes={15}
              pattern={/^w{3}-w{3}$/} // a paste that matches submits at once
              busy={checking}
              error={error} // "That code is wrong or has expired."
              readyAt={sentAt + 60_000}
              resending={resending}
              onSubmit={(code) => auth.verify(email, code)}
              onResend={() => auth.sendCode(email)}
              onChangeEmail={() => setStep("email")}
            />

            InstallPrompt

            A dismissible offer to install the app: an Install button where the browser has a dialog, and the Share steps on iPhone and iPad.

            A browser with an install dialog

            mode: unavailable, dismissed: false

            iPhone: no dialog, so the steps

            Install this app

            Tap then Add to Home Screen.

            mode: ios, dismissed: false

            Already running installed: nothing to offer

            mode: installed, dismissed: false

            Unmounted by the caller after it mounted hidden

            Press "Fire beforeinstallprompt" to show the card. Then, with focus in the card, press U: the caller unmounts it, and focus goes to the line below.

            mode: unavailable, dismissed: false

            Props
            PropType and defaultWhat it does
            mode"prompt" | "ios" | "installed" | "unavailable"What to offer; createInstallPrompt().mode from preact-signals fits.
            onInstall() => void | PromiseOpens the browser's install dialog.
            onDismiss() => voidHides the offer; the store remembers it through its port.
            Code
            const install = createInstallPrompt({ dismissed: localFlag("install-dismissed") })
            install.watch()
            
            <InstallPrompt
              mode={install.visible.value ? install.mode.value : "unavailable"}
              onInstall={install.install}
              onDismiss={install.dismiss}
            />

            PushSettings

            The settings block that says whether this device gets notifications and turns them on or off. The browser's permission prompt opens only after the press.

            A browser where the person allows it

            Notifications

            Checking this device…

            prompts shown: 0, browser subscribed: no, server has: 0

            A browser where the person blocks it

            Notifications

            Checking this device…

            prompts shown: 0, browser subscribed: no, server has: 0

            An app whose key arrives after the block is drawn

            Notifications

            Checking this device…

            visibility listeners: 0

            iPhone or iPad, in a browser tab

            Notifications

            On an iPhone or iPad, notifications work once the app is on your home screen. Tap Share, then Add to Home Screen, and open the app from there.

            A browser without push

            Notifications

            This browser can't show notifications.

            A server without keys

            Notifications

            Notifications aren't set up for this app yet.

            Props
            PropType and defaultWhat it does
            status"checking" | "unsupported" | "needs-install" | "unavailable" | "blocked" | "off" | "on"What to show; usePushSubscription from preact-signals returns it.
            busy / failed"enable" | "disable" | "test" | nullThe action running now, and the one that failed last.
            onEnable / onDisable() => voidTurn notifications on or off for this device.
            onTest() => voidOptional; adds Send a test notification while notifications are on.
            labelsPartial<PushSettingsLabels>Every sentence and button name, each with an English default.
            Code
            const push = usePushSubscription({
              publicKey, // the server's VAPID public key; null when it has none
              save: (subscription) => server.savePushSubscription(subscription),
              remove: (endpoint) => server.removePushSubscription(endpoint),
              sendTest: () => server.sendTestPush(),
            })
            
            <PushSettings {...push} />

            SettingsLayout

            A settings page with sub-pages: a sticky nav beside the open section from md up, and on a phone the list first, then one section with a link back.

            Profile

            Ada Lovelaceada@example.com

            Routed to: none

            Props
            PropType and defaultWhat it does
            titlestringThe page title, drawn by PageHeader.
            hrefstringThe list's own address: the back link's.
            sectionsreadonly SettingsSection[]id, label, href, and optional description, value (the phone list's muted state) and tone: "danger" (last, after a divider).
            currentstring | nullThe open section's id; null makes the list the page on a phone.
            defaultSectionstringThe section shown from md up while current is null: its heading and a current-looking item, by CSS only.
            navigate(href: string) => voidThe app's router; a plain click on a nav item or the back link calls it.
            introComponentChildrenDrawn under the title, such as who is signed in.
            Code
            <SettingsLayout
              title="Profile"
              href="/profile"
              sections={[
                { id: "account", label: "Account", href: "/profile/account" },
                { id: "security", label: "Sign-in and security", href: "/profile/security", value: "Two-factor off" },
                { id: "danger", label: "Delete account", href: "/profile/danger", tone: "danger" },
              ]}
              current={sectionId ?? null}
              defaultSection="account"
              navigate={(href) => router.navigate(href)}
            >
              {sectionId === "security" ? <SecuritySettings /> : <AccountSettings />}
            </SettingsLayout>

            Route changes

            useRouteAnnouncer from @spy4x/preact-system moves focus to the new page's heading, or to the page when it has none, and names the page in the tab title. A change of query string alone keeps focus where it is.

            pageHeading() is the same target without a route change: this guide page's heading, or its main without one. Pass it as returnFocus to a component that leaves the page.

            Inbox

            Showing /inbox

            @spy4x/preact-crud

            CRUD

            The list page and the add and edit forms of a resource, built from props and slots over your own store. Every card here runs on a small in-memory store.

            CRUD

            The list page and the add and edit forms of a resource, built from props and slots over your own store. Every card here runs on a small in-memory store.

            CrudList

            A resource list read from a store: title, count, search box, Active and Archived filter, an add link, and a table whose header, cells and row actions you supply.

            Teams3

            NameMembersActions
            Design4
            Edit
            Support0
            Edit
            Platform7
            Edit
            Props
            PropType and defaultWhat it does
            titlestringThe list's heading.
            storeCrudListStore<M>The active and archived rows and their load state; pass rows, a plain signal, instead for a list with no archive.
            match(row: M, word: string) => booleanWhether a row matches one word of the search; every word must match.
            headerComponentChildrenThe table's header cells.
            row(row: M) => ComponentChildrenOne row's cells.
            actions(row: M) => ComponentChildren= no actions columnThe row's actions, usually a Dropdown menu.
            addHrefstring= no add linkWhere the add link leads; canAdd decides whether it shows.
            searchDelaynumber= 300Milliseconds the search waits after the last key; Enter and the Search button search at once.
            searchLabelstring= "Search"The Search button's name, for a screen reader and the tooltip.
            searchFieldLabelstring= the placeholderThe search field's name for a screen reader.
            headingLevel1 | 2 | 3 | 4 | 5 | 6= 1The level of the title's heading; pick another when the page has its <h1>.
            pageSizenumber= every rowRows a page; with it a pager sits under the table. paginationLabels words the pager.
            pageSignal<number>= a signal of the list's ownThe page number, so the address can own it. The list writes it on a press of the pager, and sets it to 1 together with its own search or status change; a query changed from outside leaves it alone.
            loadingReadonlySignal<boolean>= noneWith rows, true while the rows are on their way: the empty state waits. A store says so itself, through its list load.
            emptyTitlestring= "Nothing here yet"Shown in place of the table when the list has no row; emptyDescription adds a second line.
            noMatchTitlestring= "Nothing matches your search"Shown in place of the table when the search leaves no row; noMatchDescription adds a second line.
            Code
            <CrudList
              title="Teams"
              store={teamStore}
              match={(row, word) => search(row.name, word)}
              query={query}
              searchLabel="Search teams"
              searchFieldLabel="Team name"
              pageSize={25}
              emptyTitle="No teams yet"
              noMatchTitle="No team has that name"
              header={<th scope="col">Name</th>}
              row={(row) => <td>{row.name}</td>}
              actions={(row) => (
                <Dropdown
                  trigger={<IconEllipsisVertical />}
                  triggerLabel={`Actions for ${row.name}`}
                  menuLabel={`Actions for ${row.name}`}
                >
                  <DropdownItem href={`/teams/${row.id}/edit`}>Edit</DropdownItem>
                </Dropdown>
              )}
            />

            CrudEditor

            The add and edit form for one row: it loads the row, validates every change, and puts your fields above Cancel and Save.

            Add a team

            Must differ from Name

            A notice slot sits between the fields and the footer.

            onCreated received: nothing created yet

            the create port logged: nothing yet

            rows in the store: 4 (4 before the first save)

            team #1 in the store: "Design", 4 members

            the update port logged: nothing yet

            Edit a team

            Props
            PropType and defaultWhat it does
            storeCrudEditorStore<M>Where the row is read from and where create and update go.
            mode"add" | "edit"Whether the form creates a row or edits the one editId names.
            entitystringThe row's kind, used in the default title and messages.
            cancelHrefstringWhere Cancel leads; a read-only viewer keeps this link.
            blankMThe row an add form starts from.
            canChange() => boolean= may changeWhether the viewer may change the row; false disables the fields and leaves Cancel alone in the footer.
            saveErrorLabelstring= "Couldn't save"What a failed save is introduced with, before the store's own message.
            saveLabelstring= "Save"The Save button's label; cancelLabel words Cancel.
            notFoundLabelstring= `Couldn't find this <entity>`Shown when mode="edit" names a row the store does not hold.
            headingLevel1 | 2 | 3 | 4 | 5 | 6= 1The level of the title's heading; pick another when the page has its <h1>.
            schemaType= noneAn arktype schema every change is checked against.
            validate(value, vl) => ValidationModel= noneYour own checks, run after the schema.
            archiveArchiveConfig<M>= no archive checkboxAdds the archive checkbox and what blocks it; its blockedLabel replaces the sentence above the list of what has to be archived first.
            children({ vm, vl }) => ComponentChildrenThe field rows, given the model and validation signals.
            Code
            <CrudEditor
              store={teamStore}
              mode="add"
              blank={blankTeam}
              schema={teamBaseSchema}
              entity="Team"
              cancelHref="/teams"
              saveErrorLabel="The team was not saved"
              archive={{}}
              onCreated={(row) => navigate(`/teams/${row.id}/edit`)}
            >
              {({ vm, vl }) => <TextField vm={vm} vl={vl} name="name" label="Name" />}
            </CrudEditor>

            AssociationEditor

            The editor for a row that joins two others, which blocks Save when the new row would duplicate one that exists or was removed.

            Add a supplier

            Type Paper supplier for a duplicate, or Ink supplier for one that was removed.

            The conflict port scans 3 rows, 1 of them removed. A save adds one.

            The store was asked to delete 0 times and to restore 0 times.

            Props
            PropType and defaultWhat it does
            storeCrudAssociationStore<M>The editor's store plus every row, removed ones included, and delete and undelete.
            blankMThe row an add form starts from.
            mode"add" | "edit"Whether the form creates a row or edits the one editId names.
            entitystringThe row's kind, used in the default title and messages.
            cancelHrefstringWhere Cancel leads.
            children({ vm, vl }) => ComponentChildrenThe field rows, given the model and validation signals.
            conflict(row: M, rows: M[]) => M | undefinedThe existing row the new one would duplicate, if any.
            conflictFieldkeyof MThe field the duplicate's message is shown under.
            conflictRemovedMessagestring= an English sentenceWhat to say when the duplicate was removed and can be restored.
            confirmDeletestring= an English sentenceThe question the dialog asks before Delete removes the row; confirmRestore is its twin.
            confirmDeleteTitlestring= Delete <entity>?The dialog's heading; confirmRestoreTitle is its twin.
            confirmCancelLabelstring= "Cancel"The label of the dialog's cancelling action.
            confirmCloseLabelstring= "Close"The name of the cross in the dialog's corner.
            deleteErrorLabelstring= "Couldn't delete"What a refused Delete is introduced with in its dialog, before the store's own message; restoreErrorLabel is its twin.
            removedLabel(ago: string) => string= Removed <ago>The line beside Restore on a removed row.
            restoreHintstring= an English sentenceThe sentence beside the offer to restore a removed duplicate.
            Code
            <AssociationEditor
              store={associationStore}
              mode="add"
              blank={blankAssociation}
              entity="Supplier association"
              cancelHref="/suppliers"
              confirmDeleteTitle="Detach this supplier?"
              confirmDelete="It can be attached again later."
              confirmCancelLabel="Keep it as it is"
              deleteErrorLabel="The supplier was not detached"
              conflictField="supplierId"
              conflict={(row, all) => all.find((entry) => entry.supplierId === row.supplierId)}
              conflictMessage="That supplier is already attached."
              conflictRemovedMessage="It was attached before and removed — restore it instead."
            >
              {({ vm, vl }) => <SelectField vm={vm} vl={vl} name="supplierId" label="Supplier" options={…} />}
            </AssociationEditor>

            TextField

            A labelled text input bound to one field of a model signal, which writes the trimmed value when it loses focus.

            name: Design · members: 4 · parentId: 1 · hiring: off · notes: (empty)

            Props
            PropType and defaultWhat it does
            vmSignal<M>The model the row reads and writes.
            vlReadonlySignal<ValidationModel<M>>The validation model whose issues the row shows.
            namekeyof MThe field of the model this row edits.
            labelstringThe label, which names the control.
            hintComponentChildrenHelper text under the control.
            spanstring= "sm:col-span-3"The row's cell in the editor's six-column grid.
            Code
            <TextField vm={vm} vl={vl} name="name" label="Name" placeholder="Design" span="sm:col-span-2" />

            NumberField

            The numeric field row, which writes 0 for an empty or half-typed box instead of NaN.

            Clear the box or type 1e, then click away

            members in the model: 4

            Code
            <NumberField vm={vm} vl={vl} name="members" label="Members" hint="Commits 0 for an empty box" />

            SelectField

            A labelled select whose options are data, so a numeric id stays a number in the model.

            parentId in the model: 2 (number)

            Code
            <SelectField
              vm={vm}
              vl={vl}
              name="parentId"
              label="Parent team"
              options={[{ value: 1, label: "Design" }, { value: 2, label: "Support" }]}
              placeholder="— none —"
            />

            CheckboxField

            A labelled checkbox bound to one boolean field of the model.

            hiring in the model: on

            Code
            <CheckboxField vm={vm} vl={vl} name="hiring" label="Hiring" />

            TextareaField

            The multi-line field row, which writes when it loses focus, like TextField.

            notes in the model: Runs the night shift.

            Code
            <TextareaField vm={vm} vl={vl} name="notes" label="Notes" rows={3} />

            FieldIssues

            The messages of one field, one per issue type, so your own checks show beside the schema's.

            The same issues through a custom renderIssue:

            Code
            <FieldIssues vl={vl} name="name" />
            
            // Or with your own rendering, and the payload the issue carries:
            <FieldIssues
              vl={vl}
              name="name"
              renderIssue={(issue, type) => <p>{type}: {issue.message}</p>}
            />

            DeletionValidation

            The rows that still point at a row and so stop it being archived; with none, it shows nothing.

            An empty list shows nothing. Re-rendered 0 times for an unrelated reason.

            Props
            PropType and defaultWhat it does
            dependencies{ kind: string; values: { title: string; url: string }[] }[]What still points at the row, grouped by kind; with none, nothing is shown.
            modelstringThe entity's name in the default sentence above the list.
            labelstring= `Archive these first, then you can archive this <model>:`The sentence above the list, in the app's own words.
            Code
            <DeletionValidation
              dependencies={[
                { kind: "Projects", values: [{ title: "Launch plan", url: "/projects/1/edit" }] },
              ]}
              model="Team"
              label="This team is still used by:"
            />

            CrudList, searched and paged from the address bar

            CrudList's query and page are signals the caller may own. Here they are ?q= and ?page=, bound through useUrlFilters: an address with both opens on that page, and a search typed into the list goes back to page 1 in one step of the browser's history.

            Teams6

            Name
            Platform
            Design