Skip to content

Build pages with the UI kit

Redac components, pages and data states.

The UI kit is the set of components, styles and fonts that Redac gives to app pages. A page that uses it looks like Redac, follows the light or dark theme, and works on the desktop, the phone and Redac Web. Build every page with it.

  • Import: import { html, startApp, Screen } from './_kit/1/ui.js'; From a file in a sub-folder, use '../_kit/1/ui.js'.
  • Opt-in: an app uses the kit when one of its .js files contains the text _kit/1/ui.js. Redac then adds the kit files to the package. Never write files under _kit/; a package with such a file is refused.
  • Components: Preact 10 with html tagged templates (htm). There is no build step. Write <${Button}>Save</${Button}>, not JSX.
  • Version: version 1 never gets a breaking change.

Rules

  1. Build every page with the kit. Use its parts for layout, controls, lists, forms, and loading, empty and error states.
  2. Write custom CSS only for something the kit has no part for. Then use a style object with the --rk-* tokens, for example style=${{ color: 'var(--rk-text-muted)' }}. Do not add <style>, <link> or innerHTML; the page security policy blocks them.
  3. Put each screen in a Screen. Make each screen a small component with its own route. In a big app, put each screen in its own file and keep data calls in one shared file.
  4. Give each screen at most one primary button. Give each IconButton a label. Ask with confirm({ danger: true }) before a delete.
  5. Read data with useCall and draw it with Async. Write in an event handler with client.cap.call, never with useCall. After a write, go to the next screen or call reload().
  6. Do not set fixed widths. Stack, Grid, List and Table fit a phone. Before install, read the page code for faults in layout and data calls. Ask the owner to check the pages, controls and data after install, on the desktop and the phone. Chat cannot open app pages before install.

Start a page

mount returns startApp(root, client, options). It adds the styles and fonts, gives client to all components, shows the first screen, and returns the cleanup function.

import { html, startApp, navigate, Screen, Section, Text, Button } from './_kit/1/ui.js';

function Home() {
  return html`
    <${Screen} title="Hello">
      <${Section} title="Welcome">
        <${Text}>This page uses the Redac UI kit.</${Text}>
        <${Button} variant="primary" onClick=${() => navigate('/about')}>About</${Button}>
      </${Section}>
    </${Screen}>
  `;
}

function About() {
  return html`<${Screen} title="About"><${Text}>Made in Redac.</${Text}></${Screen}>`;
}

export function mount(root, client) {
  return startApp(root, client, { title: 'Hello', routes: { '/': Home, '/about': About } });
}

startApp options:

OptionMeaning
routesPath patterns to screen components, for example { '/item/:id': Item }.
startThe first path. Default /.
titleThe app title. A Screen without title shows it.

A page can also draw with the kit render and no startApp. The kit render adds the kit styles and fonts too. Then the page has no router and no useClient, and mount must return () => render(null, root).

Router

The router keeps screens in memory, with a back stack. A page has no URL.

ExportMeaning
navigate(path, { replace })Opens a screen. replace: true swaps the current screen (use it after a save).
back()Goes back one screen. Returns false when there is none.
canGoBack()True when there is an earlier screen.
useRoute(){ path, params } of the current screen.
Link<${Link} to="/item/42">Open</${Link}>: a link to a screen.
  • A screen component gets { path, params } as props. params has the :name parts of the pattern and the query, for example /item/42?tab=log gives { id: '42', tab: 'log' }.
  • The first pattern that matches wins. Put '/item/new' before '/item/:id'.
  • Screen shows a Back button when canGoBack() is true.
  • An unknown path shows "Page not found". An error in a screen shows an error state with Try again; the other screens stay.

Data

The hooks call the SDK client of the page. Each returns { data, error, loading, reload }. They run again when the capability or the arguments change, they stop on unmount, and they ignore late results. They wait for client.ready before the first call and stay loading until then. If you call client.cap yourself before a user action, first await client.ready.

reload() runs the call again. It returns a Promise that settles when the new data or error arrives. The Promise never rejects: read the error in error.

HookCallsdata
useCall(capability, args)client.cap.call (read entries only)The result
useWatch(capability, args)client.cap.watch (subscription entries)The newest event
useLive(capability, args)client.cap.live (shared live query)The newest value
useClient()—The SDK client itself

The hooks are for reads only. Never put a write (an entry with a requestKey) in useCall: it calls again each time its args change, and a new key on each render makes it call again and again. useCall logs a warning for a requestKey.

A null capability waits and calls nothing; the state stays loading, so Async shows Loading. Use it when an argument is not known yet.

Async draws the state: a spinner while loading, an error with Try again, the empty state, or else its child function with the data.

PropMeaning
stateThe result of useCall, useWatch or useLive.
isEmpty(data) => boolean. Default: null, undefined or an empty array.
emptyA node or a function for the empty state. Default: "Nothing here yet".
childrenOne function that gets the data and returns the content.

database.list returns { records, nextCursor }, not an array. Give it isEmpty=${(page) => page.records.length === 0}.

function NotesExample() {
  const notes = useCall('database.list', { connection: 'database:demo:notes', limit: 100 });
  return html`
    <${Async}
      state=${notes}
      isEmpty=${(page) => page.records.length === 0}
      empty=${html`<${EmptyState} title="No notes yet" />`}
    >
      ${(page) => html`
        <${List} label="Notes">
          ${page.records.map(
            (note) => html`<${ListItem} key=${note.id} title=${note.body.title} />`,
          )}
        </${List}>
      `}
    </${Async}>
  `;
}

Write in an event handler with the client from useClient(), after await client.ready. A conflict is a returned state, not an error:

function AddNoteExample() {
  const client = useClient();
  const add = async () => {
    await client.ready;
    const result = await client.cap.call('database.put', {
      connection: 'database:demo:notes',
      id: crypto.randomUUID(),
      expectedRevision: 0,
      requestKey: crypto.randomUUID(),
      body: { title: 'New note' },
    });
    if (result.state !== 'written') return toast('Not saved. Try again.', { tone: 'danger' });
    toast('Saved', { tone: 'success' });
  };
  return html`<${Button} icon="plus" onClick=${add}>Add note</${Button}>`;
}

useRefresh

useRefresh(handler) makes the screen refreshable. Pull to refresh runs handler and waits for it. Without it, pull to refresh loads the page again. Give it the reload of the useCall or useLive of the screen. await reload() waits for the new data, so the refresh shows until the data is on the screen:

function RefreshExample() {
  const notes = useCall('database.list', { connection: 'database:demo:notes', limit: 100 });
  const { reload } = notes;
  useRefresh(async () => {
    await reload();
  });
  return html`<${Async} state=${notes}>${(page) => html`<${Text}>${page.records.length}</${Text}>`}</${Async}>`;
}

Layout

PartProps
Screentitle, actions (header buttons), footer (a bar at the bottom), back (force on/off)
Sectiontitle, description, actions
Cardtitle; onClick and label make the card a button
Stackdirection (column, row), gap (none, xs, sm, md, lg, xl), align, justify, wrap
Gridcolumns (a fixed number) or min (smallest column width, default 240px), gap
Divider—

Screen has a sticky header, a scrolling body and safe-area padding. Below 640 px: a Grid with columns becomes one column, a Grid with min keeps as many columns as fit (min="140px" gives two on a phone), and a Grid of Stat parts shows two per row.

On the iPhone and Redac Web the host draws the header. title, the back button and up to two IconButton actions with a header icon go to the host header; other actions stay in a row at the top of the page. The header icons are: plus, pencil, trash-2, search, settings, share-2, filter, refresh-cw, check, x, ellipsis, star, heart, download, upload, calendar, list, layout-grid, bell, user, info, house, copy.

function LayoutExample() {
  return html`
    <${Screen}
      title="Layout"
      actions=${html`<${IconButton} icon="settings" label="Settings" />`}
      footer=${html`<${Button} variant="primary">Save</${Button}>`}
    >
      <${Section} title="Cards" description="Two columns, one on a phone.">
        <${Grid} columns=${2}>
          <${Card} title="First">Some text.</${Card}>
          <${Card} title="Second" onClick=${() => {}}>A card that is a button.</${Card}>
        </${Grid}>
      </${Section}>
      <${Divider} />
      <${Stack} direction="row" gap="sm" wrap>
        <${Badge}>One</${Badge}>
        <${Badge}>Two</${Badge}>
      </${Stack}>
    </${Screen}>
  `;
}

Content

PartProps
Textsize (sm, md, lg), tone (default, muted, dim, info, success, warning, danger), weight (normal, medium, bold), mono, truncate, as (p, span, div, label, strong, small)
Headinglevel (1, 2, 3; default 2)
Badgetone (neutral, info, success, warning, danger), icon
Iconname, size (default 16), label (only when the icon alone has meaning)
Statlabel, value, hint, icon
EmptyStatetitle, description, icon (default inbox), action
ErrorStatetitle, error (its message shows), onRetry (shows Try again)
Spinnersize, label (default "Loading")
Skeletonlines, width, height
function ContentExample() {
  return html`
    <${Stack}>
      <${Heading}>Today</${Heading}>
      <${Text} tone="muted" size="sm">Updated 2 minutes ago</${Text}>
      <${Grid}>
        <${Stat} label="Open" value="12" hint="+3 this week" icon="list" />
        <${Stat} label="Done" value="40" icon="circle-check" />
      </${Grid}>
      <${Stack} direction="row" gap="sm" wrap>
        <${Badge} tone="success" icon="check">Paid</${Badge}>
        <${Badge} tone="warning">Due soon</${Badge}>
        <${Badge} tone="danger">Late</${Badge}>
      </${Stack}>
      <${Icon} name="star" size=${20} label="Favorite" />
      <${EmptyState} icon="inbox" title="No messages" description="New messages show here." />
      <${ErrorState} error=${new Error('The list did not load.')} onRetry=${() => {}} />
      <${Spinner} />
      <${Skeleton} lines=${3} />
    </${Stack}>
  `;
}

Icons: plus, minus, x, check, chevron-left, chevron-right, chevron-down, chevron-up, arrow-left, arrow-right, search, settings, trash, pencil, copy, refresh-cw, ellipsis, ellipsis-vertical, external-link, info, triangle-alert, circle-alert, circle-check, circle-x, calendar, clock, user, users, house, star, heart, bell, mail, funnel, download, upload, file-text, folder, image, link, lock, eye, eye-off, tag, shopping-cart, list, layout-grid, play, pause, sun, moon, inbox, send, map-pin. An unknown name draws nothing and logs a warning.

Controls

PartProps
Buttonvariant (primary, secondary, ghost, danger; default secondary), size (sm, md), icon, loading, full, type, disabled, onClick
IconButtonicon, label (required), variant (default ghost), size
Inputlabel, hint, error, required, value, onInput or onChange (event), and input attributes
Textareathe same as Input, rows (default 4)
Selectthe same as Input, options (strings or { value, label, disabled }), placeholder
Fieldlabel, hint, error, required: a label around one control that has no label
Togglelabel, description, checked, onChange (event: read event.currentTarget.checked)
Checkboxthe same as Toggle
Segmentedoptions (strings or { value, label, icon }), value, onChange (gets the value), label (required), full
SearchFieldvalue, onInput (event), label (default "Search"), placeholder

Text controls call onInput and onChange on each keystroke. Read event.currentTarget.value.

function ControlsExample() {
  const [name, setName] = useState('');
  const [size, setSize] = useState('m');
  const [view, setView] = useState('list');
  const [alerts, setAlerts] = useState(true);
  const [query, setQuery] = useState('');
  return html`
    <${Stack}>
      <${Stack} direction="row" gap="sm" wrap>
        <${Button} variant="primary" icon="plus">Add</${Button}>
        <${Button}>Cancel</${Button}>
        <${Button} variant="ghost">More</${Button}>
        <${Button} variant="danger" icon="trash">Delete</${Button}>
        <${Button} loading>Saving</${Button}>
        <${IconButton} icon="pencil" label="Edit" />
      </${Stack}>
      <${SearchField} value=${query} onInput=${(e) => setQuery(e.currentTarget.value)} />
      <${Input}
        label="Name"
        hint="As on your card"
        value=${name}
        onInput=${(e) => setName(e.currentTarget.value)}
      />
      <${Select}
        label="Size"
        value=${size}
        options=${[
          { value: 's', label: 'Small' },
          { value: 'm', label: 'Medium' },
        ]}
        onChange=${(e) => setSize(e.currentTarget.value)}
      />
      <${Segmented} label="View" options=${['list', 'grid']} value=${view} onChange=${setView} />
      <${Toggle}
        label="Alerts"
        description="Show a notice when a price drops"
        checked=${alerts}
        onChange=${(e) => setAlerts(e.currentTarget.checked)}
      />
      <${Checkbox} label="I agree" checked=${false} onChange=${() => {}} />
    </${Stack}>
  `;
}

Forms

useForm({ initial, validate, onSubmit }) keeps the values and errors. form.field(name) gives the props for one control: spread it into Input, Textarea, Select, Toggle or Checkbox. A boolean field in initial gives checked props.

  • validate(values) returns { field: message }. An empty message is no error. It runs on submit, then on each change and blur.
  • onSubmit(values) runs when the values are valid. If it throws, its message shows above the form. A Button type="submit" shows a spinner while it runs.
  • The form API also has values, errors, submitting, error, set(name, value), submit() and reset(values).
function FormExample() {
  const form = useForm({
    initial: { title: '', kind: 'book', done: false },
    validate: (values) => ({ title: values.title.trim() ? '' : 'Enter a title.' }),
    onSubmit: async (values) => {
      toast(`Saved ${values.title}`, { tone: 'success' });
    },
  });
  return html`
    <${Form} form=${form}>
      <${Input} label="Title" required ...${form.field('title')} />
      <${Select} label="Kind" options=${['book', 'film']} ...${form.field('kind')} />
      <${Toggle} label="Done" ...${form.field('done')} />
      <${Button} type="submit" variant="primary" full>Save</${Button}>
    </${Form}>
  `;
}

Lists and tables

PartProps
Listlabel; children are ListItem parts
ListItemtitle, description, leading (an icon name or a node), meta, trailing, to (a path) or onClick
Tablecolumns ({ key, label, render, align }), rows, rowKey (default id), onRowClick, label, empty

A ListItem with to or onClick shows a chevron. Below 640 px a Table shows as a List: the first column is the title and the other columns show under it.

function ListExample() {
  const rows = [
    { id: 'a1', name: 'Milk', price: 2.5 },
    { id: 'b2', name: 'Bread', price: 3 },
  ];
  return html`
    <${Stack}>
      <${List} label="Groceries">
        <${ListItem} leading="shopping-cart" title="Milk" description="2 liters" meta="$2.50" to="/item/a1" />
        <${ListItem} title="Alerts" trailing=${html`<${Badge} tone="info">On</${Badge}>`} />
      </${List}>
      <${Table}
        label="Prices"
        rows=${rows}
        columns=${[
          { key: 'name', label: 'Name' },
          {
            key: 'price',
            label: 'Price',
            align: 'right',
            render: (row) => `$${row.price.toFixed(2)}`,
          },
        ]}
        onRowClick=${(row) => navigate(`/item/${row.id}`)}
      />
    </${Stack}>
  `;
}

Overlays

ExportMeaning
Sheetopen, onClose, title, footer. From the bottom below 640 px, a dialog when wider.
confirm(options){ title, message, confirmLabel, cancelLabel, danger } → Promise<boolean>
toast(text, options){ tone, duration } (default 4000 ms; 0 keeps it). Returns a function that hides it.

Escape, Close and a click outside call onClose. Focus moves into the sheet and back when it closes.

function OverlayExample() {
  const [open, setOpen] = useState(false);
  const remove = async () => {
    const ok = await confirm({
      title: 'Delete this item?',
      message: 'You cannot undo this.',
      confirmLabel: 'Delete',
      danger: true,
    });
    if (ok) toast('Deleted', { tone: 'success' });
  };
  return html`
    <${Stack} direction="row" gap="sm" wrap>
      <${Button} onClick=${() => setOpen(true)}>Open sheet</${Button}>
      <${Button} variant="danger" onClick=${remove}>Delete</${Button}>
      <${Sheet}
        open=${open}
        onClose=${() => setOpen(false)}
        title="Filters"
        footer=${html`<${Button} variant="primary" onClick=${() => setOpen(false)}>Done</${Button}>`}
      >
        <${Toggle} label="Only in stock" checked=${true} onChange=${() => {}} />
      </${Sheet}>
    </${Stack}>
  `;
}

Many screens: list, detail and edit

A big app is a set of small screens. This page has a list, a detail and an edit screen on one collection. The manifest declares the collection books with title and author strings and the actions database.read, database.write and database.delete. In a real app, put each screen in its own file (screens/list.js, …) and the data calls in data.js.

import {
  html,
  startApp,
  navigate,
  back,
  useClient,
  useCall,
  useForm,
  Async,
  Screen,
  Card,
  Text,
  Stack,
  List,
  ListItem,
  EmptyState,
  Form,
  Input,
  Button,
  IconButton,
  confirm,
  toast,
} from './_kit/1/ui.js';

const BOOKS = 'database:reading-list:books';
const newKey = () => crypto.randomUUID();

function Books() {
  const books = useCall('database.list', { connection: BOOKS, limit: 100 });
  const add = () => navigate('/books/new');
  return html`
    <${Screen} actions=${html`<${IconButton} icon="plus" label="Add book" onClick=${add} />`}>
      <${Async}
        state=${books}
        isEmpty=${(page) => page.records.length === 0}
        empty=${html`<${EmptyState}
          title="No books yet"
          action=${html`<${Button} variant="primary" onClick=${add}>Add book</${Button}>`}
        />`}
      >
        ${(page) => html`
          <${List} label="Books">
            ${page.records.map(
              (book) =>
                html`<${ListItem}
                  key=${book.id}
                  title=${book.body.title}
                  description=${book.body.author}
                  to=${`/books/${book.id}`}
                />`,
            )}
          </${List}>
        `}
      </${Async}>
    </${Screen}>
  `;
}

function Book({ params }) {
  const client = useClient();
  const book = useCall('database.get', { connection: BOOKS, id: params.id });
  const remove = async (record) => {
    if (!(await confirm({ title: 'Delete this book?', confirmLabel: 'Delete', danger: true })))
      return;
    await client.cap.call('database.delete', {
      connection: BOOKS,
      id: record.id,
      expectedRevision: record.revision,
      requestKey: newKey(),
    });
    back();
  };
  return html`
    <${Screen} title="Book">
      <${Async} state=${book} isEmpty=${(data) => data.record == null}>
        ${({ record }) => html`
          <${Card} title=${record.body.title}>
            <${Text} tone="muted">${record.body.author}</${Text}>
          </${Card}>
          <${Stack} direction="row" gap="sm">
            <${Button} icon="pencil" onClick=${() => navigate(`/books/${record.id}/edit`)}>Edit</${Button}>
            <${Button} variant="danger" icon="trash" onClick=${() => remove(record)}>Delete</${Button}>
          </${Stack}>
        `}
      </${Async}>
    </${Screen}>
  `;
}

// The form needs its first values at once, so the screen loads the record and
// then draws the form.
function BookForm({ record }) {
  const client = useClient();
  const form = useForm({
    initial: { title: record?.body.title ?? '', author: record?.body.author ?? '' },
    validate: (values) => ({
      title: values.title.trim() ? '' : 'Enter a title.',
      author: values.author.trim() ? '' : 'Enter an author.',
    }),
    onSubmit: async (values) => {
      const result = await client.cap.call('database.put', {
        connection: BOOKS,
        id: record?.id ?? crypto.randomUUID(),
        expectedRevision: record?.revision ?? 0,
        requestKey: newKey(),
        body: { title: values.title.trim(), author: values.author.trim() },
      });
      if (result.state === 'conflict') throw new Error('The book changed. Go back and try again.');
      toast('Saved', { tone: 'success' });
      // An edit goes back to the detail. A new book replaces this screen.
      if (record) back();
      else navigate(`/books/${result.record.id}`, { replace: true });
    },
  });
  return html`
    <${Form} form=${form}>
      <${Input} label="Title" required ...${form.field('title')} />
      <${Input} label="Author" required ...${form.field('author')} />
      <${Button} type="submit" variant="primary" full>Save</${Button}>
    </${Form}>
  `;
}

function NewBook() {
  return html`<${Screen} title="Add book"><${BookForm} /></${Screen}>`;
}

function EditBook({ params }) {
  const book = useCall('database.get', { connection: BOOKS, id: params.id });
  return html`
    <${Screen} title="Edit book">
      <${Async} state=${book} isEmpty=${(data) => data.record == null}>
        ${({ record }) => html`<${BookForm} record=${record} />`}
      </${Async}>
    </${Screen}>
  `;
}

export function mount(root, client) {
  return startApp(root, client, {
    title: 'Reading list',
    routes: {
      '/': Books,
      '/books/new': NewBook,
      '/books/:id/edit': EditBook,
      '/books/:id': Book,
    },
  });
}

examples/ui-kit-gallery shows every part on its own screen, with a list → detail → edit flow on a real collection.

All exports

  • Core (Preact): html, h, Fragment, render, useState, useEffect, useMemo, useRef, useCallback, signal, computed, effect, batch, useSignal, useComputed.
  • App and router: startApp, useClient, useRoute, navigate, back, canGoBack, Link.
  • Data: useCall, useWatch, useLive, Async, useRefresh.
  • Layout: Screen, Section, Card, Stack, Grid, Divider.
  • Content: Text, Heading, Badge, Icon, Stat, EmptyState, ErrorState, Spinner, Skeleton.
  • Controls: Button, IconButton, Field, Input, Textarea, Select, Toggle, Checkbox, Segmented, SearchField.
  • Forms: useForm, Form.
  • Lists: List, ListItem, Table.
  • Overlays: Sheet, confirm, toast.

Tokens

Use these in a style object as var(--rk-…). They change with the theme.

  • Colors: --rk-bg, --rk-surface, --rk-surface-hover, --rk-border, --rk-border-subtle, --rk-text, --rk-text-muted, --rk-text-dim, --rk-accent, --rk-accent-soft, --rk-green-text, --rk-green-soft, --rk-amber-text, --rk-amber-soft, --rk-red-text, --rk-red-soft.
  • Space: --rk-space-1 (4 px) to --rk-space-6 (32 px).
  • Radius: --rk-radius-sm, --rk-radius-md, --rk-radius-card.
  • Text: --rk-text-body (15 px), --rk-text-small, --rk-font-sans, --rk-font-mono.

Synced from the Redac Desktop developer guide on 2026-10-30 · SDK contract version 23