Photon Design System

Basic Page

The standard content page — Photon’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.

Where 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 a Footer; each RowLink is a page one level down; the danger zone closes with a SectionFooter.
  • Navigation: the title morph and the trail, five levels deep. Playground shrinks into the crumb above and grows back on return. Navigation’s own title carries both enter (rising in from the root) and a morphId (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 enter rises title and description as one block. Narrow the window below sm and the trail collapses to ‹ parent.
  • Loading: a list of unknown length. One Stack, mounted once: the entrance plays with the bones, and when aria-busy clears the rows that land arrive on their own, nothing to wrap, inside a card that follows their height on animateHeight. 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 on animateHeight; removing the last item closes the block again.
  • Subview: Item and ItemPanel. 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. Fields is a sheet to fill in, labels above controls; Form owns 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 the Alert. Each created endpoint opens as the filled-in sheet: the same Fields seeded with defaultValue, Footer show="dirty" bringing the save in after the first edit, key remounting it once the save lands. The root’s and an item’s name rows are the same Form around a Card of Rows.
  • 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 Table and 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, its Back the close in the corner. Narrow the window below md and 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 sm and each quantity steps under its name.
  • Error: both altitudes. The first load fails and ErrorView takes 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, so Alert reveals 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 goes pending with 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>ada@photon.codes</BasicPage.Value>
        </BasicPage.Row>
        <BasicPage.Row label="User ID">
          <BasicPage.Value mono copy>
            {user.id}
          </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-5 side 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 on Stack, 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 from Title and the trail’s tail crumb — nothing to wire; condense={false} opts a page out, and condense="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: the h1 stays 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 in BasicPage.CondenseScope and read useCondense() — { 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, back from the first paint), and adopt={false} (say, on desktop, where the shell bar does not exist) hands it back. The column’s top zone is conditional: pages carrying a Back reserve 64px for it; pages without one keep a tighter 48px.
  • Breadcrumbs — the trail above the title: where this page sits, root-most Crumb first, ending with the immediate parent (the current page is the Title below). Placed inside Root before Title, 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; below sm it 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 morphId with that page’s Title and 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 as Crumb.
  • Title — the page <h1> (text-display-base). Long names ellipsize. Plain-text children get a slug id for hash targets (Profile → #profile); the heading is not a link. Pass id to override or id="" to opt out. A trailing accessory (a status badge, a word in its own color) goes in trailing: 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-10 between blocks). An untitled wrapper (a div, a form, a Reveal) lays out its parts at gap-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’s divide-y rows stay flush. Do not wrap a Card in a div just 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 own SectionTitle or Header reads text-under-text and sits 4px tighter. It also marks arrivals: the render in which its aria-busy clears is one, and the rows, items, and row links mounting in it rise in on their own. The gap between a section title (or Header) and its card, and between the card and its SectionFooter, is the part’s own margin as well, zeroed when the block’s gap-3 already provides it — so a wrapper between Stack and the block (a form, a Reveal) never loses the gap. Parts carry px-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: main stretches, side is floored at 29rem and capped at 32rem. Below 73.5rem of container it is one 42rem reading column, source order main → side → foot. Renders a Stack per slot; the grid owns the 40px seams. Pair with Root width="wide".
  • Header — wraps SectionTitle + Description with 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 hash id behaviour as Title (Danger zone → #danger-zone), not a link.
  • Description — supporting copy under a section title (text-display-xs, description ink). Always nest inside Header.
  • SectionFooter — optional caption below a section’s Card (outside the card). One step smaller than Description (text-xs, description ink). Not the save Footer that follows a card.
  • Card — bordered surface that stacks rows with hairline dividers. Safe as a direct Stack child: it marks itself (data-basic-page-card) and owns gap-0, so the block gap-3 does 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). Pass animateHeight on 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. Item pads like a plain row; put primary content straight in as children, and end with an ItemPanel — 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. Pass divided when later card siblings follow — the panel then renders as an inset rounded chip instead, leaving the card’s own divider in charge. Plain Row stays fine for label/control rows.
  • Value — a row’s read-only value on the right, in description ink, one line that truncates. mono sets it in the mono face (an id, a number); copy makes it copy itself, from a click on the value or on the copy control after it, named for the row’s label (Copy resource id). copy copies string children; pass the text when the value is composed (a formatted number copies its digits).
  • Callout — standalone featured surface (invite form, notice). Do not wrap in Card — use with CalloutTitle / CalloutDescription. Optional icon anchors a decorative glyph to the header (first child).
  • CalloutTitle — callout <h2>. Long names ellipsize. Same hash id behaviour as Title (Invitations → #invitations), not a link.
  • Onboard — the service’s setup, before the page: an ordered card of Steps under a Header, 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 not done; that step is open, and the others fold to their title. A step finishing folds closed as the next opens, each fold a Reveal on the page spring. paged makes it a row of steps over the page of the step at hand instead, for steps that are pages of their own; each Step’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) or pending (someone else’s turn, minutes or days). At hand, the step sits on the page’s ground the way a Callout does, its decorative icon in the top-right corner — or, in the icon’s place, an aside to look at, a code to scan with its caption, its top on the title’s — and the line and the children (a small Button or ButtonLink, at the foot, level with a taller aside’s bottom) open under the title on its own left edge; done, the children (a Value for 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, SectionTitle over Description. In a paged Onboard the step is its place in the row, the ring and the title (a word or two), and its children are its page; once done, its icon (when it has one) takes the check’s place in the ring.

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.

/requests

Open 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;
    }
  }}
>

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 Card in place, so the row could leave on its own; replacing a whole Stack with an ErrorView takes 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 htmlFor on rows that own a single primary input; give compound controls (first/last name) their own aria-labels.
  • Reach for Stack before 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 Spinner when the loaded layout is already known — keep labels real, bone only server values.
  • Failures follow the same split: ErrorView in place of the stack when the page couldn’t load, Alert under its row when one save didn’t land.
  • Change the children, keep the container: a Card that stays while its rows come and go can animate them; a Stack replaced 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 stays RowLink. A wide page that is two things at once (traffic and facts) is Columns: 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 a Row with a Value, and records in columns stay a Table.
  • A service that needs setting up shows Onboard in 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.