Document page
The centered 72rem column for overviews, settings and records, with one h1 and 32px sections.
What it's for#
The document page is the default shape for anything people read top to bottom: overviews, reports, settings and forms. It scrolls with the window and never fills the viewport edge to edge.
Reach for it when the page is a set of sections about one subject, each answering a question in turn. If the page is one big grid, board or canvas that people work inside, use the List page or Builder canvas template instead. Home, record pages and settings are document pages with extra parts, and each has its own template.
| Route | What it shows |
|---|---|
| /contact-center/queues | A stat strip, a live table of queues and their service levels. |
| /contact-center/callbacks | Calls the team promised to return, including ones Nora and queue overflow scheduled. |
| /reports | Revenue reports for the selected range, one section per report. |
| /meetings | Prep briefs before each call, recordings and recaps after. |
| /ai/analytics | Agent performance charts and tables in the 72rem column. |
| /settings/workspace | A narrow 48rem form inside the settings layout. |
Live composition#
Real Canon components with Cedarline data, drawn at 1280px and scaled to fit. Switch the range, call a supplier back, or collapse the sidebar.
An overview page
The Contact Center queues page: one h1 through Page title, a stat strip, a table card and a list card, 32px apart. The one filled button is New queue.
import { Button } from "@oration/canon/components/button";import { Meter } from "@oration/canon/components/meter";import { PageTitle } from "@oration/canon/components/page-title";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { Stat, StatStrip } from "@oration/canon/components/stat";import { StatusLabel } from "@oration/canon/components/status-dot";import { toast } from "@oration/canon/components/toast";import { ArrowUpRightIcon, DownloadIcon, PhoneIcon } from "lucide-react";import * as React from "react";import { AppShell } from "./frame";export function QueuesPage() { const queues = [ { name: "Payments and remittances", waiting: 6, longest: "3:42", level: 82, online: "5 of 7", }, { name: "Supplier onboarding", waiting: 2, longest: "1:05", level: 94, online: "3 of 4", }, { name: "Invoice disputes", waiting: 4, longest: "6:18", level: 61, online: "2 of 3", }, { name: "W-9 and tax forms", waiting: 0, longest: "0:00", level: 100, online: "2 of 2", }, ]; const callbacks = [ { name: "Tomás Ferreira", company: "Halcyon", reason: "Asked about the Sep 30 remittance", at: "10:30 AM", }, { name: "Wen Zhou", company: "Orchard Street", reason: "W-9 rejected for a TIN mismatch", at: "11:15 AM", }, { name: "Jordan Lee", company: "Northwind Freight", reason: "INV-20944 is on hold for a missing PO", at: "2:00 PM", }, ]; const [range, setRange] = React.useState<"today" | "week">("today"); const today = range === "today"; return ( <AppShell active="Contact Center" crumbs={[{ label: "Contact Center" }, { label: "Queues" }]} actions={ <Button variant="outline" size="sm" onClick={() => toast.add({ title: "Export started", description: "queues-sep-28.csv will download in a moment.", }) } > <DownloadIcon data-icon="inline-start" aria-hidden="true" /> Export </Button> } > <main className="min-h-0 flex-1 overflow-y-auto"> <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-8 @min-[640px]:px-6"> <PageTitle title="Queues" description="Where supplier calls and chats wait for a person, how they're routed, and whether each queue is meeting its service level." actions={ <> <SegmentedControl label="Range" value={range} onValueChange={setRange} options={[ { value: "today", label: "Today" }, { value: "week", label: "This week" }, ]} /> <Button size="sm" onClick={() => toast.add({ title: "New queue", description: "Name it, pick a channel and set its service level.", }) } > New queue </Button> </> } /> <section aria-label="Service summary"> <StatStrip> <Stat label="Waiting now" value="12" /> <Stat label="Answered within SLA" value={today ? "84.2%" : "86.9%"} delta={{ value: "2.1%", direction: "up", good: true, }} /> <Stat label="Abandoned" value={today ? "3.1%" : "2.7%"} delta={{ value: "0.4%", direction: "down", good: true, }} /> <Stat label="Average wait" value={today ? "1:48" : "1:31"} /> </StatStrip> </section> <section aria-labelledby="queues-live" className="flex flex-col gap-3" > <div className="flex items-baseline justify-between gap-4"> <h2 id="queues-live" className="text-sm font-semibold" > Every queue, live </h2> <span className="text-xs text-muted-foreground"> Updated 9:41 AM </span> </div> <div className="overflow-x-auto rounded-xl bg-card shadow-border"> <table className="w-full min-w-[40rem] text-13"> <thead> <tr className="border-b border-border text-xs text-muted-foreground"> <th scope="col" className="h-8 px-4 text-left font-medium" > Queue </th> <th scope="col" className="px-3 text-right font-medium" > Waiting </th> <th scope="col" className="px-3 text-right font-medium" > Longest wait </th> <th scope="col" className="px-3 text-left font-medium" > Service level </th> <th scope="col" className="px-4 text-right font-medium" > Agents online </th> </tr> </thead> <tbody> {queues.map((queue) => ( <tr key={queue.name} className="border-b border-border last:border-b-0" > <td className="h-11 px-4 font-medium text-foreground"> {queue.name} </td> <td className="px-3 text-right tabular-nums"> {queue.waiting} </td> <td className="px-3 text-right text-muted-foreground tabular-nums"> {queue.longest} </td> <td className="px-3"> <div className="flex items-center gap-3"> <Meter className="w-24" size="sm" max={100} value={queue.level} target={80} label={`${queue.name} service level`} /> <span className="w-9 text-right tabular-nums"> {queue.level}% </span> <StatusLabel tone={ queue.level >= 80 ? "success" : "warning" } > {queue.level >= 80 ? "On target" : "Below target"} </StatusLabel> </div> </td> <td className="px-4 text-right text-muted-foreground tabular-nums"> {queue.online} </td> </tr> ))} </tbody> </table> </div> </section> <section aria-labelledby="callbacks-due" className="rounded-xl bg-card shadow-border" > <div className="flex items-baseline justify-between gap-4 px-4 pt-4 pb-2"> <h2 id="callbacks-due" className="text-sm font-semibold" > Callbacks due today </h2> <span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground"> All callbacks <ArrowUpRightIcon aria-hidden="true" className="size-3" /> </span> </div> <ul className="flex flex-col px-2 pb-2"> {callbacks.map((item) => ( <li key={item.name} className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors duration-150 hover:bg-muted" > <span className="w-16 shrink-0 text-xs text-muted-foreground tabular-nums"> {item.at} </span> <span className="flex min-w-0 flex-1 flex-col"> <span className="truncate text-13 font-medium"> {item.name}, {item.company} </span> <span className="truncate text-xs text-muted-foreground"> {item.reason} </span> </span> <Button variant="ghost" size="sm" onClick={() => toast.add({ title: `Calling ${item.name}`, description: "Dialing the number on file.", }) } > <PhoneIcon data-icon="inline-start" aria-hidden="true" /> Call </Button> </li> ))} </ul> </section> </div> </main> </AppShell> );}A narrow form
The same page shape with the column capped at 48rem, for a single form. Edit a field to raise the save bar.
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { NativeSelect, NativeSelectOption } from "@oration/canon/components/native-select";import { PageTitle } from "@oration/canon/components/page-title";import { SaveBar } from "@oration/canon/components/save-bar";import { Textarea } from "@oration/canon/components/textarea";import { toast } from "@oration/canon/components/toast";import { useDirtyForm } from "@oration/canon/hooks/use-dirty-form";import { AppShell } from "./frame";export function NarrowForm() { const form = useDirtyForm( { name: "Nora", purpose: "Answers supplier calls about invoices, remittances and W-9s, and hands anything about bank details to a person.", greeting: "Thanks for calling Cedarline accounts payable. This is Nora.", owner: "priya", }, { onSave: () => new Promise<void>((resolve) => { window.setTimeout(() => { toast.add({ type: "success", title: "Changes saved" }); resolve(); }, 600); }), }, ); return ( <AppShell active="Agents" crumbs={[ { label: "Agents" }, { label: "Nora" }, { label: "Details" }, ]} > <main className="min-h-0 flex-1 overflow-y-auto"> <div className="mx-auto flex w-full max-w-3xl flex-col gap-8 px-4 py-8 @min-[640px]:px-6"> <PageTitle title="Details" description="How Nora introduces herself and who answers for her. Changes apply to the next call." /> <FieldGroup className="gap-5"> <Field> <FieldLabel htmlFor="agent-name">Name</FieldLabel> <Input id="agent-name" value={form.values.name} onChange={(event) => form.set("name", event.target.value) } /> </Field> <Field> <FieldLabel htmlFor="agent-purpose"> What Nora does </FieldLabel> <Textarea id="agent-purpose" value={form.values.purpose} onChange={(event) => form.set("purpose", event.target.value) } /> <FieldDescription> Shown to teammates on the agent's page and in handoff notes. </FieldDescription> </Field> <Field> <FieldLabel htmlFor="agent-greeting"> Greeting </FieldLabel> <Input id="agent-greeting" value={form.values.greeting} onChange={(event) => form.set("greeting", event.target.value) } /> </Field> <Field> <FieldLabel htmlFor="agent-owner">Owner</FieldLabel> <NativeSelect id="agent-owner" className="w-64" value={form.values.owner} onChange={(event) => form.set("owner", event.target.value) } > <NativeSelectOption value="priya"> Priya Raman </NativeSelectOption> <NativeSelectOption value="tomas"> Tomás Ferreira </NativeSelectOption> <NativeSelectOption value="aisha"> Aisha Bello </NativeSelectOption> </NativeSelect> </Field> </FieldGroup> <SaveBar dirty={form.isDirty} changes={form.dirtyCount} saving={form.saving} onDiscard={form.reset} onSave={form.save} warnOnLeave={false} /> </div> </main> </AppShell> );}Inside these frames the responsive classes are container queries at the real breakpoints, such as @min-[640px]:px-6, so the preview folds the way the app does. The starter below uses the viewport classes the product uses, such as sm:px-6.
Anatomy#
Five parts, in reading order. The header comes from the shell; everything else is the page's own.
- App header. The 48px sticky shell header with breadcrumbs and page-level actions such as Export. It never holds the page title.
- Page title. Exactly one
h1, drawn by Page title in Headline (600, 20px). The description sits under it in Body, capped at 42rem. - Title actions. Controls that act on the whole page, aligned to the title's top and wrapping under it on narrow screens. At most one is filled.
- Sections. Stat strips, cards, tables and lists, 32px apart. A section with a heading uses Title (600, 14px) with an optional 12px meta link on the same baseline.
- Column. A centered column up to 72rem with 24px gutters (16px below 640px) and 32px top and bottom padding. Narrow forms cap at 48rem.
Measurements#
From DESIGN.md's Layout section. Every number here is a class in the starter code.
| Measure | Value | Classes |
|---|---|---|
| App header | 48px | h-12, sticky, hairline bottom |
| Column width | 72rem (1152px) | mx-auto w-full max-w-6xl |
| Narrow form column | 48rem (768px) | max-w-3xl |
| Side gutters | 24px, 16px below 640px | px-4 sm:px-6 |
| Top and bottom padding | 32px | py-8 |
| Between sections | 32px | gap-8 |
| Section heading to content | 12px | gap-3 |
| Page description | up to 42rem | max-w-2xl in Page title |
| Card padding | 16px | p-4 |
| Controls | 32px | h-8, size="sm" buttons are 28px |
PageBody is max-w-6xl px-6 py-8, so it keeps 24px gutters on a phone where DESIGN.md asks for 16px. Several pages also drift: Queues widens to max-w-7xl, and Queues and Reports space sections 24px apart with gap-6. Follow DESIGN.md: max-w-6xl px-4 sm:px-6 and gap-8.
Responsive behavior#
A document page changes little across widths, which is the point. Pick a width; the frame is that viewport, sidebar included.
From 1024px nothing new appears on a document page. The column grows until it reaches 72rem.
| Width | What changes |
|---|---|
| 390px | 16px gutters. The sidebar is an 18rem sheet behind the header toggle. Title actions wrap under the description; the stat strip is two columns; wide tables scroll sideways inside their card. |
| 768px | 24px gutters (from 640px). The sidebar is a 16rem rail. The column fills the plane. |
| 1024px | No new parts. Sections that were stacked may sit side by side if the page designs for it with lg:grid-cols-2. |
| 1440px | The column stops at 72rem and centers. Extra width is margin. |
States#
The page itself has almost no states. Each section owns its own, so one slow or failed section never blanks the page.
| Region | States it owns |
|---|---|
| Page | Not found and no access, drawn with Empty or EmptyState in place of the column. The title and header never skeleton. |
| Title actions | Hidden when the viewer can't act, rather than disabled with no reason. See Permissions. |
| Each section | Loading as a skeleton of its own shape, empty with a sentence that teaches the first action, and error with a retry. Data state wires all three to a query. |
| Narrow forms | Dirty, saving and saved through the Save bar, plus field errors. See Saving. |
A section that owns its states
Switch states. The section keeps its header and its place in the page; only its body changes.
Callbacks due today
All callbacks- 10:30 AMTomás Ferreira, Halcyon
- 11:15 AMWen Zhou, Orchard Street
- 2:00 PMJordan Lee, Northwind Freight
import { EmptyState, ErrorState } from "@oration/canon/components/data-state";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { SkeletonRows } from "@oration/canon/components/skeletons";import * as React from "react";export function SectionStates() { const callbacks = [ { name: "Tomás Ferreira", company: "Halcyon", at: "10:30 AM" }, { name: "Wen Zhou", company: "Orchard Street", at: "11:15 AM" }, { name: "Jordan Lee", company: "Northwind Freight", at: "2:00 PM" }, ]; const [state, setState] = React.useState< "ready" | "loading" | "empty" | "error" >("ready"); return ( <div className="flex w-full max-w-xl flex-col items-center gap-4"> <SegmentedControl label="Section state" value={state} onValueChange={setState} options={[ { value: "ready", label: "Ready" }, { value: "loading", label: "Loading" }, { value: "empty", label: "Empty" }, { value: "error", label: "Error" }, ]} /> <section aria-labelledby="states-callbacks" aria-busy={state === "loading" || undefined} className="w-full rounded-xl bg-card shadow-border" > <div className="flex items-baseline justify-between gap-4 px-4 pt-4 pb-2"> <h3 id="states-callbacks" className="text-sm font-semibold"> Callbacks due today </h3> <span className="text-xs text-muted-foreground"> All callbacks </span> </div> {state === "loading" ? ( <SkeletonRows rows={3} className="px-4 pb-4" /> ) : state === "empty" ? ( <EmptyState size="sm" illustration="schedule" title="No callbacks due today" description="Callbacks that Nora or queue overflow schedules show up here." /> ) : state === "error" ? ( <ErrorState size="sm" title="Couldn't load callbacks" onRetry={() => setState("ready")} /> ) : ( <ul className="flex flex-col px-2 pb-2"> {callbacks.map((item) => ( <li key={item.name} className="flex items-center gap-3 rounded-lg px-2 py-2" > <span className="w-16 shrink-0 text-xs text-muted-foreground tabular-nums"> {item.at} </span> <span className="min-w-0 flex-1 truncate text-13 font-medium"> {item.name}, {item.company} </span> </li> ))} </ul> )} </section> </div> );}Starter code#
The skeleton every document page starts from. Swap in your sections; keep the column, the gutters and the gap.
// app/(app)/contact-center/queues/page.tsximport { Button } from "@oration/canon/components/button";import { PageTitle } from "@oration/canon/components/page-title";import Link from "next/link";import { AppHeader } from "@/components/shell/page-header";export default function QueuesPage() { return ( <> <AppHeader crumbs={[{ label: "Contact Center", href: "/contact-center" }, { label: "Queues" }]} actions={<Button variant="outline" size="sm">Export</Button>} /> <div className="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6"> <PageTitle title="Queues" description="Where supplier calls and chats wait for a person, and whether each queue is meeting its service level." actions={<Button size="sm">New queue</Button>} /> <section aria-label="Service summary"> <StatStrip>…</StatStrip> </section> <section aria-labelledby="queues-live" className="flex flex-col gap-3"> <div className="flex items-baseline justify-between gap-4"> <h2 id="queues-live" className="text-sm font-semibold">Every queue, live</h2> <Link href="/contact-center/analytics" className="text-xs text-muted-foreground hover:text-foreground"> Analytics </Link> </div> <div className="overflow-x-auto rounded-xl bg-card shadow-border">…</div> </section> </div> </> );}// A narrow form keeps the gutters and caps the column at 48rem<div className="mx-auto flex w-full max-w-3xl flex-col gap-8 px-4 py-8 sm:px-6"> <PageTitle title="Details" description="…" /> <FieldGroup className="gap-5">…</FieldGroup> <SaveBar dirty={form.isDirty} changes={form.dirtyCount} onDiscard={form.reset} onSave={form.save} /></div>- One
h1per page, from Page title. Section headings areh2at Title size, not Headline. - Give every
sectionan accessible name witharia-labelledbyoraria-label. - Page-level actions go in Page title's
actions, or in the header'sactionswhen they belong to the route rather than the content.
Rules that apply#
The named rules a document page most often breaks.
The One Filled Button Rule
The Tint Well Rule
The Owned States Rule
The Sentence Case Rule
Do and don't#
Queues
Every queue, live
Callbacks due today
Queues
Every queue, live
Callbacks due today