Pho Design System

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.

Where to look:

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>

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:

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