Record page
One record: its name, a sticky line tab bar, activity on the left and a 21rem attribute rail.
What it's for#
A record page is one deal, company or person: who and what it is, where it stands, what happened, and every attribute you can change.
It is a document page that splits in two from 1024px. The left column reads top to bottom, from the record's name through its stage to tabs of overview and activity. The right column is a 21rem rail of attributes that edit in place. People arrive from a list page or a search and move to the next record with J and K.
| Route | Record |
|---|---|
| /deals/d_northwind | A deal: stage track with exit criteria, line items, buying committee, files and notes. |
| /companies/c_northwind | A company: overview, activity, people, deals and notes, with firmographics in the rail. |
| /people/p_dana | A person: overview, activity, emails and notes, with contact details in the rail. |
Live composition#
The Northwind Freight deal at 1280px. Click a stage, tick the exit criteria to fill the move button, switch tabs, click an amount to edit it, or collapse a rail section.
import { Button } from "@oration/canon/components/button";import { Checkbox } from "@oration/canon/components/checkbox";import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@oration/canon/components/collapsible";import { EmptyState } from "@oration/canon/components/data-state";import { EditableText } from "@oration/canon/components/editable-text";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { StatusLabel } from "@oration/canon/components/status-dot";import { Stepper } from "@oration/canon/components/stepper";import { Tabs, TabsContent, TabsList, TabsTrigger } from "@oration/canon/components/tabs";import { Tag } from "@oration/canon/components/tag";import { Timeline, TimelineBody, TimelineHeader, TimelineItem, TimelineTime,} from "@oration/canon/components/timeline";import { toast } from "@oration/canon/components/toast";import { ArrowRightIcon, CalendarIcon, ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, CircleDollarSignIcon, GaugeIcon, HeartPulseIcon, MailIcon, MoreHorizontalIcon, PanelRightIcon, PhoneIcon, SparklesIcon, TrendingUpIcon, UserIcon, XIcon,} from "lucide-react";import * as React from "react";import { AppShell } from "../document-page/frame";export function DealRecord() { const steps = [ { id: "discovery", label: "Discovery" }, { id: "proposal", label: "Proposal" }, { id: "negotiation", label: "Negotiation" }, { id: "won", label: "Closed won" }, ]; const [stage, setStage] = React.useState("negotiation"); const [exitDone, setExitDone] = React.useState(false); const [amount, setAmount] = React.useState("$186,000.00"); const [close, setClose] = React.useState("Oct 2, 2026"); const [details, setDetails] = React.useState(false); const current = steps.find((step) => step.id === stage); const nextStep = steps[steps.findIndex((step) => step.id === stage) + 1]; const changeStage = (id: string) => { const previous = stage; setStage(id); setExitDone(false); toast.add({ title: `Moved to ${steps.find((step) => step.id === id)?.label}`, description: "Tally logged the change on the deal.", actionProps: { children: "Undo", onClick: () => setStage(previous), }, }); }; const onKeyDown = (event: React.KeyboardEvent) => { const target = event.target as HTMLElement; if (target.closest("input, textarea, [role=tab]")) return; if (event.metaKey || event.ctrlKey || event.altKey) return; if (event.key === "j" || event.key === "k") { event.preventDefault(); toast.add({ title: event.key === "j" ? "Next deal" : "Previous deal", description: event.key === "j" ? "Vendor portal expansion, Halcyon" : "Multi-entity AP, Orchard Street", }); } }; const row = ( icon: React.ReactNode, label: string, value: React.ReactNode, ) => ( <div key={label} className="grid min-h-8 grid-cols-[7.25rem_minmax(0,1fr)] items-center gap-1 text-13" > <dt className="flex min-w-0 items-center gap-2 px-1.5 text-muted-foreground [&>svg]:size-3.5 [&>svg]:shrink-0"> {icon} <span className="truncate">{label}</span> </dt> <dd className="flex min-w-0 items-center">{value}</dd> </div> ); const plain = (value: React.ReactNode) => ( <span className="flex min-w-0 items-center gap-1.5 truncate px-1.5 py-1"> {value} </span> ); const section = (title: string, children: React.ReactNode) => ( <Collapsible key={title} defaultOpen className="border-b border-border px-3 py-3 last:border-b-0" > <h2> <CollapsibleTrigger className="group flex h-7 w-full items-center gap-1 rounded-md px-1.5 text-13 font-medium outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/40"> <ChevronRightIcon aria-hidden="true" className="size-3.5 text-muted-foreground transition-transform duration-150 group-data-[panel-open]:rotate-90" /> {title} </CollapsibleTrigger> </h2> <CollapsibleContent> <dl className="flex flex-col pt-1">{children}</dl> </CollapsibleContent> </Collapsible> ); const attributes = ( <> {section( "Deal", <> {row( <CircleDollarSignIcon />, "Amount", <EditableText label="Amount" value={amount} onCommit={setAmount} className="h-7 tabular-nums" />, )} {row( <TrendingUpIcon />, "Stage", plain( <Tag color="amber" dot> {current?.label} </Tag>, ), )} {row( <CalendarIcon />, "Close date", <EditableText label="Close date" value={close} onCommit={setClose} className="h-7 tabular-nums" />, )} {row(<UserIcon />, "Owner", plain("Maya Okafor"))} {row( <GaugeIcon />, "Forecast", plain(<Tag color="indigo">Commit</Tag>), )} {row( <GaugeIcon />, "Probability", plain(<span className="tabular-nums">70%</span>), )} {row( <HeartPulseIcon />, "Health", plain( <StatusLabel tone="success">On track</StatusLabel>, ), )} {row( <CalendarIcon />, "In stage", plain(<span className="tabular-nums">12 days</span>), )} </>, )} {section( "Company", <> <div className="mb-1 flex items-center gap-2.5 rounded-lg px-1.5 py-1.5 hover:bg-muted"> <MonogramTile name="Northwind Freight" color="teal" size="lg" /> <span className="flex min-w-0 flex-col"> <span className="truncate text-13 font-medium"> Northwind Freight </span> <span className="text-xs text-muted-foreground"> northwindfreight.com </span> </span> </div> {row( <TrendingUpIcon />, "Lifecycle", plain(<Tag color="blue">Opportunity</Tag>), )} {row(<GaugeIcon />, "ICP fit", plain("Strong"))} {row( <UserIcon />, "Invoices a month", plain(<span className="tabular-nums">22,000</span>), )} </>, )} {section( "Products", <div className="flex flex-wrap gap-1.5 px-1.5 py-1"> <Tag>Invoice capture</Tag> <Tag>Vendor portal</Tag> <Tag>Payment runs</Tag> </div>, )} </> ); return ( <AppShell active="Deals" crumbs={[ { label: "Deals", icon: <CircleDollarSignIcon className="size-3.5" />, }, { label: "Invoice automation rollout" }, ]} actions={ <> <span className="flex items-center"> <Button variant="ghost" size="icon-sm" aria-label="Previous deal" onClick={() => toast.add({ title: "Previous deal" }) } > <ChevronUpIcon aria-hidden="true" /> </Button> <Button variant="ghost" size="icon-sm" aria-label="Next deal" onClick={() => toast.add({ title: "Next deal" })} > <ChevronDownIcon aria-hidden="true" /> </Button> </span> <Button variant="ghost" size="icon-sm" aria-label="More actions" > <MoreHorizontalIcon aria-hidden="true" /> </Button> <Button variant="ghost" size="icon-sm" aria-label="Show details" className="@min-[1024px]:hidden" onClick={() => setDetails(true)} > <PanelRightIcon aria-hidden="true" /> </Button> </> } > <section aria-label="Invoice automation rollout" className="relative flex min-h-0 flex-1" onKeyDown={onKeyDown} > <div className="min-w-0 flex-1 overflow-y-auto"> <div className="flex flex-col gap-4 px-6 pt-6 pb-4 @min-[640px]:px-8"> <div className="flex items-start gap-4"> <MonogramTile name="Northwind Freight" color="teal" size="xl" /> <div className="min-w-0 flex-1"> <h1 className="text-xl font-semibold tracking-[-0.015em]"> Invoice automation rollout </h1> <div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground"> <span className="font-medium text-foreground"> Northwind Freight </span> <span className="tabular-nums"> {amount} </span> <span className="tabular-nums"> Closes {close} </span> </div> <div className="mt-2.5 flex flex-wrap items-center gap-1.5"> <Tag color="amber" dot> {current?.label} </Tag> <StatusLabel tone="success"> On track </StatusLabel> <Tag color="indigo">Commit</Tag> </div> </div> </div> <div className="flex flex-wrap items-center gap-2"> <Button variant="outline" size="sm"> <MailIcon data-icon="inline-start" aria-hidden="true" /> Email contacts </Button> <Button variant="outline" size="sm"> <PhoneIcon data-icon="inline-start" aria-hidden="true" /> Log activity </Button> <Button variant="ghost" size="sm"> <SparklesIcon data-icon="inline-start" aria-hidden="true" /> Ask an agent </Button> </div> </div> <div className="flex flex-col gap-3 px-6 pb-5 @min-[640px]:px-8"> <Stepper steps={steps} current={stage} label="Deal stage" onStepSelect={changeStage} /> {nextStep ? ( <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl bg-surface px-3.5 py-2.5"> <label className="flex min-w-0 items-center gap-2.5 text-13"> <Checkbox checked={exitDone} onCheckedChange={setExitDone} /> <span className="text-muted-foreground"> To leave {current?.label}: </span> <span className="font-medium"> Redlines returned and signer named </span> </label> <Button variant={exitDone ? "default" : "outline"} size="sm" onClick={() => changeStage(nextStep.id)} > Move to {nextStep.label} <ArrowRightIcon data-icon="inline-end" aria-hidden="true" /> </Button> </div> ) : ( <p className="rounded-xl bg-surface px-3.5 py-2.5 text-13"> Closed won on Sep 28. </p> )} </div> <Tabs defaultValue="overview" className="gap-0"> <div className="sticky top-0 z-10 border-b border-border bg-background px-6 @min-[640px]:px-8"> <TabsList variant="line" scrollable className="h-10 gap-3" > <TabsTrigger value="overview" className="px-0"> Overview </TabsTrigger> <TabsTrigger value="activity" className="px-0"> Activity </TabsTrigger> <TabsTrigger value="committee" className="px-0"> Buying committee{" "} <span className="text-xs font-normal text-muted-foreground tabular-nums"> 3 </span> </TabsTrigger> <TabsTrigger value="files" className="px-0"> Files </TabsTrigger> <TabsTrigger value="notes" className="px-0"> Notes </TabsTrigger> </TabsList> </div> <TabsContent value="overview" className="px-6 py-6 @min-[640px]:px-8" > <div className="flex max-w-3xl flex-col gap-8"> <section className="flex flex-col gap-3"> <h2 className="flex min-h-7 items-center text-sm font-semibold"> Next step </h2> <div className="flex flex-col gap-1 rounded-xl bg-card p-4 shadow-border"> <p className="text-13 font-medium"> Walk Jordan through the 3-year option </p> <p className="text-13 text-muted-foreground"> Wednesday, Sep 30 at 2:00 PM, before Friday's review with their CFO. </p> </div> </section> <section className="flex flex-col gap-3"> <h2 className="flex min-h-7 items-center text-sm font-semibold"> Line items </h2> <div className="overflow-hidden rounded-xl bg-card shadow-border"> <table className="w-full 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" > Product </th> <th scope="col" className="px-3 text-right font-medium" > Quantity </th> <th scope="col" className="px-4 text-right font-medium" > Annual </th> </tr> </thead> <tbody> {[ [ "Invoice capture", "22,000 a month", "$118,000.00", ], [ "Vendor portal", "1,400 suppliers", "$44,000.00", ], [ "Payment runs", "9 entities", "$24,000.00", ], ].map( ([ product, quantity, annual, ]) => ( <tr key={product} className="border-b border-border last:border-b-0" > <td className="h-9 px-4"> {product} </td> <td className="px-3 text-right text-muted-foreground tabular-nums"> {quantity} </td> <td className="px-4 text-right tabular-nums"> {annual} </td> </tr> ), )} </tbody> </table> </div> </section> </div> </TabsContent> <TabsContent value="activity" className="px-6 py-6 @min-[640px]:px-8" > <Timeline className="max-w-3xl"> <TimelineItem icon={<MailIcon />} tone="primary" > <TimelineHeader> <strong>Quill</strong> <span> drafted a reply to Jordan Lee </span> <TimelineTime> Today, 7:06 AM </TimelineTime> </TimelineHeader> <TimelineBody className="text-muted-foreground"> Waiting for Maya's approval on Home. </TimelineBody> </TimelineItem> <TimelineItem icon={<TrendingUpIcon />} tone="success" > <TimelineHeader> <strong>Maya Okafor</strong> <span> moved the deal to Negotiation </span> <TimelineTime> Sep 16, 3:40 PM </TimelineTime> </TimelineHeader> </TimelineItem> <TimelineItem icon={<PhoneIcon />}> <TimelineHeader> <strong>Priya Raman</strong> <span> logged a call with Jordan Lee </span> <TimelineTime> Sep 12, 11:05 AM </TimelineTime> </TimelineHeader> </TimelineItem> </Timeline> </TabsContent> <TabsContent value="committee" className="px-6 py-6 @min-[640px]:px-8" > <ul className="flex max-w-3xl flex-col rounded-xl bg-card shadow-border"> {[ ["Jordan Lee", "AP manager", "Champion"], [ "Tomás Ferreira", "Controller", "Evaluator", ], ["Aisha Bello", "CFO", "Signer"], ].map(([name, title, role]) => ( <li key={name} className="flex items-center gap-3 border-b border-border px-4 py-2.5 text-13 last:border-b-0" > <span className="font-medium"> {name} </span> <span className="text-muted-foreground"> {title} </span> <Tag className="ml-auto">{role}</Tag> </li> ))} </ul> </TabsContent> <TabsContent value="files" className="px-6 py-6 @min-[640px]:px-8" > <EmptyState size="sm" illustration="inbox" title="No files yet" description="Order forms and security packets attached to emails land here." /> </TabsContent> <TabsContent value="notes" className="px-6 py-6 @min-[640px]:px-8" > <EmptyState size="sm" illustration="knowledge" title="No notes yet" description="Notes you write here are shared with everyone on the deal." /> </TabsContent> </Tabs> </div> <aside aria-label="Invoice automation rollout details" className="hidden w-[21rem] shrink-0 overflow-y-auto border-l border-border bg-background @min-[1024px]:block" > {attributes} </aside> {details ? ( <div className="absolute inset-0 z-40 @min-[1024px]:hidden"> <button type="button" aria-label="Close details" className="absolute inset-0 bg-black/25" onClick={() => setDetails(false)} /> <div role="dialog" aria-label="Invoice automation rollout details" className="absolute inset-y-0 right-0 flex w-[22rem] max-w-[90%] flex-col bg-popover shadow-lg" > <div className="flex items-center gap-2 border-b border-border px-4 py-3"> <p className="min-w-0 flex-1 truncate text-base leading-none font-medium"> Invoice automation rollout details </p> <Button variant="ghost" size="icon-sm" aria-label="Close" onClick={() => setDetails(false)} > <XIcon aria-hidden="true" /> </Button> </div> <div className="min-h-0 flex-1 overflow-y-auto"> {attributes} </div> </div> </div> ) : null} </section> </AppShell> );}Anatomy#
Seven parts. One through six scroll together in the record column; the rail scrolls on its own.
- App header. Crumbs end in the record's name with a switcher. Actions are the previous and next pager, favorite, a more menu, and the details button below 1024px. No filled button here.
- Record hero. A 48px Monogram tile or avatar, the record name as the page's one
h1in Headline, a subtitle line and meta tags. - Record actions. Outline and ghost buttons for the record's common jobs: email, log activity, ask an agent.
- Stage track. Stepper: 4px segments, Ink 25 done, Ink 80 current, Well Gray upcoming. Under it, the exit criteria; the move button fills only once they are met.
- Line tab bar. Tabs in the line variant, 40px, sticky at the top of the record column so it rides under the header. It scrolls and fades its clipped edge.
- Tab content. Sections 32px apart, capped at 48rem. Overview leads with the next step; Activity is a Timeline.
- Attribute rail. 21rem with a hairline left edge, never a shadow. Collapsible sections of 32px label-left rows; values edit in place with Editable text and selects.
Measurements#
From DESIGN.md's Layout, Navigation and Meters sections, and RecordShell.
| Part | Value | Classes |
|---|---|---|
| Column | 100svh, two scrollers | flex h-svh min-h-0 flex-col |
| Record gutters | 24px, 32px from 640px | px-6 sm:px-8 |
| Hero media | 48px, 12px corners | MonogramTile size="xl" |
| Record name | 20px Headline | text-xl font-semibold tracking-[-0.015em] |
| Stage segments | 4px tall, 4px apart | Stepper |
| Tab bar | 40px, sticky | sticky top-0 z-10, h-10 gap-3 |
| Tab content | 48rem maximum | max-w-3xl, sections gap-8 |
| Attribute rail | 21rem from 1024px | hidden w-[21rem] border-l lg:block |
| Rail rows | 32px minimum, 7.25rem labels | grid min-h-8 grid-cols-[7.25rem_minmax(0,1fr)] |
| Details sheet | 22rem, 90vw at most | w-[22rem] max-w-[90vw] |
Responsive behavior#
The rail is the only part that moves. Below 1024px it becomes a right sheet behind the header's details button.
From 640px gutters are 32px and every stage label shows. The attribute rail is still a sheet: press the details button in the header.
| Width | What changes |
|---|---|
| 390px | 24px gutters. The stage track shows only the current label. Tabs scroll with a faded edge. Attributes open in a sheet. |
| 768px | 32px gutters from 640px; every stage label shows. Attributes are still a sheet. |
| 1024px | The 21rem rail sits beside the record and the details button hides. |
| 1440px | The record column widens; tab content stays at 48rem. |
States#
A record page has one page-level state, missing, and the rest belong to its parts.
| Region | States it owns |
|---|---|
| Page | Missing: an Empty centered in the viewport, This deal isn't available, with Back to deals. |
| Stage track | Done, current and upcoming segments; clickable when the stage can change. Won and lost replace the exit row with a line of outcome. |
| Exit criteria | Unmet keeps the move button outline; met fills it. |
| Tabs | Each panel owns loading and empty. Counts trail the label in 12px Slate Meta. |
| Attribute rows | Read, editing, and empty as Empty in Faint Slate. Read-only values are plain text with no hover. |
The line tab bar
Seven tabs in a narrow column. The list scrolls sideways and fades its clipped edge, per the Scroll Edge Rule.
import { Kbd } from "@oration/canon/components/kbd";import { Tabs, TabsContent, TabsList, TabsTrigger } from "@oration/canon/components/tabs";import { cn } from "@oration/canon/lib/utils";export function TabBar() { const tabs = [ "Overview", "Activity", "Buying committee", "Files", "Notes", "Emails", "Meetings", ]; return ( <div className="w-full max-w-sm overflow-hidden rounded-xl bg-card shadow-border"> <Tabs defaultValue="Overview" className="gap-0"> <div className="border-b border-border px-4"> <TabsList variant="line" scrollable className="h-10 gap-3"> {tabs.map((tab) => ( <TabsTrigger key={tab} value={tab} className="px-0"> {tab} </TabsTrigger> ))} </TabsList> </div> {tabs.map((tab) => ( <TabsContent key={tab} value={tab} className={cn( "px-4 py-6 text-13 text-muted-foreground", )} > {tab} for Invoice automation rollout. Scroll the tabs sideways; the clipped edge fades over 2rem. With a tab focused, <Kbd>→</Kbd> moves to the next one. </TabsContent> ))} </Tabs> </div> );}Stepper has no lost or skipped stage, so a deal that closed lost can't be drawn on the track; the product swaps the exit row for a sentence instead. Record pages also read synchronously today and draw no loading state.
Keyboard#
| Keys | Action |
|---|---|
| J | Open the next record in the list you came from. |
| K | Open the previous record. |
| ←→ | Move between tabs once the tab bar has focus. |
| Enter | Edit an attribute value; Enter again commits it. |
| Esc | Cancel an edit and restore the value. |
Starter code#
RecordShell, then the record column. The sheet and the rail render the same aside.
// components/records/record/record-shell.tsxexport function RecordShell({ crumbs, actions, aside, asideTitle, children }: RecordShellProps) { return ( <div className="flex h-svh min-h-0 flex-col"> <AppHeader crumbs={crumbs} actions={ <> {actions} {/* Below 1024px the rail moves into a sheet */} <Sheet> <SheetTrigger render={<Button variant="ghost" size="icon-sm" aria-label="Show details" className="lg:hidden" />}> <PanelRightIcon /> </SheetTrigger> <SheetContent side="right" className="w-[22rem] max-w-[90vw] gap-0 p-0"> <SheetHeader className="border-b border-border"> <SheetTitle>{asideTitle}</SheetTitle> <SheetDescription className="sr-only">Record attributes</SheetDescription> </SheetHeader> <div className="min-h-0 flex-1 overflow-y-auto">{aside}</div> </SheetContent> </Sheet> </> } /> <div className="flex min-h-0 flex-1"> <div className="min-w-0 flex-1 overflow-y-auto">{children}</div> <aside aria-label={asideTitle} className="hidden w-[21rem] shrink-0 overflow-y-auto border-l border-border bg-background lg:block"> {aside} </aside> </div> </div> );}// The record column: hero, stage track, then line tabs that stick under the header<RecordShell crumbs={crumbs} actions={<RecordPager … />} aside={<DealProperties deal={deal} />} asideTitle={`${deal.name} details`}> <RecordHero media={<MonogramTile name={company.name} color={company.color} size="xl" />} title={deal.name} subtitle={…} meta={…} actions={…} /> <div className="px-6 pb-5 sm:px-8"> <Stepper steps={stages} current={deal.stage} label="Deal stage" onStepSelect={changeStage} /> </div> <Tabs defaultValue="overview" className="gap-0"> {/* top-0, not top-12: the record column is its own scroller under the header */} <div className="sticky top-0 z-10 border-b border-border bg-background px-6 sm:px-8"> <TabsList variant="line" scrollable className="h-10 gap-3"> <TabsTrigger value="overview" className="px-0">Overview</TabsTrigger> <TabsTrigger value="activity" className="px-0">Activity</TabsTrigger> </TabsList> </div> <TabsContent value="overview" className="px-6 py-6 sm:px-8"> <div className="flex max-w-3xl flex-col gap-8">…</div> </TabsContent> </Tabs></RecordShell>- The tab bar sticks at
top-0of the record column, which is its own scroller under the 48px header.top-12would leave a gap. - Name the rail with
aria-labeland the sheet with a title, both from the record name.
Rules that apply#
The Hairline-and-Lift Rule
shadow-border), never from a CSS border plus a shadow. CSS borders are for structural dividers only: table rules, header bottoms, section splits.