Basic Page
The standard content page — Pho’s second section: a composed assembly for single-column management surfaces (settings, billing, members). It encodes a page title, section titles, optional descriptions, and bordered cards of label/control rows (including danger-zone actions).
Sections stay presentational. No data fetching, no mutations — wire forms,
dialogs, and menus around the parts. Prefer Stack so page rhythm comes from
the section defaults rather than host mt-* wrappers.
Import
import { BasicPage } from "@photon-ai/pho-ui/sections/basic-page";
Playground
One page, five levels deep. The frame is an app pane, and its pages are named after what they show: Navigation, Loading, Reveal, Subview, Form, Auto save, Wide, Receipt, Error, Onboard, Register. Click through it the way a user would; Reset starts the data and the simulated network over.
/playgroundWhere to look:
- Playground, the root. Values load behind a 1:1 skeleton in the same
Stack(the bones ride the entrance, then values fill in place); the name row saves through aFooter; eachRowLinkis a page one level down; the danger zone closes with aSectionFooter. - Navigation: the title morph and the trail, five levels deep.
Playgroundshrinks into the crumb above and grows back on return. Navigation’s own title carries bothenter(rising in from the root) and amorphId(flying back from an item). Open an item and the trail grows a crumb, then its history for a third; a history event makes four, and the trail folds its middle behind an ellipsis menu. Every page condenses; the event page is long enough to see it — scroll, and a bar takes the top with the way back. Only the tail ever moves, and any crumb jumps straight to its page. On the history page,Header enterrises title and description as one block. Narrow the window belowsmand the trail collapses to‹ parent. - Loading: a list of unknown length. One
Stack, mounted once: the entrance plays with the bones, and whenaria-busyclears the rows that land arrive on their own, nothing to wrap, inside a card that follows their height onanimateHeight. Reload sends them off the same way. - Reveal: blocks that come and go. Adding the first item opens the list
block and closes the callout’s empty line (
Reveal show,gap="block"inside the callout); the card between follows every add and remove onanimateHeight; removing the last item closes the block again. - Subview:
ItemandItemPanel. A new key shows its secret once, in a full-bleed panel when last in the card, an inset chip when rows follow. Revoke everything for the card’s empty state. - Form: the form layout.
Fieldsis a sheet to fill in, labels above controls;Formowns the save: the sheet disables while the action runs, a name already on the list or a URL that is not https comes back under its field, and the name “outage” comes back in theAlert. Each created endpoint opens as the filled-in sheet: the sameFieldsseeded withdefaultValue,Footer show="dirty"bringing the save in after the first edit,keyremounting it once the save lands. The root’s and an item’s name rows are the sameFormaround aCardofRows. - Auto save: documents that save themselves — pause or leave a field and the change lands, the word by the field says where it stands; flip Fail saves for the Retry toast that stays until a save lands.
- Wide: a page that carries a
Tableand says nothing else — the table runs it wide. The requests sort on their headers, figures at the end; each row is a link, and an open one lands beside the list, sticky at the pane’s top with its own scroll, itsBackthe close in the corner. Narrow the window belowmdand the same request is the page instead. - Receipt: charges that add up. The period’s usage in two groups,
closing on what it has come to so far, and under it the invoice that
usage runs into: the plan and its lines in a group, the subtotal and tax
loose below it, the amount due under the rule. The bones are the same
receipts with names and figures boned, so the values fill in place. Send
100 moves the figures, and the columns hold; narrow the window below
smand each quantity steps under its name. - Error: both altitudes. The first load fails and
ErrorViewtakes the stack’s place, centered in the pane; Try again loads for real. Then the name save answers 409 the first time and lands the second, soAlertreveals under its row and collapses again. - Onboard: a service that needs setting up first. Three steps in an
Onboard, one open at a time: choosing a number is Photon working, so its button loads, and when the number lands the step folds to its title with a check and the number while the next opens; registering waits on a reviewer, so the step goespendingwith a dashed ring until the frame approves it; the third check swaps the setup for the page, and Start over brings it back. - Register: a setup whose steps are pages, on
Onboard paged. Pick a type and the page turns to the brand at once, the first step now named for the pick with its icon in the ring; Back to it and nothing is picked, so picking again turns again. Continue turns forward, Back turns back, each from its own side. Scroll to a form’s foot before Continue: the row comes back into view with the new page. Register spins the last ring while Photon works, then the campaign is the page.
Two things to try on purpose. Scroll the root title out of view before opening a row: the word fades in at the back link instead of diving in from off screen. Prefer reduced motion: everything is static.
Anatomy
A settings page, in full:
<BasicPage.Root>
<BasicPage.Title>Profile</BasicPage.Title>
<BasicPage.Stack>
<form>
<BasicPage.Card>
<BasicPage.Row label="Avatar">{/* avatar */}</BasicPage.Row>
<BasicPage.Row label="Name" htmlFor="first-name">
<Input id="first-name" size="sm" />
</BasicPage.Row>
<BasicPage.Row label="Email">
<BasicPage.Value>[email protected]</BasicPage.Value>
</BasicPage.Row>
</BasicPage.Card>
<BasicPage.Footer show={dirty}>
<output className="text-xs" />
<Button type="submit" size="sm" variant="effect">
Save changes
</Button>
</BasicPage.Footer>
</form>
<div>
<BasicPage.SectionTitle>Danger zone</BasicPage.SectionTitle>
<BasicPage.Card>
<BasicPage.Row
label="Delete account"
description="Permanently deletes your Photon account…"
>
<Button variant="ghost" color="destructive" size="sm">
Delete
</Button>
</BasicPage.Row>
</BasicPage.Card>
<BasicPage.SectionFooter>
Delete or transfer every project first. Then account deletion can
proceed and ends access for the account.
</BasicPage.SectionFooter>
</div>
</BasicPage.Stack>
</BasicPage.Root>
- Root — the page: a full-width shell that owns the chrome (the trail
in its top-left corner, the condensing bar across its top) around a
centered 44.5rem content column with
px-5side gutters, so narrow viewports keep a breathing edge. Prefer this over ad-hoc width wrappers in the host app. Root itself never animates — the page entrance lives onStack, so the title and back link stay put. It condenses by default: once the title has scrolled out, a sticky bar pins to the top with the way back and the page name, assembled fromTitleand the trail’s tail crumb — nothing to wire;condense={false}opts a page out, andcondense="pinned"keeps the bar up from the first paint for a page that is one surface (an inbox) with no room for a title block: theh1stays for the document but leaves the flow, the trail hides behind the bar’s crumb, and the column starts right under the bar. An app shell with a bar of its own can adopt the condensed one instead of stacking a second: wrap the shell inBasicPage.CondenseScopeand readuseCondense()—{ condensed, title, back }— where its bar is; while the scope adopts, the page renders no sticky bar of its own, its corner trail hides (the way back lives in the shell bar,backfrom the first paint), andadopt={false}(say, on desktop, where the shell bar does not exist) hands it back. The column’s top zone is conditional: pages carrying aBackreserve 64px for it; pages without one keep a tighter 48px. - Breadcrumbs — the trail above the title: where this page sits, root-most
Crumbfirst, ending with the immediate parent (the current page is theTitlebelow). Placed insideRootbeforeTitle, it floats in the page’s top-left corner instead of taking layout space — chrome of the pane, not the column, so on a wide pane it stays at the corner while the column centers — and the page title sits at the same position with or without it. One form at every depth:‹ parent / parent / parent, the chevron opening the trail and slashes between the crumbs; belowsmit collapses to‹ parent, the last crumb alone. Four crumbs or more, the middle folds:‹ root / … / parent / parent, the folded pages behind the ellipsis as a menu, so the trail keeps a width ceiling at any depth. - Crumb — one step of the trail: a small ghost link. Pair its
morphIdwith that page’sTitleand the word morphs between title and crumb across the navigation. Crumbs before the last ellipsize. - Back — the one-crumb
Breadcrumbs:‹ parent, for a page one level down. Same props asCrumb. - Title — the page
<h1>(text-display-base). Long names ellipsize. Plain-text children get a slugidfor hash targets (Profile→#profile); the heading is not a link. Passidto override orid=""to opt out. A trailing accessory (a status badge, a word in its own color) goes intrailing: it sits at the label’s end, on its line, outside the word, so it neither flies with a morph nor goes into the condensed bar, and the label still ellipsizes beside it; when the word arrives in motion, the accessory rises in beside it on the same spring. - TitleAccessory — a part composed into
Title’s children beside the words, saying whether it follows them into the condensed bar. Everything in the children goes into the bar unless it says otherwise: a status the name can’t be read without stays (inBar, the default), an action on the page leaves (inBar={false}), since the bar is a way back and a name, not a toolbar.<TitleAccessory inBar={false}><RenameButton /></TitleAccessory>. - Stack — vertical rhythm under the title (
gap-10between blocks). An untitled wrapper (adiv, aform, aReveal) lays out its parts atgap-3. A self-contained surface as a Stack child —Card,Callout,Fields,Onboard,Receipt,Header,Columns,Table,Code— keeps its own internal rhythm and does not inherit that gap, so a bare Card’sdivide-yrows stay flush. Do not wrap a Card in adivjust to protect its rows; still wrap a section title + card (+ footer) so that group is one block. Under the page title, what the first block leads with decides the seat: a bare surface (callout, table, card) takes the full 40px block rhythm, while a block leading with its ownSectionTitleorHeaderreads text-under-text and sits 4px tighter. It also marks arrivals: the render in which itsaria-busyclears is one, and the rows, items, and row links mounting in it rise in on their own. The gap between a section title (orHeader) and its card, and between the card and itsSectionFooter, is the part’s own margin as well, zeroed when the block’sgap-3already provides it — so a wrapper betweenStackand the block (a form, aReveal) never loses the gap. Parts carrypx-5. Mounting plays the page entrance — the stack rises and fades in, carrying whatever the page first shows (skeleton included), while the chrome above never moves. - Columns — two-column body on a wide page:
mainstretches,sideis floored at 29rem and capped at 32rem. Below 73.5rem of container it is one 42rem reading column, source order main → side → foot. Renders aStackper slot; the grid owns the 40px seams. Pair withRoot width="wide". - Header — wraps
SectionTitle+Descriptionwith no gap between them. - SectionTitle — a section
<h2>(text-display-xs). Same padding as the page title — only the type size differs. Long names ellipsize. Same hashidbehaviour asTitle(Danger zone→#danger-zone), not a link. - Description — supporting copy under a section title (
text-display-xs, description ink). Always nest insideHeader. - SectionFooter — optional caption below a section’s
Card(outside the card). One step smaller thanDescription(text-xs, description ink). Not the saveFooterthat follows a card. - Card — bordered surface that stacks rows with hairline dividers.
Safe as a direct
Stackchild: it marks itself (data-basic-page-card) and ownsgap-0, so the blockgap-3does not leak between rows. A row removed from it collapses and fades out on its own: the card keeps it until the exit has played (key list rows by their data id). PassanimateHeighton a card whose contents change while mounted (bones becoming a different number of rows, a row created or revoked): its height follows the content on the page spring instead of jumping. - Item / ItemPanel — a card row that can host a subview.
Itempads like a plain row; put primary content straight in as children, and end with anItemPanel— a gray sheet flush under the body, full-bleed to the card edges, owning the row’s bottom padding; only its bottom rounds, into the card corner. Passdividedwhen later card siblings follow — the panel then renders as an inset rounded chip instead, leaving the card’s own divider in charge. PlainRowstays fine for label/control rows. - Callout — standalone featured surface (invite form, notice). Do not wrap
in
Card— use withCalloutTitle/CalloutDescription. Optionaliconanchors a decorative glyph to the header (first child). - CalloutTitle — callout
<h2>. Long names ellipsize. Same hashidbehaviour asTitle(Invitations→#invitations), not a link. - Onboard — the service’s setup, before the page: an ordered card of
Steps under aHeader, for a service that needs something done before it can be used. The list numbers the steps by position and finds the one at hand, the first notdone; that step is open, and the others fold to their title. A step finishing folds closed as the next opens, each fold aRevealon the page spring.pagedmakes it a row of steps over the page of the step at hand instead, for steps that are pages of their own; eachStep’s children are then its page. - Step — one step:
title(a verb and its object),description(a line), and its standing:done,working(Photon’s own task, a minute or less) orpending(someone else’s turn, minutes or days). At hand, the step sits on the page’s ground the way aCalloutdoes, its decorativeiconin the top-right corner — or, in the icon’s place, anasideto look at, a code to scan with its caption, its top on the title’s — and the line and the children (a smallButtonorButtonLink, at the foot, level with a taller aside’s bottom) open under the title on its own left edge; done, the children (aValuefor what it came to, in the section’s type) sit at the title’s end and the rest folds away; waiting its turn, the title alone, muted, on the card’s white. The ring draws the standing: solid ink with the number at hand, faint while waiting its turn, spinning around the number while working, dashed while pending, an ink ring with a drawn check once done. The title and the line wear the section’s type,SectionTitleoverDescription. In a pagedOnboardthe step is its place in the row, the ring and thetitle(a word or two), and its children are its page; once done, itsicon(when it has one) takes the check’s place in the ring.
Navigation
Pages drill into subpages, and a PaaS goes deep: project, resource,
sub-resource, one record of it. RowLink makes the whole row a link with a
trailing chevron; the way up is the trail. One level down it is Back,
‹ parent; deeper it is Breadcrumbs, ‹ parent / parent / parent, any
crumb of which jumps straight to its page. One form at every depth: the
chevron opens the trail (it is the way up) and slashes separate the
crumbs (they are the path); a › after a ‹ read as two directions at
once, and a form that flipped with depth read as two components. Both
float in the page’s top-left corner, above the title, so the title never
shifts between pages with and without one; below sm the path collapses to
‹ parent. Four crumbs or more, the middle folds behind an ellipsis —
‹ root / … / parent / parent — and the folded pages wait there as a
menu, so the trail never outgrows the column however deep the page. All of
it is presentational: links render through the host’s LinkProvider, so
the URL and history stay the router’s job (React Router in the dashboard).
In the playground: any row of the root’s Pages card, and the trail on
every page below it.
// One level down
<BasicPage.Root>
<BasicPage.Back href="/settings">Settings</BasicPage.Back>
<BasicPage.Title>Billing</BasicPage.Title>
…
</BasicPage.Root>
// Deeper: the path, root-most first, ending with the immediate parent
<BasicPage.Root>
<BasicPage.Breadcrumbs>
<BasicPage.Crumb href="/settings">Settings</BasicPage.Crumb>
<BasicPage.Crumb href="/settings/billing">Billing</BasicPage.Crumb>
</BasicPage.Breadcrumbs>
<BasicPage.Title>Invoices</BasicPage.Title>
…
</BasicPage.Root>
A page with rows that lead on:
<BasicPage.Root>
<BasicPage.Back href="/settings">Settings</BasicPage.Back>
<BasicPage.Title>Billing</BasicPage.Title>
<BasicPage.Stack>
<div>
<BasicPage.Card>
<BasicPage.RowLink
label="Invoices"
description="Monthly statements and receipts."
href="/settings/billing/invoices"
>
<BasicPage.Value>12 documents</BasicPage.Value>
</BasicPage.RowLink>
<BasicPage.RowLink
label="Payment method"
href="/settings/billing/payment-method"
>
<BasicPage.Value>Visa ending 4242</BasicPage.Value>
</BasicPage.RowLink>
</BasicPage.Card>
</div>
</BasicPage.Stack>
</BasicPage.Root>
Title morph
Give the parent page’s Title and the subpage’s Back (or the tail
Crumb) the same morphId and the word itself travels: drilling in
shrinks the large title into the trail; returning grows it back. Give the
subpage’s own title enter: the moment the word lifts off its line, the
new title rises into it from just below — one upward stream, never
overlapping. A title with a description rises as one block — put enter
on the Header wrapping the pair instead. On the first push the chevron
comes out from under the arriving word, sliding left, landing with it.
Everything rides the page-tier entrance spring — measured off a 60fps
iOS push, ~330ms with a long gentle glide, co-landing — and is skipped
under reduced motion. A shared element only flies between points the user
has seen: if the word’s other end was scrolled off screen when you
navigated, the flight is skipped and the destination simply fades in —
nothing dives in from off-screen.
A page in the middle of the tree takes both props on its title: enter
for the way in from its parent, morphId for the way back from its own
subpages. Whichever applies plays; the other end being on screen decides.
Deeper than one level the trail is already there when you navigate, and
everything before the tail holds still: a push grows it by one crumb (a
slash emerges from under the arriving word), a pop loses the tail (the
word grows back into the title, the spare slash fades). Collapsed below
sm, the same moves play on the single visible crumb the way iOS does them
(measured off the WLAN → Succade push and back in the same recording): the
chevron holds still, the outgoing label fades away leftward, the incoming
one slides out from under the chevron. Only a pop to the root, where no
trail remains, fades the chevron out. All of it is
automatic; nothing to wire beyond the two props. In the playground:
Playground → Navigation → an item → its history.
A flight is worth it only over a short distance. The title sits at the column’s left edge, the crumb in the page’s top-left corner, so the two ends are as far apart as the column is inset from the page. Up to 96px of inset the morph reads as one word moving up; on a wider pane the column centers far from the corner, a flight would be a long streak across empty space, and the word arrives on its own instead: a crumb fades in, a title rises in (the same arrival it plays when the other end was never on screen). Measured when the flight is decided, against the page the word is leaving, so a window resized since that page loaded is judged as it is now.
// Level 1 — /project
<BasicPage.Title morphId="project">Project</BasicPage.Title>
// Level 2 — /project/sms: rises in from Project, flies back from a number
<BasicPage.Back href="/project" morphId="project">
Project
</BasicPage.Back>
<BasicPage.Title morphId="sms" enter>
SMS
</BasicPage.Title>
// Level 3 — /project/sms/:id: the path so far, then the number
<BasicPage.Breadcrumbs>
<BasicPage.Crumb href="/project" morphId="project">
Project
</BasicPage.Crumb>
<BasicPage.Crumb href="/project/sms" morphId="sms">
SMS
</BasicPage.Crumb>
</BasicPage.Breadcrumbs>
<BasicPage.Title enter>(530) 436-7438</BasicPage.Title>
// With a description, rise the pair as one block instead:
<BasicPage.Header enter>
<BasicPage.Title>(530) 436-7438</BasicPage.Title>
<BasicPage.Description>US local number on this project.</BasicPage.Description>
</BasicPage.Header>
Wide pages
A page that is a list first — rows with several columns, many of them —
needs more than the reading column. Wide is 80rem; nothing else changes:
title, stack, and cards keep their rhythm. A page carrying a Table runs
wide on its own; width="wide" says it for a wide page without one.
Forms stay on the default width, where a label and its control sit in one
glance.
<BasicPage.Root width="wide">
<BasicPage.Title>Outgoing requests</BasicPage.Title>
<BasicPage.Stack>…</BasicPage.Stack>
</BasicPage.Root>
A page that is one surface filling the pane — an inbox, a board — takes
width="full": no cap, no gutters, no trim under the column, so the
surface runs edge to edge and brings its own padding. With
condense="pinned" the bar spans the pane and Stack sits flush under it.
<BasicPage.Root width="full" condense="pinned">
<BasicPage.Back href="/sms">SMS</BasicPage.Back>
<BasicPage.Title>(530) 784-9134</BasicPage.Title>
<BasicPage.Stack>…</BasicPage.Stack>
</BasicPage.Root>
A list with column semantics — sorting, aligned figures, several fields
per row — is a Data Table, under the page’s
name as BasicPage.Table, placed in the stack as a block. rowHref
makes each row a link into a subpage and current keeps the open row’s
wash — the pair that feeds the aside below. search gives the table a
search with two faces. Beside a section title, BasicPage.Search is the
boxed one — open where the header is wide enough, a round button where
it isn’t, the box gliding open from it on click on the page spring, the
two faces crossfading; fold="always" keeps the button at every width.
A list of rows can wear the same box: give Search value,
onValueChange, and placeholder and it searches on its own, no table
needed (label names it when the placeholder is too short to). On a block whose table
speaks for itself, the table wears its own: a ghost search line in the
band at the top of the frame — table chrome like the header row, quiet
ink on the cells’ own text line, no boxes in a box — with toolbar
filters after it (a Select on variant="inline", a ghost Button).
To own the order in the band, place the ghost face yourself —
<BasicPage.Search variant="ghost" /> in toolbar, and the table’s own
yields. TableCount in a header says how many rows there are, and how
many still match a search. createColumns comes with the section, so a page
file needs one import. The playground’s Wide page is one.
const col = createColumns<Request>();
const columns = col.columns([
col.accessor("to", { header: "To" }),
col.accessor("amount", { header: "Amount", meta: { align: "end" } }),
]);
// The box beside the section title…
<BasicPage.Header>
<BasicPage.SectionTitle>Requests</BasicPage.SectionTitle>
<BasicPage.TableCount one="request" other="requests" />
<BasicPage.Search />
</BasicPage.Header>
// …or the table wears its own ghost search, filters in the band after it.
<BasicPage.Stack>
<div>
<BasicPage.Table
id="requests"
columns={columns}
data={requests}
search="Search requests"
toolbar={<StatusFilter />}
/>
</div>
</BasicPage.Stack>;
Aside
Any page can open a subpage beside itself: either Root width hosts an
Aside, and both sides keep their own measure. The page holds its column
cap beside the panel (itself at most 28rem by default; width="wide"
opens the panel to two thirds of the pane, up to 48rem); leftover
space is margin, not content width. Aside takes the subpage. On a pane
at least 48rem wide (md) it is a panel at the right edge, and by default the panel
floats: it takes no ground, surfaces over the page’s edge when a subpage
opens and leaves with it, and the page beneath stays live, so opening
another row swaps the panel’s page. variant="inline" docks it: the
panel holds its ground from the first paint, the list never reflows as
subpages open and close, and only the panel’s face crossfades,
placeholder while empty, the subpage while one is open. modal on a
floating panel dims the page behind it and keeps focus and pointer
inside; the backdrop is a way out. Either way the subpage scrolls on its
own, sticky at the pane’s top, its Back becomes the close in the
corner, and Escape closes it. The panel is what moves: it slides in from
the edge with the subpage already at rest inside it, so nothing in the
subpage plays an entrance of its own on top (Header / Title enter
and the Stack rise are held, as inside a paged Onboard); as the page,
the subpage enters as any page does. On a narrower pane the same subpage
is the page: the list steps aside and the subpage takes the pane with
its own Back. One subpage component serves both; it never asks which.
The subpage stays a route. With React Router, hand Aside the outlet
element — null while no subpage route matches — and the aside opens and
closes with the URL:
function Requests() {
const outlet = useOutlet();
const { requestId } = useParams();
return (
<BasicPage.Root>
<BasicPage.Title>Outgoing requests</BasicPage.Title>
<BasicPage.Stack>
<div>
<BasicPage.Table
columns={columns}
data={requests}
rowHref={(row) => `/requests/${row.id}`}
current={requestId}
/>
</div>
</BasicPage.Stack>
<BasicPage.Aside width="wide">{outlet}</BasicPage.Aside>
</BasicPage.Root>
);
}
function Request() {
return (
<BasicPage.Root>
<BasicPage.Back href="/requests">Outgoing requests</BasicPage.Back>
<BasicPage.Title>$5.00</BasicPage.Title>
<BasicPage.Stack>…</BasicPage.Stack>
</BasicPage.Root>
);
}
current keeps the open row washed while its page is beside it (a
RowLink list does the same under aria-current="page" — the aside
serves either kind of list). Pass open to decide explicitly; onClose
is the way out for a subpage without a Back. On a docked panel,
placeholder is the empty face: a quiet line that says what opens here.
The playground’s Wide page is this behind an in-memory router; the
request’s own Aside block docks the panel or makes it modal, and the
panel moves as the switches flip.
Panel or page
Panel or page is presentation, not routing: the URL is the subpage’s
either way. Left to itself the aside reads how the subpage was reached.
A subpage that opens after the list is on screen was opened from it, and
is a panel. A subpage already open when the aside mounts arrived by URL
— a deep link, a refresh, a pasted address — and is the page: the list
steps aside and the subpage takes the pane with its own Back, exactly
as on a narrow pane. The panel carries an Expand beside its close that
turns it into the page at the same URL; the expanded page carries Show
as panel in its corner to fold back beside the list; its Back is the
way to the list. Each time the aside closes the choice resets, so the
next subpage decides afresh. Below md the subpage is the page whatever
is said, and neither control shows.
/requestsOpen a request: a panel. Press Expand: the page, same address. Press
Reload: the app remounts at that address, and the request is the page
from the first paint. Its Back returns to the list.
presentation takes the choice over ("panel" or "page"), with
onPresentationChange carrying Expand and Show as panel to the app;
defaultPresentation seeds an uncontrolled aside instead of the
arrival rule; expandable={false} hides both controls.
<BasicPage.Aside
width="wide"
presentation={expanded ? "page" : "panel"}
onPresentationChange={(next) => setExpanded(next === "page")}
>
{outlet}
</BasicPage.Aside>
Columns
A wide page that is two things at once — traffic on the left, facts on
the right — uses Columns instead of a host grid. The side column has a
ceiling and a floor: 32rem so it stays a facts column, 29rem so a
resource id (pho_res_ and 26 more) sits whole beside its label in a
card row. The main column takes the rest. Leave side out and the page
stays one reading column at every width.
Columns brings its own @container. Two columns only when the
container can hold the 42rem reading column, a 2.5rem gutter, and the
29rem side — 73.5rem. Below that the page is one 42rem reading column:
main, then side, then foot, in that order — the order a phone paints.
Pair with Root width="wide" so the canvas can host both columns; a
default Root stays one column. Each slot is a Stack; the primitive
owns the 40px seams between them, so the host never puts mt-* on a
part.
<BasicPage.Root width="wide">
<BasicPage.Columns
header={<BasicPage.Title>Line</BasicPage.Title>}
main={
<div>
<BasicPage.SectionTitle>Activity</BasicPage.SectionTitle>
<BasicPage.Card>
<BasicPage.Row label="Messages">
<BasicPage.Value>128</BasicPage.Value>
</BasicPage.Row>
</BasicPage.Card>
</div>
}
side={
<div>
<BasicPage.Card>
<BasicPage.Row label="Resource ID">
<BasicPage.Value>
pho_res_abcdefghijklmnopqrstuvwxyz
</BasicPage.Value>
</BasicPage.Row>
</BasicPage.Card>
</div>
}
foot={
<div>
<BasicPage.SectionTitle>Danger zone</BasicPage.SectionTitle>
<BasicPage.Card>
<BasicPage.Row label="Release">
<BasicPage.Value>Ends the line.</BasicPage.Value>
</BasicPage.Row>
</BasicPage.Card>
</div>
}
/>
</BasicPage.Root>
Metadata
A detail page opens with the record’s metadata: the request id, the
trace id, the key that made it, the time, how long it took. Those are
dense lines of text, not rows with a control on the right, so they are a
Description List, under the page’s name
as Meta and MetaItem, placed inside a Card Item for the card’s
padding. mono puts an id in the mono face and keeps its
line; copy adds a copy control after it; optional marks a fact that
may not be there, and empty says what its absence reads as. Plain
Row stays the choice for label/control rows and for what a row
navigates to.
<BasicPage.Card>
<BasicPage.Item>
<BasicPage.Meta>
<BasicPage.MetaItem label="Request ID" value={request.id} mono copy />
<BasicPage.MetaItem label="Trace ID" value={request.traceId} mono copy />
<BasicPage.MetaItem label="Idempotency key" optional mono copy />
<BasicPage.MetaItem label="Time" value={formatTime(request.time)} />
<BasicPage.MetaItem label="Request duration (root)" value="426.257 µs" />
</BasicPage.Meta>
</BasicPage.Item>
</BasicPage.Card>
Receipt
Charges that add up: the usage a billing period has run to, the invoice it
runs into, an order before it is paid. Receipt is a card of its own, one
line per charge in three columns: what was charged, how much of it, and
what it came to. The figures stand right-aligned in tabular digits, and
each figure column is as wide as its widest entry across the whole
receipt, totals included, so the amounts read down one edge.
ReceiptGroup gathers lines under a name, a category or a plan;
ReceiptTotal closes the receipt under a hairline, at full ink. In the
playground: Receipt.
<div>
<BasicPage.Header>
<BasicPage.SectionTitle>Usage this period</BasicPage.SectionTitle>
<BasicPage.Description>
Metered usage accrued since the current billing period started.
</BasicPage.Description>
</BasicPage.Header>
<BasicPage.Receipt empty="No usage yet this period.">
{categories.map((category) => (
<BasicPage.ReceiptGroup key={category.code} label={category.name}>
{category.lines.map((line) => (
<BasicPage.ReceiptLine
key={line.metricCode}
label={line.displayName}
quantity={formatQuantity(line)}
amount={formatAmount(line.amountCents, line.currency)}
/>
))}
</BasicPage.ReceiptGroup>
))}
{totals.map((total) => (
<BasicPage.ReceiptTotal
key={total.currency}
label="Total so far"
amount={formatAmount(total.amountCents, total.currency)}
/>
))}
</BasicPage.Receipt>
</div>
Hairlines separate the blocks. A group is a block, and so is a run of loose lines or a run of totals: a total after lines opens on its rule, and a second total, in another currency, shares the first one’s block. To set a subtotal and its tax apart from the items, put the items in a group with no label:
<BasicPage.Receipt>
<BasicPage.ReceiptGroup>
<BasicPage.ReceiptLine label="Business plan" amount="$250.00" />
<BasicPage.ReceiptLine
label="Dedicated line"
quantity="× 2"
amount="$500.00"
/>
</BasicPage.ReceiptGroup>
<BasicPage.ReceiptLine label="Subtotal" amount="$750.00" />
<BasicPage.ReceiptLine label="Tax" quantity="8.25%" amount="$61.88" />
<BasicPage.ReceiptTotal label="Due Oct 1" amount="$811.88" />
</BasicPage.Receipt>
The host formats every figure, since it knows the currency and the
locale. A line with no quantity gives its name both columns. Below sm
the quantity steps under the name, the way a Row stacks, so a phone
keeps the name whole. While the data loads, bone what comes from the
server (label={<Skeleton className="h-3 w-24" />}): every cell holds one
20px line and centers a bone in it, so the bones stand where the words
will. empty is what the card says with nothing on it.
Charges were Rows before there was a receipt. A row is 68px, built for a
control at its end; a line here is 28px of text, and a group names the
category once where rows repeated it under every charge.
Forms
Two layouts, one Form. A settings page that saves is a Card of Rows;
a page to fill in is Fields, labels above controls in one rhythm. Either
way Form owns the save: pass an async action, and the parts read its
state — Submit shows pending, Alert shows what it threw, field errors
it returns land under their fields, and Footer show="dirty" comes and
goes with the edit. Inputs are uncontrolled: defaultValue for the initial
value, name for the key it comes back under, and key on the form to
start over when the record changes. There is no field state to seed and no
error to reset.
<BasicPage.Form key={endpoint.updatedAt} action={save}>
<BasicPage.Fields>
<Input name="name" label="Name" defaultValue={endpoint.name} required />
<Input
name="url"
label="URL"
type="url"
defaultValue={endpoint.url}
required
/>
<BasicPage.Inline>
<Field.Root name="timeout">
<Field.Label>Timeout</Field.Label>
<InputGroup.Root>
<InputGroup.Input name="timeout" type="number" defaultValue={30} />
<InputGroup.Separator />
<InputGroup.Addon>seconds</InputGroup.Addon>
</InputGroup.Root>
</Field.Root>
<Input name="retries" label="Retries" type="number" defaultValue={3} />
</BasicPage.Inline>
<BasicPage.Alert />
<BasicPage.Footer show="dirty">
<BasicPage.Submit />
</BasicPage.Footer>
</BasicPage.Fields>
</BasicPage.Form>
The action is any function that returns a promise. With TanStack Query,
pass the mutation’s mutateAsync; the cache stays the mutation’s business,
the sheet’s state is the form’s:
const save = useMutation({ mutationFn: updateEndpoint });
<BasicPage.Form
key={endpoint.updatedAt}
action={async (values) => {
try {
await save.mutateAsync(values);
} catch (error) {
if (isProblem(error) && error.errors) return { errors: error.errors };
throw error;
}
}}
>
Footer reveal
Keep Footer mounted and drive it with show — appearing and disappearing
both glide (height spring + content fade) instead of popping. In the
playground: edit the root’s name, or an item’s.
<BasicPage.Card>
<BasicPage.Row label="Name" htmlFor="first-name">
<Input id="first-name" size="sm" />
</BasicPage.Row>
</BasicPage.Card>
<BasicPage.Footer show={dirty}>
<p role="status" className="text-xs" />
<Button type="submit" size="sm" variant="effect">
Save changes
</Button>
</BasicPage.Footer>
Item subviews
When a card row needs a secondary strip under its primary content (a just-
created secret, an expanded detail), end the Item with an ItemPanel. Its
content is already a centered flex row; no extra classes needed. The panel
takes one of two shapes depending on where the item sits in the card. In
the playground: Subview. Create a key with the others revoked for the
standalone sheet, with others still listed for the chip.
Standalone
Only child (or last) in the card — omit divided. A gray sheet flush under
the square body, running full-bleed and owning the row’s bottom padding; its
bottom nests into the card’s rounded corner.
<div>
<BasicPage.SectionTitle>Keys</BasicPage.SectionTitle>
<BasicPage.Card>
<BasicPage.Item>
<div className="flex min-h-10 items-center justify-between gap-6">
<div className="flex min-w-0 flex-col gap-0.5">
<p className="text-pho-primary truncate text-base font-medium">
ci-deploy
</p>
<p className="text-pho-description truncate text-xs">
Created Aug 7, 2026 · Expires Nov 5, 2026
</p>
</div>
<Button
variant="ghost"
color="destructive"
size="sm"
shape="circle"
svgOnly
aria-label="Revoke"
>
<IconTrash />
</Button>
</div>
<BasicPage.ItemPanel>
<div className="min-w-0 flex-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<code className="text-pho-secondary block w-max font-mono text-xs select-all">
<span className="text-pho-primary">
pho_sk_01preview_just_created
</span>
_rmejcirMJALMkI6y-8uQDUHkjhKElDjCGjyiTGFTx0k
</code>
</div>
<Button
variant="ghost"
size="sm"
shape="circle"
svgOnly
aria-label="Copy secret"
>
<IconCopy />
</Button>
</BasicPage.ItemPanel>
</BasicPage.Item>
</BasicPage.Card>
</div>
With items below
Later siblings follow — pass divided. Mid-list there’s no card corner for a
full-bleed sheet to land on, so the panel becomes an inset rounded chip: it
overhangs the text gutter by 8px per side (content stays aligned with the
body), and the row keeps its own bottom padding and hairline divider.
<div>
<BasicPage.SectionTitle>Keys</BasicPage.SectionTitle>
<BasicPage.Card>
<BasicPage.Item>
<div className="flex min-h-10 items-center justify-between gap-6">
<div className="flex min-w-0 flex-col gap-0.5">
<p className="text-pho-primary truncate text-base font-medium">
ci-deploy
</p>
<p className="text-pho-description truncate text-xs">
Created Aug 7, 2026 · Expires Nov 5, 2026
</p>
</div>
<Button
variant="ghost"
color="destructive"
size="sm"
shape="circle"
svgOnly
aria-label="Revoke"
>
<IconTrash />
</Button>
</div>
<BasicPage.ItemPanel divided>
<div className="min-w-0 flex-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<code className="text-pho-secondary block w-max font-mono text-xs select-all">
<span className="text-pho-primary">
pho_sk_01preview_just_created
</span>
_rmejcirMJALMkI6y-8uQDUHkjhKElDjCGjyiTGFTx0k
</code>
</div>
<Button
variant="ghost"
size="sm"
shape="circle"
svgOnly
aria-label="Copy secret"
>
<IconCopy />
</Button>
</BasicPage.ItemPanel>
</BasicPage.Item>
<BasicPage.Item>
<div className="flex min-h-10 items-center justify-between gap-6">
<div className="flex min-w-0 flex-col gap-0.5">
<p className="text-pho-primary truncate text-base font-medium">
staging
</p>
<p className="text-pho-description truncate font-mono text-xs">
pho_••••
</p>
<p className="text-pho-description truncate text-xs">
Created Jul 24, 2026 · Expires Oct 22, 2026
</p>
</div>
<Button
variant="ghost"
color="destructive"
size="sm"
shape="circle"
svgOnly
aria-label="Revoke"
>
<IconTrash />
</Button>
</div>
</BasicPage.Item>
</BasicPage.Card>
</div>
Callout
Featured action block — its own bordered surface, not nested in a Card.
In the playground: Add an item on Reveal, Create a key on Subview.
<BasicPage.Callout icon={<IconMail stroke={1.25} />}>
<BasicPage.Header>
<BasicPage.CalloutTitle>Invitations</BasicPage.CalloutTitle>
<BasicPage.CalloutDescription>
Invite teammates by email. Invitations expire on their own.
</BasicPage.CalloutDescription>
</BasicPage.Header>
{/* form */}
<p className="text-pho-description text-xs">No pending invitations.</p>
</BasicPage.Callout>
Code
A body on the page — an event’s JSON, a request as it was sent, the
command that installs the SDK — is a Code Block,
under the page’s name as BasicPage.Code, placed in the stack as a block.
It is its own surface: the title row sits flush on the body, and the stack
leaves its rhythm alone the way it does a Card. Say the block’s name
with a SectionTitle (a Header when it needs a line under) and leave
the panel’s title for what the body is — a file, a tool — or off, so the
controls float over the corner. language colors it. In the playground:
the event page’s Body, the endpoint page’s Try it.
<div>
<BasicPage.Header>
<BasicPage.SectionTitle>Body</BasicPage.SectionTitle>
<BasicPage.Description>The event as it was delivered.</BasicPage.Description>
</BasicPage.Header>
<BasicPage.Code language="json" code={event} />
</div>
<div>
<BasicPage.SectionTitle>Try it</BasicPage.SectionTitle>
<BasicPage.Code title="curl" language="bash" code={curl} />
</div>
Section description
Use BasicPage.Description under a SectionTitle when the block needs a short
blurb before the card. In the playground: the root’s Pages block.
<div>
<BasicPage.Header>
<BasicPage.SectionTitle>Agent profile</BasicPage.SectionTitle>
<BasicPage.Description>
This identity is shown for the project's Photon agent.
</BasicPage.Description>
</BasicPage.Header>
<BasicPage.Card>{/* rows */}</BasicPage.Card>
</div>
Skeleton
While page data loads, keep real chrome — page title, field labels,
section titles, static descriptions — and only bone the values that come
from the server (avatar, name inputs, email, actions that depend on
permissions). Mark the region aria-busy. In the playground: the root on
first load, and Error after Try again.
import { Skeleton } from "@photon-ai/pho-ui/components/skeleton";
import { BasicPage } from "@photon-ai/pho-ui/sections/basic-page";
<BasicPage.Root aria-busy="true" aria-label="Loading profile">
<BasicPage.Title>Profile</BasicPage.Title>
<BasicPage.Stack>
<BasicPage.Card>
<BasicPage.Row label="Avatar">
<Skeleton className="size-10 rounded-full" />
</BasicPage.Row>
<BasicPage.Row label="Name">
<div className="flex w-full gap-2 sm:w-auto">
<Skeleton className="rounded-base h-9 w-28" />
<Skeleton className="rounded-base h-9 w-28" />
</div>
</BasicPage.Row>
<BasicPage.Row label="Email">
<Skeleton className="h-3.5 w-40" />
</BasicPage.Row>
</BasicPage.Card>
</BasicPage.Stack>
</BasicPage.Root>;
Reveal
Two separate moments animate. Entering the page is Stack’s job and
automatic: the stack rises in on mount, carrying whatever the page first
shows — a skeleton, cached content, an error — while Title and Back
above never move. Data arriving is automatic too, and keyed to the
aria-busy a loading page carries anyway: the render in which the stack’s
aria-busy clears is an arrival, and every Row, Item, or RowLink
mounting in it rises in on its own, dividers intact. Nothing else counts
as arriving: a remount under a new key, a row the user toggled on, the
page’s first render all appear plainly. A skeleton that mirrors the loaded
layout element for element shares the stack and branches inside its rows:
the rows stay, so values fill into a shape that never moves. A list of
unknown length branches at the row level, so the rows arrive; give that
card animateHeight so it grows with them. For regions below page level
that none of this covers, Reveal is the same entrance as an explicit
wrapper. Static under reduced motion. In the playground: Loading keeps one
stack and its rows arrive inside a card that follows their height; the
root and Error keep one stack and fill values in place.
// Unknown length — branch at the row level; the rows arrive when
// aria-busy clears, and the card follows their height:
<BasicPage.Stack aria-busy={pending || undefined}>
<BasicPage.Card animateHeight>
{pending ? <Bones /> : rows.map((row) => <BasicPage.Row key={row.id} … />)}
</BasicPage.Card>
</BasicPage.Stack>;
// 1:1 skeleton — the rows stay, branch inside them; values fill in place:
<BasicPage.Stack aria-busy={pending || undefined}>
<BasicPage.Card>
<BasicPage.Row label="Name">
{pending ? <Skeleton className="h-3.5 w-28" /> : <BasicPage.Value>…</BasicPage.Value>}
</BasicPage.Row>
</BasicPage.Card>
</BasicPage.Stack>;
Blocks that come and go
A whole block of the Stack that appears or leaves at runtime — a promo
callout dismissed, a section unlocked — is Reveal’s show mode (the
Footer grammar): keep the block mounted, drive it with the boolean, and
it glides open and closed on the page spring while its neighbours follow.
The Stack’s gap-10 is compensated during the collapse, so nothing jumps
when the block finishes leaving. In the playground: Reveal’s list block
opens with the first item, and the callout’s “Nothing added yet” line
closes inside the callout.
<BasicPage.Stack>
<BasicPage.Callout>…get a number…</BasicPage.Callout>
<BasicPage.Reveal show={numbers.length > 0}>
<BasicPage.SectionTitle>Numbers</BasicPage.SectionTitle>
<BasicPage.Card animateHeight>…one RowLink per number…</BasicPage.Card>
</BasicPage.Reveal>
</BasicPage.Stack>
// Inside a block (a Callout's gap-3), compensate that gap instead:
<BasicPage.Reveal show={invitations.length > 0} gap="block">
…pending list…
</BasicPage.Reveal>
Leaving
A row removed from a Card leaves on its own: it collapses and fades, its
padding and divider closing with it, and the rows below follow. So does a
Reveal show block, a Footer, an Alert clearing, the condensed bar.
What still leaves the moment React unmounts it: a block of the Stack
that is not a Reveal show, and a page swapped for the next. That is not a
styling gap, it is how React works. The DOM is gone in the same commit, so
an exit can only be played by a parent that is still mounted and noticed
the child go, which is what Card now does for its rows and Reveal does
for its own content. Two things have to hold for that to work:
- The container outlives the change. Swap children, not the surface
around them. Revoking one key leaves the
Cardin place, so the row could leave on its own; replacing a wholeStackwith anErrorViewtakes every row down with it, and nothing is left to animate. When a region must be replaced wholesale, accept the cut, or lift the swap to a parent that stays (Root, or the route). - Children are keyed by their data. A container tells who left by
comparing keys between renders.
key={endpoint.id}makes a removal read as “that one left”; an index key makes it read as “the last one left”, and the exit plays on the wrong row while the others change content in place. Ids derived from a list’s length repeat after a removal and confuse the same comparison.
Error states
Two altitudes, one contract — both consume the caught value directly and derive their copy from the problem body.
When the page itself couldn’t load, keep the chrome and swap the stack for an
ErrorView. Root is a min-h-full flex column, so give the view flex-1
and it claims the viewport left below the title and centers itself in it.
In the playground: Error, on its first load.
import { ErrorView } from "@photon-ai/pho-ui/components/error";
if (error) {
return (
<BasicPage.Root>
<BasicPage.Title>Profile</BasicPage.Title>
<ErrorView className="flex-1" error={error} onRetry={() => refetch()} />
</BasicPage.Root>
);
}
A failure scoped to one row — a save that didn’t land — is Alert. Keep it
mounted and pass the mutation’s error: the line reveals under its row on the
same height spring + fade as Footer, and collapses again when the error
clears. The card drops its divider above the line so the message reads as
part of the row. In the playground: Error’s name row. The first save
fails; typing or saving again clears it.
<BasicPage.Card>
<BasicPage.Row label="Name" htmlFor="name">
<Input id="name" size="sm" />
</BasicPage.Row>
<BasicPage.Alert error={mutation.error} />
</BasicPage.Card>
<BasicPage.Footer show={dirty}>…</BasicPage.Footer>
Onboarding
Some services need something done before their page can be used: a
number chosen, a business registered with the carriers. Until then the
page shows the setup instead. Keep the chrome (the title is still the
service) and under it put a Header that says what is ahead and an
Onboard that lists the steps in order. In the playground: Onboard.
Onboard is an ordered card of Steps. Each step says what it gets done
(title, a verb and its object), what it involves (description), and
where it stands: done, or pending while it waits on someone else. The
list works out the rest. It numbers the steps by position and finds the
one at hand, the first that isn’t done; that step is open on the page’s
ground, the way a Callout stands, its icon in the corner and its line
and its button under the title, and the others fold to their title on
the card’s white, the done ones with a check, the waiting ones muted.
One action on the page at a time, and it is always the next thing to do.
<BasicPage.Root>
<BasicPage.Title>SMS</BasicPage.Title>
<BasicPage.Stack>
<div>
<BasicPage.Header>
<BasicPage.SectionTitle>Set up your number</BasicPage.SectionTitle>
<BasicPage.Description>
Three steps, in order. The carrier review usually takes a day.
</BasicPage.Description>
</BasicPage.Header>
<BasicPage.Onboard>
<BasicPage.Step
icon={<IconPhone stroke={1.25} />}
title="Choose a number"
description="A US local number this project can text from."
done={number != null}
>
{number ? (
<BasicPage.Value>{number}</BasicPage.Value>
) : (
<Button size="sm" loading={choosing} onClick={choose}>
{choosing ? "Choosing…" : "Choose a number"}
</Button>
)}
</BasicPage.Step>
<BasicPage.Step
icon={<IconShieldCheck stroke={1.25} />}
title="Register to send"
description={
review === "pending"
? "In review. Usually a day."
: "Carriers check who is texting before the first message goes out."
}
done={review === "approved"}
pending={review === "pending"}
>
{review === "idle" && (
<Button size="sm" onClick={register}>
Register
</Button>
)}
</BasicPage.Step>
<BasicPage.Step
icon={<IconSend stroke={1.25} />}
title="Send a test message"
description="Texts your own phone once, so you know the line works."
done={tested}
>
{!tested && (
<Button size="sm" onClick={test}>
Send a test
</Button>
)}
</BasicPage.Step>
</BasicPage.Onboard>
</div>
</BasicPage.Stack>
</BasicPage.Root>
The fold is Reveal’s, in its show mode: the step’s line and button
glide open and closed on the page spring, fading as the space opens, the
Footer grammar — never clipped, so the button shows whole, rim and
focus ring, from the first frame. A step finishing folds closed as the
next opens, so the card only ever moves by one body. Static under
reduced motion.
Two kinds of waiting, told apart. While Photon works on a step (a few
seconds), the step’s own button is loading, with an in-flight label:
“Choosing…”. While someone else does (a carrier’s review, a day), the
step is pending: it stays open and at hand, its ring goes dashed, and
the line says how long: “In review. Usually a day.” Nothing spins for a
day.
The children are the step’s own: a small Button or ButtonLink while
the step is at hand, under its line; a Value for what a done step came
to, at its title’s end; nothing while it waits its turn.
When the last step is done the service is on. Render the page in its
place: give the setup block and the page block a Reveal each, driven by
show, and the setup glides closed while the page opens under it. On a
later visit the page renders alone.
Paged
Some setups are pages rather than steps on one page. A registration asks
who is registering, then for the brand, then for the campaign, each a form
of its own. Give Onboard paged and each Step’s children become its
page: a Stack, or a Form around one, as any page’s is. The steps stand
in a row under the title, the same rings with their titles beside them and
a line from each to the next, inked up to the step at hand. Under the row
is the page of the step at hand, and only that page. In the playground:
Register.
<BasicPage.Root>
<BasicPage.Title>Register to send SMS</BasicPage.Title>
<BasicPage.Onboard paged>
<BasicPage.Step title="Type" done={at > 0}>
<BasicPage.Stack>…</BasicPage.Stack>
</BasicPage.Step>
<BasicPage.Step title="Brand" done={at > 1}>
<BasicPage.Form action={registerBrand}>
<BasicPage.Stack>…</BasicPage.Stack>
</BasicPage.Form>
</BasicPage.Step>
<BasicPage.Step title="Campaign" done={at > 2}>
<BasicPage.Form action={review}>
<BasicPage.Stack>…</BasicPage.Stack>
</BasicPage.Form>
</BasicPage.Step>
<BasicPage.Step title="Review" done={at > 3} working={registering}>
<BasicPage.Stack>…</BasicPage.Stack>
</BasicPage.Step>
</BasicPage.Onboard>
</BasicPage.Root>
A paged Onboard stands where the page’s Stack would, so don’t wrap it
in one; each page brings its own. The list still decides which page shows:
the first step that isn’t done. Going back is the same move the other
way, marking the later steps not done again. Keep the step in the URL
(?step=brand) and work done out from it, so Back and a reload land
where they should. A step’s title is its name in the row, a word or two.
Its description and aside belong to the card and don’t show here; the
page says what it asks for in its own Header.
A done step can say what it came to. Once someone picks Business, the
first step’s title can be “Business” and its icon the building: the
ring draws the icon in place of the check, the title crossfades as the
ring’s glyph does, and the room it takes opens or closes on the page
spring, the line and the steps after it gliding over with it. Without an icon the ring keeps the check. A step whose
page is a one-time choice (pick a card and the page turns) should forget
the pick when the person comes back to it, so picking the same card turns
the page again.
Choice tiles (RadioCard, CheckboxCard) standing on the page are its
surfaces, like a card: their edges reach the column’s, their words start
on the headings’ line, and three quarters of that inset (15px) stands
above and below. Don’t pad the group to line them up. Inside a
card, a sheet or a callout they keep their compact 12px. Below sm only
the step at hand keeps its title beside its ring.
Changing the step at hand turns the page. The next page arrives the way a page does, rising on the page spring, but turned along the row: from the right going forward, from the left going back. The page left behind fades out where it stands and takes no clicks while it goes, and the pages’ stacks play no entrance of their own. A turn from the foot of a long form brings the row back into view, and focus left on the old page moves to the new one, unless the new page focuses a field itself. Static under reduced motion.
Checklist
Progress Photon reports, not steps someone takes: the DNS checks behind a
sending domain, a registration’s stages with carriers. Checklist is a list
of Checks, one line each, in the Onboard ring’s language at the text’s
size. A done check draws its mark (on as it turns done, already drawn when
it loads done). A pending one is dashed while it waits on someone else,
and a working one spins while Photon is at it. An upcoming one is faint
while it waits its turn. A failed one trades the ring for an X and says
“Failed” beside its label, since an X alone can read as dismiss, or its own
word for it given failure (“Rejected”, “Suspended”). A check’s
value stands at the line’s end in description ink: the date it passed, a
count. Its description sits right under the label, on the label’s edge, at
text-xs: why a failed check failed, in error ink with no surface of its own
(the X already marks it), or what a waiting one waits on, in description ink.
Put the list in a Card’s Item, and when it was last checked, if that
matters, in a SectionFooter.
<BasicPage.Card>
<BasicPage.Item>
<BasicPage.Checklist aria-label="Registration">
<BasicPage.Check state="done" value="Sep 20, 2026">
Submitted
</BasicPage.Check>
<BasicPage.Check
state="failed"
description="Tax ID: The EIN and legal company name don't match IRS records."
>
Registration
</BasicPage.Check>
<BasicPage.Check>Verified</BasicPage.Check>
</BasicPage.Checklist>
</BasicPage.Item>
</BasicPage.Card>
Each check says its standing with its label for assistive tech (“Done”,
“Waiting”, “In progress”, “Not yet”). The list works out where it stands:
the first check that failed, waits or is being worked on, or, once all
passed, the last one. That check is its current step, its label set one
weight up whatever came of it, and aria-current for assistive tech.
It’s not Onboard: nobody acts on a check,
so there are no numbers, no actions and no fold. And it’s not Progress:
the stages are named, not a percentage.
Timeline
How things got where they are: a campaign’s reviews, a domain’s checks over
time. Timeline is a list of TimelineItems, one event each, on the
checklist’s mark (state, done unless said) with a faint line down to the
next event’s mark; the last one draws none. Beside the mark: the event’s
title, its time under it in description ink, and its details as children
under that, at text-xs in description ink. Details are a record, not an
alarm: a failure’s mark already says it failed, and the page’s current state
(a Checklist, a callout) says what to do about it. Newest first unless the
page says otherwise; put it in a Card’s Item.
<BasicPage.Card>
<BasicPage.Item>
<BasicPage.Timeline aria-label="Review history">
<BasicPage.TimelineItem
state="failed"
title="Carrier review failed"
time="Sep 23, 2026, 12:11 PM"
>
A sample message is missing opt-out instructions.
</BasicPage.TimelineItem>
<BasicPage.TimelineItem
title="Registration completed"
time="Sep 21, 2026, 12:11 PM"
/>
</BasicPage.Timeline>
</BasicPage.Item>
</BasicPage.Card>
Where Checklist is where things stand now, a timeline is how they got
there: one list of named stages, the other of dated events.
Guidelines
- Keep one job per card: identity fields together, destructive actions in their own “Danger zone” block.
- Prefer
htmlForon rows that own a single primary input; give compound controls (first/last name) their ownaria-labels. - Reach for
Stackbefore adding host spacing classes — the section owns page rhythm. Gaps and paddings stay consistent; only title scale changes. - Prefer a page skeleton over a lone
Spinnerwhen the loaded layout is already known — keep labels real, bone only server values. - Failures follow the same split:
ErrorViewin place of the stack when the page couldn’t load,Alertunder its row when one save didn’t land. - Change the children, keep the container: a
Cardthat stays while its rows come and go can animate them; aStackreplaced wholesale cannot. - Key list rows by their data id, never by index or by the list’s length, so a removal reads as the right row leaving.
- Records in columns are a
Table— sorting, aligned figures, the page wide on its own. A list where each row is one label and a chevron staysRowLink. A wide page that is two things at once (traffic and facts) isColumns: the side column has a max, the main column stretches. - Money that adds up is a
Receipt: each charge a line, the sum under a rule. One figure beside its name stays aRowwith aValue, and records in columns stay aTable. - A service that needs setting up shows
Onboardin place of its page: one step open at a time, its button the page’s only one. Once the last step is done, the page; nothing of the setup stays. - When the steps are forms of their own, the setup is
Onboard paged: one page at a time under the row of steps, the step kept in the URL. - On a wide page, fill a callout with structure, not stretch: the words and their glyph on the left, the form row beside them (a container query flips it back to a stack on narrow surfaces). A control row spanning the full wide column strands its button; a capped row strands the surface.