Typography
One neutral grotesque set small: Geist Sans for everything a person reads, Geist Mono for what a machine wrote.
Two typefaces#
Geist Sans carries everything a person reads. Geist Mono appears only where a machine wrote the string.
Geist Sans and Geist Mono
One neutral grotesque set small, with tightened tracking at the top of the ramp, and a mono for IDs, keys, variables and timecodes.
Geist Sans
Everything a person reads
ABCDEFGHIJKLMNOPQRSTUVWXYZ
abcdefghijklmnopqrstuvwxyz
0123456789 $12,480.00 92%
Halcyon approved the October remittance for 48 suppliers.
Geist Mono
What a machine wrote
inv_8F3K2QW7
{{supplier_name}}
+14155550123
Record and run IDs, keys, variables and timecodes.
export function Typefaces() { return ( <div className="grid w-full max-w-2xl gap-4 sm:grid-cols-2"> <div className="flex min-w-0 flex-col gap-4 rounded-xl bg-card p-4 shadow-border"> <div className="flex items-baseline justify-between gap-3"> <p className="text-sm font-semibold text-foreground"> Geist Sans </p> <p className="text-xs text-muted-foreground"> Everything a person reads </p> </div> <p aria-hidden="true" className="text-[4.5rem] leading-none font-semibold tracking-[-0.03em] text-foreground" > Aa </p> <div className="flex flex-col gap-1 rounded-[10px] bg-muted/70 px-3 py-2"> <p className="text-13 text-foreground"> ABCDEFGHIJKLMNOPQRSTUVWXYZ </p> <p className="text-13 text-foreground"> abcdefghijklmnopqrstuvwxyz </p> <p className="text-13 text-foreground tabular-nums"> 0123456789 $12,480.00 92% </p> </div> <p className="text-13 text-pretty text-muted-foreground"> Halcyon approved the October remittance for 48 suppliers. </p> </div> <div className="flex min-w-0 flex-col gap-4 rounded-xl bg-card p-4 shadow-border"> <div className="flex items-baseline justify-between gap-3"> <p className="text-sm font-semibold text-foreground"> Geist Mono </p> <p className="text-xs text-muted-foreground"> What a machine wrote </p> </div> <p aria-hidden="true" className="font-mono text-[4.5rem] leading-none font-medium tracking-[-0.04em] text-foreground" > Aa </p> <div className="flex flex-col gap-1 rounded-[10px] bg-muted/70 px-3 py-2"> <p className="truncate font-mono text-xs leading-5 text-foreground"> inv_8F3K2QW7 </p> <p className="truncate font-mono text-xs leading-5 text-foreground"> {"{{supplier_name}}"} </p> <p className="truncate font-mono text-xs leading-5 text-foreground"> +14155550123 </p> </div> <p className="text-13 text-pretty text-muted-foreground"> Record and run IDs, keys, variables and timecodes. </p> </div> </div> );}Both faces load through next/font/google in apps/web/src/app/layout.tsx, which self-hosts the files and exposes them as --font-geist-sans and --font-geist-mono on <html>. Canon maps those to Tailwind's font-sans and font-mono with system fallbacks, so no component names a font family directly.
The body rule sets 14px at a 1.45 line height and turns on the cv11 and ss01 alternates. Every element inherits them, so there is no class to remember and nothing to turn on per component.
// apps/web/src/app/layout.tsximport { Geist, Geist_Mono } from "next/font/google";const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });<html className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>/* packages/canon/src/styles/globals.css */@theme inline { --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; --font-mono: var(--font-geist-mono), ui-monospace, "SF Mono", monospace;}@theme { --text-2xs: 0.6875rem; /* 11px, footnotes only */ --text-2xs--line-height: 1rem; --text-13: 0.8125rem; /* 13px, dense UI */ --text-13--line-height: 1.25rem;}@layer base { body { font-size: 0.875rem; /* 14px reading text */ line-height: 1.45; font-feature-settings: "cv11", "ss01"; } h1, h2, h3, h4 { text-wrap: balance; } p { text-wrap: pretty; }}The type ramp#
Twelve styles, read from the DESIGN.md frontmatter on every build. Each sample is set in the style it describes.
| Style | Specimen | Size | Line height | Weight | Tracking | Tailwind |
|---|---|---|---|---|---|---|
| Display | Good morning, Maya | 24px | 32px | 600 | -0.02em | text-2xl font-semibold tracking-[-0.02em] |
| Headline | Northwind Freight | 20px | 28px | 600 | -0.015em | text-xl font-semibold tracking-[-0.015em] |
| Title large | Schedule payment run | 16px | 1 (16px) | 500 | 0 | text-base leading-none font-medium |
| Title | Open invoices | 14px | 20px | 600 | 0 | text-sm font-semibold |
| Body | Nora matched 212 invoices to purchase orders and flagged two. | 14px | 1.45 (20.3px) | 400 | 0 | text-sm leading-[1.45] |
| Body dense | Halcyon, net 30, approved | 13px | 20px | 400 | 0 | text-13 |
| Control | Schedule run | 14px | 20px | 500 | 0 | text-sm font-medium |
| Label | Net 30 | 12px | 16px | 500 | 0 | text-xs font-medium |
| Caption | Updated 4 minutes ago | 12px | 16px | 400 | 0 | text-xs |
| Footnote | Credits reset on Oct 1 | 11px | 16px | 400 | 0 | text-2xs |
| Numeric | $248,112.40 | 13px | 20px | 500 | 0 | text-13 font-medium tabular-nums |
| Mono | inv_8F3K2QW7 | 12px | 16px | 400 | 0 | font-mono text-xs |
| Style | Used for |
|---|---|
| Display | The Home greeting only. |
| Headline | The page h1 or record name, exactly one per page. |
| Title large | Dialog and sheet titles. |
| Title | Section headings in cards, panels and settings groups. |
| Body | Reading text, descriptions and dialog copy. Inherited from body, so most reading text needs no class. Page descriptions cap at 42rem. |
| Body dense | Table cells, nav items, breadcrumbs, toolbars, list rows and card copy. |
| Control | Button and tab labels. Small buttons drop to 12.8px and extra-small to 12px. |
| Label | Tags, keyboard keys and run badges. |
| Caption | Timestamps, counts, helper text and sidebar group labels. |
| Footnote | The smallest notes, such as a meter's target label or a credits reset date. |
| Numeric | Amounts and counts in rows and summaries. |
| Mono | IDs, keys, codes and other machine strings. |
Two custom sizes fill the gaps in Tailwind's scale: text-13 (13px on a 20px line) for dense UI and text-2xs (11px on a 16px line) for footnotes. The rest are Tailwind defaults: text-xs is 12px on 16px, text-sm 14px on 20px, text-base 16px, text-xl 20px on 28px and text-2xl 24px on 32px.
// Canon's cn knows text-13 is a size:import { cn } from "@oration/canon/lib/utils";cn("text-sm", "text-13"); // "text-13"// packages/canon components merge with the plain cn package, which reads// text-13 as a color and keeps text-sm. Pass an arbitrary size instead:<TableCell className="text-[13px]">Northwind Freight</TableCell>Where components drift from the ramp
Some components ship sizes the ramp doesn't use. Follow DESIGN.md and override at the call site until they change. CardTitle is 16px at weight 500 where card headings are Title (14px, 600). Table rows are 14px where table cells are Body dense. SidebarMenuButton is 14px where nav items are 13px. Each is recorded on its component page.
Three rules for type#
These govern every screen. Most type mistakes in review break one of them.
The Thirteen-Fourteen Rule
The Tabular Figures Rule
The Machine Mono Rule
Tabular figures#
Numbers that change or get compared sit in tabular figures, and numeric columns align right.
Figures in a column and a counter
Switch to proportional figures and watch the decimal points drift apart and the credit count shake as it ticks down. Tabular figures give every digit the same width.
Friday payment run
Credits left 1,204
| Supplier | Amount |
|---|---|
| Northwind Freight | $11,111.11 |
| Halcyon | $80,808.08 |
| Orchard Street | $1,904.50 |
| Brightline Logistics | $711.17 |
| Total | $94,534.86 |
import { SegmentedControl } from "@oration/canon/components/segmented-control";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function TabularFigures() { const [figures, setFigures] = React.useState<"tabular" | "proportional">( "proportional", ); const [credits, setCredits] = React.useState(1204); React.useEffect(() => { const timer = window.setInterval(() => { setCredits((value) => value <= 1040 ? 1204 : value - 1 - Math.floor(Math.random() * 9), ); }, 600); return () => window.clearInterval(timer); }, []); const total = payees.reduce((sum, payee) => sum + payee.amount, 0); const nums = figures === "tabular" ? "tabular-nums" : "proportional-nums"; return ( <div className="flex w-full max-w-md flex-col items-center gap-4"> <SegmentedControl label="Figure style" value={figures} onValueChange={setFigures} options={[ { value: "proportional", label: "Proportional" }, { value: "tabular", label: "Tabular" }, ]} /> <div className="flex w-full flex-col overflow-hidden rounded-xl bg-card shadow-border"> <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3"> <p className="text-sm font-semibold text-foreground"> Friday payment run </p> <p className="text-13 text-muted-foreground"> Credits left{" "} <span className={cn("font-medium text-foreground", nums)} > {credits.toLocaleString("en-US")} </span> </p> </div> <table className="w-full text-13"> <caption className="sr-only"> Payees in the Friday payment run </caption> <thead> <tr className="border-b border-border"> <th scope="col" className="h-8 px-4 text-left text-xs font-medium text-muted-foreground" > Supplier </th> <th scope="col" className="h-8 px-4 text-right text-xs font-medium text-muted-foreground" > Amount </th> </tr> </thead> <tbody> {payees.map((payee) => ( <tr key={payee.supplier} className="border-b border-border" > <td className="h-9 px-4 text-foreground"> {payee.supplier} </td> <td className={cn( "h-9 px-4 text-right text-foreground", nums, )} > {usd.format(payee.amount)} </td> </tr> ))} </tbody> <tfoot> <tr> <td className="h-9 px-4 text-xs text-muted-foreground"> Total </td> <td className={cn( "h-9 px-4 text-right font-medium text-foreground", nums, )} > {usd.format(total)} </td> </tr> </tfoot> </table> </div> </div> );}tabular-nums so digits, commas and decimal points line up down the column.tabular-numsapplies to amounts, counts, percentages, times, dates in tables, credits and anything that animates. Animated number sets it for you.- Numeric columns align right, and so do their headers. Totals sit in the same column, in medium weight.
- Keep the precision of a column consistent: every amount shows cents, or none do.
- The Numeric style is 13px at weight 500, for amounts and counts in rows and summaries.
- Footers of full-bleed tools carry counts and totals in 12px tabular figures.
Machine mono#
Geist Mono marks a string a person shouldn't retype by hand. Everything else, figures included, stays in Geist Sans.
- Invoice
- INV-20931
- Run
- run_7Hq2xK9
- Amount
- $12,480.00
- Due
- Oct 2, 2026
- Invoice
- INV-20931
- Run
- run_7Hq2xK9
- Amount
- $12,480.00
- Due
- Oct 2, 2026
| Mono | Geist Sans |
|---|---|
Record and run IDs: inv_8F3K2QW7, run_7Hq2xK9 | Amounts, counts and percentages, in tabular-nums |
| API keys, tokens, webhook secrets and codes | Keyboard keys: Kbd is set in Label type |
Template variables: {{supplier_name}}, see Variable chip | Field labels, column headers and headings |
| E.164 phone numbers, SIP URIs, DNS records and JSON | Dates and times written for people: Oct 2, 2026 at 2:00 PM |
| Timecodes in recordings and transcripts | Names of suppliers, people and agents |
Mono is 12px on a 16px line, a step below Body dense. Values a person copies sit in a Copy row; blocks of code and payloads use Code block.
Wrapping and truncation#
Headings balance, paragraphs avoid orphans, and anything clipped can still be read in full somewhere.
Balance and pretty
h1 to h4 get text-wrap: balance and every p gets text-wrap: pretty from the base layer. Use text-balance or text-pretty on other elements that act as headings or copy.
Approve 14 invoices from Northwind Freight before Friday
Nora matched each one to a purchase order and a receipt. Two are above the supplier's usual amount.
import { SegmentedControl } from "@oration/canon/components/segmented-control";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function Wrapping() { const [wrap, setWrap] = React.useState<"canon" | "browser">("browser"); const canon = wrap === "canon"; return ( <div className="flex w-full flex-col items-center gap-4"> <SegmentedControl label="Text wrapping" value={wrap} onValueChange={setWrap} options={[ { value: "browser", label: "Browser default" }, { value: "canon", label: "Balance and pretty" }, ]} /> <div className="flex w-full max-w-[21rem] flex-col gap-1.5 rounded-xl bg-card p-4 shadow-border"> <p className={cn( "text-sm font-semibold text-foreground", canon ? "text-balance" : "text-wrap", )} > Approve 14 invoices from Northwind Freight before Friday </p> <p className={cn( "text-13 text-muted-foreground", canon ? "text-pretty" : "text-wrap", )} > Nora matched each one to a purchase order and a receipt. Two are above the supplier's usual amount. </p> </div> </div> );}Truncation and line clamps
Truncate names in rows to one line, clamp descriptions in cards to two, and let reading text wrap.
- Northwind Freight International LogisticsRemittance for September freight, net 30, split across three purchase orders and one credit note from August.
- HalcyonW-9 on file. Bank details changed on Sep 21 and were verified by Priya Raman.
- Orchard Street Facilities and Grounds MaintenanceQuarterly service invoice, approved by Tomás Ferreira.
import { SegmentedControl } from "@oration/canon/components/segmented-control";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function Truncation() { const [mode, setMode] = React.useState<"truncate" | "clamp" | "wrap">( "truncate", ); return ( <div className="flex w-full flex-col items-center gap-4"> <SegmentedControl label="Overflow treatment" value={mode} onValueChange={setMode} options={[ { value: "truncate", label: "Truncate" }, { value: "clamp", label: "Clamp to 2 lines" }, { value: "wrap", label: "Wrap" }, ]} /> <ul className="flex w-full max-w-[22rem] flex-col rounded-xl bg-card p-2 shadow-border"> {suppliers.map((supplier) => ( <li key={supplier.name} className="flex min-w-0 flex-col gap-0.5 rounded-md px-2 py-2 hover:bg-muted" > <span title={ mode === "truncate" ? supplier.name : undefined } className={cn( "text-13 font-medium text-foreground", mode === "truncate" && "truncate", )} > {supplier.name} </span> <span className={cn( "text-xs text-muted-foreground", mode === "truncate" && "truncate", mode === "clamp" && "line-clamp-2", )} > {supplier.memo} </span> </li> ))} </ul> </div> );}truncateonly works on an element that can shrink. Inside flex and grid rows, give the text's containermin-w-0.- A truncated value must be readable in full somewhere: a
title, a tooltip, a wider column or the record itself. - Clamp card descriptions with
line-clamp-2. Don't clamp dialog copy, errors or anything a person must read to decide. - Never truncate an amount, a count or an ID. Give the column room or wrap the row.
- Reading text caps at 42rem so lines stay near 75 characters. Page descriptions follow the same cap.
Inputs on small screens#
Text fields are 16px below 768px and 14px from 768px up, so mobile browsers don't zoom the page when a field takes focus.
// packages/canon/src/components/input.tsx (and textarea.tsx)"h-8 … px-2.5 py-1 text-base … md:text-sm"// A hand-built field follows the same pair:<input className="h-8 rounded-lg border border-input px-2.5 text-base md:text-sm" />Input, Textarea and Search field already follow it. Anything that renders its own <input> needs the same text-base md:text-sm pair.
ToolbarSearch stays at 13px on phones, so iOS zooms the page when it is focused. See Toolbar.
Headings#
One h1 per page. Heading levels follow the structure of the page, and the size follows the type ramp.
A record page outline
The record name is the page's only h1, in Headline. Card sections are h2 in Title. A heading inside a card steps down a level and still takes its size from the ramp.
h1Northwind Freight
h2Open invoices
View allh3Due this week
3 invoices, $24,112.40
export function HeadingOutline() { return ( <div className="flex w-full max-w-lg flex-col gap-4"> <div className="flex items-baseline gap-3"> <code className="w-6 shrink-0 font-mono text-xs text-muted-foreground"> h1 </code> <p className="text-xl font-semibold tracking-[-0.015em] text-foreground"> Northwind Freight </p> </div> <div className="flex flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <div className="flex items-baseline gap-3"> <code className="w-6 shrink-0 font-mono text-xs text-muted-foreground"> h2 </code> <div className="flex min-w-0 flex-1 items-baseline justify-between gap-3"> <p className="text-sm font-semibold text-foreground"> Open invoices </p> <span className="text-xs text-muted-foreground"> View all </span> </div> </div> <div className="flex flex-col gap-1.5 rounded-[10px] bg-muted/70 px-3 py-2"> <div className="flex items-baseline gap-3"> <code className="w-6 shrink-0 font-mono text-xs text-muted-foreground"> h3 </code> <p className="text-13 font-medium text-foreground"> Due this week </p> </div> <p className="pl-9 text-13 text-muted-foreground tabular-nums"> 3 invoices, $24,112.40 </p> </div> </div> </div> );}- Document pages put their h1 in the body, in Headline (20px, 600, -0.015em). Home is the exception: its greeting is Display.
- Dense list pages and full-bleed tools have no visible title. Their h1 sits in the app header as screen-reader text, through the
headingprop of the page header. - Don't skip levels to get a smaller size. Pick the level from the outline and the class from the ramp.
- Dialog and sheet titles are Title large, and the dialog takes its accessible name from the title.
- No eyebrow or kicker labels above headings, and no uppercase or letter-spaced labels anywhere.
Page title always renders an h1 and takes no level, so a page that uses it twice has two. Use it once per page.