Skip to content

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.

Routes built on the document page
RouteWhat it shows
/contact-center/queuesA stat strip, a live table of queues and their service levels.
/contact-center/callbacksCalls the team promised to return, including ones Nora and queue overflow scheduled.
/reportsRevenue reports for the selected range, one section per report.
/meetingsPrep briefs before each call, recordings and recaps after.
/ai/analyticsAgent performance charts and tables in the 72rem column.
/settings/workspaceA 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.

Queues

Where supplier calls and chats wait for a person, how they're routed, and whether each queue is meeting its service level.

Waiting now
12
Answered within SLA
84.2%Up 2.1%
Abandoned
3.1%Down 0.4%
Average wait
1:48

Every queue, live

Updated 9:41 AM
QueueWaitingLongest waitService levelAgents online
Payments and remittances63:42
82%On target
5 of 7
Supplier onboarding21:05
94%On target
3 of 4
Invoice disputes46:18
61%Below target
2 of 3
W-9 and tax forms00:00
100%On target
2 of 2

Callbacks due today

All callbacks
  • 10:30 AMTomás Ferreira, HalcyonAsked about the Sep 30 remittance
  • 11:15 AMWen Zhou, Orchard StreetW-9 rejected for a TIN mismatch
  • 2:00 PMJordan Lee, Northwind FreightINV-20944 is on hold for a missing PO
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.

Details

How Nora introduces herself and who answers for her. Changes apply to the next call.

Shown to teammates on the agent's page and in handoff notes.

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.

  1. App header. The 48px sticky shell header with breadcrumbs and page-level actions such as Export. It never holds the page title.
  2. Page title. Exactly one h1, drawn by Page title in Headline (600, 20px). The description sits under it in Body, capped at 42rem.
  3. 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.
  4. 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.
  5. 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.

Document page measurements
MeasureValueClasses
App header48pxh-12, sticky, hairline bottom
Column width72rem (1152px)mx-auto w-full max-w-6xl
Narrow form column48rem (768px)max-w-3xl
Side gutters24px, 16px below 640pxpx-4 sm:px-6
Top and bottom padding32pxpy-8
Between sections32pxgap-8
Section heading to content12pxgap-3
Page descriptionup to 42remmax-w-2xl in Page title
Card padding16pxp-4
Controls32pxh-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.

Queues

Where supplier calls and chats wait for a person, how they're routed, and whether each queue is meeting its service level.

Waiting now
12
Answered within SLA
84.2%Up 2.1%
Abandoned
3.1%Down 0.4%
Average wait
1:48

Every queue, live

Updated 9:41 AM
QueueWaitingLongest waitService levelAgents online
Payments and remittances63:42
82%On target
5 of 7
Supplier onboarding21:05
94%On target
3 of 4
Invoice disputes46:18
61%Below target
2 of 3
W-9 and tax forms00:00
100%On target
2 of 2

Callbacks due today

All callbacks
  • 10:30 AMTomás Ferreira, HalcyonAsked about the Sep 30 remittance
  • 11:15 AMWen Zhou, Orchard StreetW-9 rejected for a TIN mismatch
  • 2:00 PMJordan Lee, Northwind FreightINV-20944 is on hold for a missing PO

From 1024px nothing new appears on a document page. The column grows until it reaches 72rem.

Document page at each width
WidthWhat changes
390px16px 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.
768px24px gutters (from 640px). The sidebar is a 16rem rail. The column fills the plane.
1024pxNo new parts. Sections that were stacked may sit side by side if the page designs for it with lg:grid-cols-2.
1440pxThe 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.

States by region
RegionStates it owns
PageNot found and no access, drawn with Empty or EmptyState in place of the column. The title and header never skeleton.
Title actionsHidden when the viewer can't act, rather than disabled with no reason. See Permissions.
Each sectionLoading 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 formsDirty, 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.

A document page
// 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 h1 per page, from Page title. Section headings are h2 at Title size, not Headline.
  • Give every section an accessible name with aria-labelledby or aria-label.
  • Page-level actions go in Page title's actions, or in the header's actions when 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

Each view has at most one filled indigo button, the action the view exists for. Everything else is outline, secondary, ghost, link or a destructive tint. In a stack of proposals, only the expanded one shows its filled button.

The Tint Well Rule

Inside a card, a sub-region is a Well Gray tint at 70% with a 10px radius, never a second bordered or shadowed card. No card is ever nested in a card.

The Owned States Rule

Every component that shows data owns its loading, empty and error states, with a skeleton shaped like its final layout. A whole page is never gated on loading.

The Sentence Case Rule

Everything is sentence case. No eyebrow or kicker label above a heading, no uppercase labels, no letter-spaced captions.

Do and don't#

Queues

Every queue, live

Callbacks due today

Do. Give the page one Headline title and step section headings down to Title.

Queues

Every queue, live

Callbacks due today

Don't. Set every section heading in Headline. The page loses its one entry point and reads as a stack of pages.
Do. Center the sections in the 72rem column and let wide screens add margin.
Don't. Stretch a document page edge to edge. Rows get too long to read and the page looks like a tool.
Do. Separate sections with 32px of plane and let each be its own card, table or strip.
Don't. Wrap the whole page in one card and nest the sections inside it.