Spacing
A 4px rhythm with named gaps for controls, blocks, cards, gutters and sections.
A 4px base#
Every gap, padding and height is a multiple of 4px, with 6px as the one half step. The scale is Tailwind's own, so the token name is the class number.
| Token | Size | Scale | Classes | Used for |
|---|---|---|---|---|
| 1 | 4px | gap-1 p-1 | Menu padding, and tight stacks such as a name over its meta. | |
| 2 | 8px | gap-2 p-2 px-2 | Between inline items and buttons, list inset inside cards, grid cell padding, sidebar item padding. | |
| 3 | 12px | gap-3 px-3 | Between related blocks, app header side padding, well padding across. | |
| 4 | 16px | gap-4 p-4 | Card and dialog padding, rail gaps, gaps between cards. | |
| 6 | 24px | px-6 gap-6 | Page gutters from 640px. | |
| 8 | 32px | py-8 gap-8 | Page top and bottom padding, between page sections, between Home's columns. | |
| 1.5 | 6px | gap-1.5 px-1.5 | Icon to label inside controls, a status dot to its label, tag padding, page actions in the header. |
The scale is read from the DESIGN.md frontmatter. Bars are drawn at twice their size. Tailwind's spacing unit is 0.25rem, so gap-3 is 12px and h-9 is 36px; there is no custom spacing token to import.
Named gaps#
Five distances cover almost every layout decision. Pick by relationship, not by eye.
| Relationship | Gap | Classes |
|---|---|---|
| Inside controls and between inline items: icon to label, buttons in a row, a dot and its text | 6 to 8px | gap-1.5 gap-2 |
| Related blocks: a section header and its content, a label and its field group | 12px | gap-3 |
| Card padding, and gaps between rail cards | 16px | p-4 gap-4 |
| Page gutters, 16px below 640px | 24px | px-4 sm:px-6 |
| Page sections, page top and bottom padding | 32px | gap-8 py-8 |
A card with its spacing drawn
Every distance in this payment run card is one of the named gaps. Switch the view to hide the annotations.
Friday payment run
3 suppliers- Northwind Freight$12,480.00
- Halcyon$8,212.75
- Orchard Street$1,904.50
16px card padding and before the footer, 12px between the header and its list, 8px list inset and between buttons, 6px from icon to label.
import { Button } from "@oration/canon/components/button";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { CheckIcon, FileTextIcon } from "lucide-react";import * as React from "react";export function AnnotatedCard() { const [view, setView] = React.useState<"card" | "spacing">("spacing"); const on = view === "spacing"; const hatch: React.CSSProperties | undefined = on ? { backgroundImage: "repeating-linear-gradient(135deg, var(--border-strong) 0 1px, transparent 1px 6px)", } : undefined; const chip = "pointer-events-none absolute z-10 rounded-sm bg-foreground px-1 text-2xs leading-4 font-medium text-background tabular-nums"; const rows = [ { supplier: "Northwind Freight", amount: "$12,480.00" }, { supplier: "Halcyon", amount: "$8,212.75" }, { supplier: "Orchard Street", amount: "$1,904.50" }, ]; return ( <div className="flex w-full flex-col items-center gap-4"> <SegmentedControl label="Card view" value={view} onValueChange={setView} options={[ { value: "card", label: "Card" }, { value: "spacing", label: "Show spacing" }, ]} /> <div className="relative w-full max-w-sm rounded-xl bg-card p-4 shadow-border" style={hatch} > {on ? ( <span className={cn(chip, "top-0 left-1/2 -translate-x-1/2")} > 16 </span> ) : null} <div className="flex flex-col bg-card"> <div className="flex items-baseline justify-between gap-3"> <p className="text-sm font-semibold text-foreground"> Friday payment run </p> <span className="text-xs text-muted-foreground"> 3 suppliers </span> </div> <div aria-hidden="true" className="relative h-3" style={hatch} > {on ? ( <span className={cn( chip, "top-1/2 right-0 -translate-y-1/2", )} > 12 </span> ) : null} </div> <div className="relative rounded-[10px] bg-muted/70 p-2"> {on ? ( <span className={cn( chip, "top-0 left-0 -translate-y-1/2", )} > 8 </span> ) : null} <ul className="flex flex-col"> {rows.map((row) => ( <li key={row.supplier} className="flex h-8 items-center justify-between rounded-md px-2 text-13 hover:bg-muted" > <span className="flex min-w-0 items-center"> <FileTextIcon aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground" /> <span aria-hidden="true" className="relative h-3.5 w-1.5 shrink-0" style={hatch} /> <span className="truncate text-foreground"> {row.supplier} </span> </span> <span className="font-medium text-foreground tabular-nums"> {row.amount} </span> </li> ))} </ul> </div> <div aria-hidden="true" className="relative h-4" style={hatch} > {on ? ( <span className={cn( chip, "top-1/2 right-0 -translate-y-1/2", )} > 16 </span> ) : null} </div> <div className="flex items-center justify-end"> <Button type="button" variant="outline"> Edit run </Button> <span aria-hidden="true" className="relative h-8 w-2 shrink-0" style={hatch} > {on ? ( <span className={cn( chip, "-top-2 left-1/2 -translate-x-1/2", )} > 8 </span> ) : null} </span> <Button type="button" onClick={() => toast.add({ type: "success", title: "Payment run approved", description: "3 suppliers will be paid on Friday, Oct 2.", }) } > <CheckIcon data-icon="inline-start" aria-hidden="true" /> Approve run </Button> </div> </div> </div> {on ? ( <p className="max-w-sm text-center text-xs text-muted-foreground"> 16px card padding and before the footer, 12px between the header and its list, 8px list inset and between buttons, 6px from icon to label. </p> ) : null} </div> );}Control heights#
Controls are 32px. Toolbars use 28px, the smallest inline actions 24px, and touch-first layouts 36px.
Four control heights
Buttons, inputs, selects, segmented controls and filter chips share these heights, so controls in a row line up without extra classes.
h-6h-7h-8h-9import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";export function ControlHeights() { const sizes = [ { size: "xs", height: 24, className: "h-6" }, { size: "sm", height: 28, className: "h-7" }, { size: "default", height: 32, className: "h-8" }, { size: "lg", height: 36, className: "h-9" }, ] as const; return ( <div className="flex flex-wrap items-end justify-center gap-x-8 gap-y-6"> {sizes.map((item) => ( <div key={item.size} className="flex flex-col items-center gap-3" > <div className="flex items-end gap-2"> <span aria-hidden="true" className="w-px bg-foreground/40" style={{ height: item.height }} /> <Button type="button" variant="outline" size={item.size} onClick={() => toast.add({ title: `${item.height}px control`, description: `Button size "${item.size}", class ${item.className}.`, }) } > Export </Button> </div> <div className="flex flex-col items-center"> <span className="text-13 font-medium text-foreground tabular-nums"> {item.height}px </span> <code className="font-mono text-xs text-muted-foreground"> {item.className} </code> </div> </div> ))} </div> );}| Height | Class | Used for |
|---|---|---|
| 24px | h-6 | Extra-small buttons, tiny row actions, the large tag. The minimum hit target. |
| 28px | h-7 | Toolbar controls, view tabs, filter chips, segmented controls and sidebar items. |
| 32px | h-8 | The default: buttons, inputs, selects, the tabs track, the sidebar search field. |
| 36px | h-9 | Large buttons and touch-first layouts. 36px also matches the default grid row. |
Row heights#
Grid rows are 32, 36 or 44px by density, 36px by default. Headers, nav items and the app chrome have fixed heights of their own.
Grid density
The same invoices at each density. Headers stay 32px; only the rows change.
| Invoice | Supplier | Due | Amount |
|---|---|---|---|
| INV-20931 | Northwind Freight | Oct 2 | $12,480.00 |
| INV-20944 | Halcyon | Oct 5 | $8,212.75 |
| INV-20957 | Orchard Street | Oct 9 | $1,904.50 |
| INV-20960 | Brightline Logistics | Oct 12 | $711.17 |
import { SegmentedControl } from "@oration/canon/components/segmented-control";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function RowDensity() { const [density, setDensity] = React.useState< "compact" | "default" | "comfortable" >("default"); const rowHeight = { compact: "h-8", default: "h-9", comfortable: "h-11" }[ density ]; const px = { compact: 32, default: 36, comfortable: 44 }[density]; return ( <div className="flex w-full flex-col items-center gap-4"> <div className="flex flex-wrap items-center justify-center gap-3"> <SegmentedControl label="Row density" value={density} onValueChange={setDensity} options={[ { value: "compact", label: "Compact" }, { value: "default", label: "Default" }, { value: "comfortable", label: "Comfortable" }, ]} /> <span className="text-13 text-muted-foreground tabular-nums"> {px}px rows </span> </div> <div className="w-full max-w-xl overflow-x-auto rounded-xl bg-card shadow-border"> <table className="w-full min-w-[28rem] border-collapse text-13"> <caption className="sr-only">Open invoices</caption> <thead> <tr> {["Invoice", "Supplier", "Due", "Amount"].map( (column) => ( <th key={column} scope="col" className={cn( "h-8 border-b border-border px-2 text-left font-medium whitespace-nowrap text-muted-foreground first:pl-3 last:pr-3", column === "Amount" && "text-right", )} > {column} </th> ), )} </tr> </thead> <tbody> {invoices.map((invoice) => ( <tr key={invoice.id} className="border-b border-border last:border-b-0 hover:bg-surface" > <td className={cn( "pl-3 font-mono text-xs text-foreground", rowHeight, )} > {invoice.id} </td> <td className="px-2 text-foreground"> {invoice.supplier} </td> <td className="px-2 text-muted-foreground tabular-nums"> {invoice.due} </td> <td className="pr-3 text-right font-medium text-foreground tabular-nums"> {invoice.amount} </td> </tr> ))} </tbody> </table> </div> </div> );}| Element | Height | Class |
|---|---|---|
| App header | 48px | h-12 |
| Data grid header | 32px | h-8 |
| Grid row, compact | 32px | h-8 |
| Grid row, default | 36px | h-9 |
| Grid row, comfortable | 44px | h-11 |
| Record rows in lists and cards, at least | 32px | min-h-8 |
| Sidebar item | 28px | h-7 |
| Sticky footer of a full-bleed tool | 36px | h-9 |
| Tag (large tag) | 20px (24px) | h-5 (h-6) |
Density#
Oration is dense on purpose, and the room stays calm because the spacing is predictable.
- Dense UI is 13px text in 32 to 36px rows. Reading text is 14px with room around it. Don't mix the two in one list.
- Card padding is 16px at every size. A bigger card gets more content, not more padding.
- Lists inside cards inset 8px, with 32px rows on 8px corners that fill Well Gray on hover.
- Spacing grows with the distance in meaning: 6 to 8px within a thing, 12px between related things, 16px between cards, 32px between sections.
- One density per grid. The density switch belongs to the view, not to individual rows.
- Use
gapon a flex or grid parent rather than margins on children, so a hidden child doesn't leave a stray gap.
W-9 status
Halcyon
On file since Sep 21
W-9 status
Halcyon
On file since Sep 21
In Tailwind#
The recipes below cover a card and a document page. Copy them rather than restating the numbers.
// A card: 16px padding, 12px between related blocks<div className="flex flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <div className="flex items-baseline justify-between gap-3"> <h2 className="text-sm font-semibold">Friday payment run</h2> <Link className="text-xs text-muted-foreground" href="/payments/runs/42">View run</Link> </div> {/* A list inset 8px, rows 32px with 8px corners */} <ul className="flex flex-col rounded-[10px] bg-muted/70 p-2"> <li className="flex h-8 items-center gap-1.5 rounded-md px-2 text-13">…</li> </ul> <div className="flex justify-end gap-2">…</div></div>// A document page: 24px gutters (16px below 640px), 32px top and bottom,// sections 32px apart<div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6"> <div className="flex flex-col gap-8">…</div></div>