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";
Anatomy
Profile
Danger zone
Delete account
Permanently deletes your Photon account and ends access to its projects.
Delete or transfer every project first. Then account deletion can proceed and ends access for the account.
<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.Footer show={dirty}>
<output className="text-xs" />
<Button type="submit" size="sm" variant="outlined">
Save changes
</Button>
</BasicPage.Footer>
</BasicPage.Card>
</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 — page chrome: a 42rem content column with
px-5side gutters (centered), so narrow viewports keep a breathing edge. Prefer this over ad-hoc width wrappers in the host app. Root itself never animates — the page entrance lives onStack, so the title and back link stay put. The top zone is conditional: pages carrying aBackreserve 64px for it; pages without one keep a tighter 48px. - Back — subpage return link (a small ghost button), placed inside
RootbeforeTitle. It floats inRoot’s reserved top padding instead of taking layout space, so the page title sits at the same position with or without it. Pair itsmorphIdwith the parent title’s to morph the word across the navigation. - Title — the page
<h1>(text-display-base). Long names ellipsize. Plain-text children get a slugidfor hash targets (Profile→#profile); the heading is not a link. Passidto override orid=""to opt out. Trailing accessories (a status badge) sit asshrink-0siblings of amin-w-0 truncatelabel. - Stack — vertical rhythm under the title (
mt-6,gap-10between blocks; each block usesgap-3). Parts carrypx-5. Mounting plays the page entrance — the stack rises and fades in, carrying whatever the page first shows (skeleton included), while the chrome above never moves. - Header — wraps
SectionTitle+Descriptionwith no gap between them. - SectionTitle — a section
<h2>(text-display-xs). Same padding as the page title — only the type size differs. Long names ellipsize. Same hashidbehaviour asTitle(Danger zone→#danger-zone), not a link. - Description — supporting copy under a section title (
text-display-xs, description ink). Always nest insideHeader. - SectionFooter — optional caption below a section’s
Card(outside the card). One step smaller thanDescription(text-xs, description ink). Not the card-internalFooter. - Card — bordered surface that stacks rows with hairline dividers. Pass
animateHeighton a card whose contents change while mounted (bones becoming a different number of rows, a row created or revoked): its height follows the content on the page spring instead of jumping. - Item / ItemPanel — a card row that can host a subview.
Itempads like a plain row; put primary content straight in as children, and end with anItemPanel— a gray sheet flush under the body, full-bleed to the card edges, owning the row’s bottom padding; only its bottom rounds, into the card corner. Passdividedwhen later card siblings follow — the panel then renders as an inset rounded chip instead, leaving the card’s own divider in charge. PlainRowstays fine for label/control rows. - Callout — standalone featured surface (invite form, notice). Do not wrap
in
Card— use withCalloutTitle/CalloutDescription. Optionaliconanchors a decorative glyph to the header (first child). - CalloutTitle — callout
<h2>. Long names ellipsize. Same hashidbehaviour asTitle(Invitations→#invitations), not a link. - Row — label (+ optional description) left, control right. Pass
htmlForwhen the row wraps a labelled input; omit it for display/action rows. Passstackedfor a full-width control under the label. Rows are at least4.25rem(68px) tall. - RowLink — a row that navigates: the whole row is one link into a
subpage, with a trailing chevron cue. Always horizontal (label left,
chevron right) — unlike
Row, it never stacks on narrow viewports. - Value — read-only trailing text (email, etc.). Long values ellipsize.
- Alert — server error under a row, rendered by
Error from the caught value, revealing on the same
animation as
Footer(matches card padding). - Footer — trailing bar inside a card (dirty-state save status + submit).
Pass
showinstead of conditional rendering to animate the bar in and out: the card glides open on a near-critically-damped height spring while the content fades — the same motion language as the onboarding / invite card. - Reveal — data-arrival entrance for regions the skeleton couldn’t
predict (a list of unknown length): the region rises and fades in on the
same spring family. A 1:1 skeleton swaps in place with no motion — its
animation is the page entrance it rides in on, plus its own pulse. With
show, it becomes an animated-presence block instead: a Stack section that comes and goes glides open/closed with the gap compensated (gap="block"for elements inside a block, where the gap isgap-3).
Navigation
Pages can drill into subpages. RowLink makes the whole row a link with a
trailing chevron; Back returns to the parent, floating in the reserved zone
above the title — the title never shifts between pages with and without a
back link. Both are purely presentational — they render through the host’s
LinkProvider, so the URL and history stay the router’s job (React Router in
the dashboard).
<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 the same morphId
and the word itself travels: drilling in shrinks the large title into the
back link; 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. The back chevron fades in place (no travel), landing with
the word. 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.
// Parent — /platforms/sms
<BasicPage.Title morphId="project-sms">SMS</BasicPage.Title>
// Subpage — /platforms/sms/:id
<BasicPage.Back href="/platforms/sms" morphId="project-sms">
SMS
</BasicPage.Back>
<BasicPage.Title enter>(530) 436-7438</BasicPage.Title>
// With a description, rise the pair as one block instead:
<BasicPage.Header enter>
<BasicPage.Title>Photon CLI</BasicPage.Title>
<BasicPage.Description>Photon from your terminal.</BasicPage.Description>
</BasicPage.Header>
Footer reveal
Keep Footer mounted and drive it with show — appearing and disappearing
both glide (height spring + content fade) instead of popping:
<BasicPage.Card>
<BasicPage.Row label="Name" htmlFor="first-name">
<Input id="first-name" size="sm" />
</BasicPage.Row>
<BasicPage.Footer show={dirty}>
<p role="status" className="text-xs" />
<Button type="submit" size="sm" variant="outlined">
Save changes
</Button>
</BasicPage.Footer>
</BasicPage.Card>
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.
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.
Keys
ci-deploy
<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.
Keys
ci-deploy
staging
pho_••••
Created Jul 24, 2026 · Expires Oct 22, 2026
<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:
Invitations
Invite teammates by email. Invitations expire on their own.
No pending invitations.
<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>
Section description
Use BasicPage.Description under a SectionTitle when the block needs a short
blurb before the card:
Agent profile
This identity is shown for the project's Photon agent.
Avatar
Name
<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.
Profile
Avatar
Name
Danger zone
Delete account
Permanently deletes your Photon account and ends access to its projects.
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 only animates when the skeleton
couldn’t promise the shape. A skeleton kept in its own component remounts
the stack when data lands, replaying the entrance as the data-arrival
animation — right for lists of unknown length. A skeleton that mirrors the
loaded layout element for element shares ONE stack and branches its
children instead: values fill into a shape that never moves. For regions
below page level (a card inside an already-shown page, an error branch
without a stack), Reveal is the same entrance as an explicit wrapper.
Static under reduced motion.
Numbers
// Unknown shape — the skeleton is its own component (own Stack); swapping it
// for the loaded return remounts the stack and replays the entrance:
if (isPending) return <NumbersSkeleton />;
return (
<div>
<BasicPage.Title>Numbers</BasicPage.Title>
<BasicPage.Stack>…the list, now that its shape is known…</BasicPage.Stack>
</div>
);
// 1:1 skeleton — one stack, branch the children; values fill in place:
<BasicPage.Stack aria-busy={pending || undefined}>
{pending ? <SettingsBones /> : <>…loaded content…</>}
</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.
SMS
Get a number
Photon picks one US local number and adds it to this project.
Numbers
+1 (415) 555-0113
Added Aug 2, 2026
+1 (628) 555-0198
Added Jul 19, 2026
<BasicPage.Stack>
<BasicPage.Reveal show={canManage}>
<BasicPage.Callout>…get a number…</BasicPage.Callout>
</BasicPage.Reveal>
<div>…numbers…</div>
</BasicPage.Stack>
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:
Profile
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. Try it — save always fails here; typing clears it:
<BasicPage.Card>
<BasicPage.Row label="Name" htmlFor="name">
<Input id="name" size="sm" />
</BasicPage.Row>
<BasicPage.Alert error={mutation.error} />
<BasicPage.Footer show={dirty}>…</BasicPage.Footer>
</BasicPage.Card>
Guidelines
- Keep one job per card: identity fields together, destructive actions in their own “Danger zone” block.
- Prefer
htmlForon rows that own a single primary input; give compound controls (first/last name) their ownaria-labels. - Reach for
Stackbefore adding host spacing classes — the section owns page rhythm. Gaps and paddings stay consistent; only title scale changes. - Prefer a page skeleton over a lone
Spinnerwhen the loaded layout is already known — keep labels real, bone only server values. - Failures follow the same split:
ErrorViewin place of the stack when the page couldn’t load,Alertunder its row when one save didn’t land.