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
.jsfiles 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
htmltagged templates (htm). There is no build step. Write<${Button}>Save</${Button}>, not JSX. - Version: version 1 never gets a breaking change.
Rules
- Build every page with the kit. Use its parts for layout, controls, lists, forms, and loading, empty and error states.
- Write custom CSS only for something the kit has no part for. Then use a
styleobject with the--rk-*tokens, for examplestyle=${{ color: 'var(--rk-text-muted)' }}. Do not add<style>,<link>orinnerHTML; the page security policy blocks them. - 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. - Give each screen at most one
primarybutton. Give eachIconButtonalabel. Ask withconfirm({ danger: true })before a delete. - Read data with
useCalland draw it withAsync. Write in an event handler withclient.cap.call, never withuseCall. After a write, go to the next screen or callreload(). - Do not set fixed widths.
Stack,Grid,ListandTablefit 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:
| Option | Meaning |
|---|---|
routes | Path patterns to screen components, for example { '/item/:id': Item }. |
start | The first path. Default /. |
title | The 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.
| Export | Meaning |
|---|---|
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.paramshas the:nameparts of the pattern and the query, for example/item/42?tab=loggives{ id: '42', tab: 'log' }. - The first pattern that matches wins. Put
'/item/new'before'/item/:id'. Screenshows a Back button whencanGoBack()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.
| Hook | Calls | data |
|---|---|---|
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.
| Prop | Meaning |
|---|---|
state | The result of useCall, useWatch or useLive. |
isEmpty | (data) => boolean. Default: null, undefined or an empty array. |
empty | A node or a function for the empty state. Default: "Nothing here yet". |
| children | One 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
| Part | Props |
|---|---|
Screen | title, actions (header buttons), footer (a bar at the bottom), back (force on/off) |
Section | title, description, actions |
Card | title; onClick and label make the card a button |
Stack | direction (column, row), gap (none, xs, sm, md, lg, xl), align, justify, wrap |
Grid | columns (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
| Part | Props |
|---|---|
Text | size (sm, md, lg), tone (default, muted, dim, info, success, warning, danger), weight (normal, medium, bold), mono, truncate, as (p, span, div, label, strong, small) |
Heading | level (1, 2, 3; default 2) |
Badge | tone (neutral, info, success, warning, danger), icon |
Icon | name, size (default 16), label (only when the icon alone has meaning) |
Stat | label, value, hint, icon |
EmptyState | title, description, icon (default inbox), action |
ErrorState | title, error (its message shows), onRetry (shows Try again) |
Spinner | size, label (default "Loading") |
Skeleton | lines, 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
| Part | Props |
|---|---|
Button | variant (primary, secondary, ghost, danger; default secondary), size (sm, md), icon, loading, full, type, disabled, onClick |
IconButton | icon, label (required), variant (default ghost), size |
Input | label, hint, error, required, value, onInput or onChange (event), and input attributes |
Textarea | the same as Input, rows (default 4) |
Select | the same as Input, options (strings or { value, label, disabled }), placeholder |
Field | label, hint, error, required: a label around one control that has no label |
Toggle | label, description, checked, onChange (event: read event.currentTarget.checked) |
Checkbox | the same as Toggle |
Segmented | options (strings or { value, label, icon }), value, onChange (gets the value), label (required), full |
SearchField | value, 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. AButton type="submit"shows a spinner while it runs.- The form API also has
values,errors,submitting,error,set(name, value),submit()andreset(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
| Part | Props |
|---|---|
List | label; children are ListItem parts |
ListItem | title, description, leading (an icon name or a node), meta, trailing, to (a path) or onClick |
Table | columns ({ 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
| Export | Meaning |
|---|---|
Sheet | open, 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.