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";

Anatomy

Profile

Avatar

AL

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>

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>

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

Created Aug 7, 2026 · Expires Nov 5, 2026
pho_sk_01preview_just_created_rmejcirMJALMkI6y-8uQDUHkjhKElDjCGjyiTGFTx0k
<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

Created Aug 7, 2026 · Expires Nov 5, 2026
pho_sk_01preview_just_created_rmejcirMJALMkI6y-8uQDUHkjhKElDjCGjyiTGFTx0k

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

AG

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

Email

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

Service Unavailable
Your profile couldn't be loaded.Request ID:
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