Skip to content

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.

Loading the fonts
// 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`}>
Font tokens
/* 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.

The type ramp with size, line height, weight, tracking and classes
StyleSpecimenSizeLine heightWeightTrackingTailwind
DisplayGood morning, Maya24px32px600-0.02emtext-2xl font-semibold tracking-[-0.02em]
HeadlineNorthwind Freight20px28px600-0.015emtext-xl font-semibold tracking-[-0.015em]
Title largeSchedule payment run16px1 (16px)5000text-base leading-none font-medium
TitleOpen invoices14px20px6000text-sm font-semibold
BodyNora matched 212 invoices to purchase orders and flagged two.14px1.45 (20.3px)4000text-sm leading-[1.45]
Body denseHalcyon, net 30, approved13px20px4000text-13
ControlSchedule run14px20px5000text-sm font-medium
LabelNet 3012px16px5000text-xs font-medium
CaptionUpdated 4 minutes ago12px16px4000text-xs
FootnoteCredits reset on Oct 111px16px4000text-2xs
Numeric$248,112.4013px20px5000text-13 font-medium tabular-nums
Monoinv_8F3K2QW712px16px4000font-mono text-xs
Where each type style is used
StyleUsed for
DisplayThe Home greeting only.
HeadlineThe page h1 or record name, exactly one per page.
Title largeDialog and sheet titles.
TitleSection headings in cards, panels and settings groups.
BodyReading text, descriptions and dialog copy. Inherited from body, so most reading text needs no class. Page descriptions cap at 42rem.
Body denseTable cells, nav items, breadcrumbs, toolbars, list rows and card copy.
ControlButton and tab labels. Small buttons drop to 12.8px and extra-small to 12px.
LabelTags, keyboard keys and run badges.
CaptionTimestamps, counts, helper text and sidebar group labels.
FootnoteThe smallest notes, such as a meter's target label or a credits reset date.
NumericAmounts and counts in rows and summaries.
MonoIDs, 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.

Setting 13px on a packages/canon component
// 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

Dense UI is 13px, reading text is 14px, meta is 12px. 11px is for footnotes only, and nothing is set smaller.

The Tabular Figures Rule

Every number that changes or gets compared (amounts, counts, percentages, times, credits) is set in tabular figures, and numeric columns align right.

The Machine Mono Rule

Geist Mono is only for strings a machine produced or will parse: record and run IDs, API keys, tokens, codes, DNS records, template variables, E.164 phone numbers, timecodes. Figures, labels, keyboard keys and headings stay in Geist Sans.
HalcyonW-9 on file, updated Sep 21
Do. Set dense rows at 13px with 12px meta beneath, and keep 11px for footnotes.
HalcyonW-9 on file, updated Sep 21
Don't. Shrink meta below 11px to fit more in a row. Nothing in Canon is set smaller than 11px.

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

Payees in the Friday payment run
SupplierAmount
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>    );}
Invoice$11,111.11Invoice$80,808.08Invoice$1,904.50Invoice$711.17
Do. Right-align amounts in tabular-nums so digits, commas and decimal points line up down the column.
Invoice$11,111.11Invoice$80,808.08Invoice$1,904.50Invoice$711.17
Don't. Leave amounts left-aligned in proportional figures. A 1 is narrower than an 8, so the column can't be scanned.
  • tabular-nums applies 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
Do. Set the invoice and run IDs in mono, and the amount and date in Geist Sans with tabular figures.
Invoice
INV-20931
Run
run_7Hq2xK9
Amount
$12,480.00
Due
Oct 2, 2026
Don't. Set labels, amounts and dates in mono because they look technical. Mono figures are wider and read as code.
What is and isn't set in mono
MonoGeist Sans
Record and run IDs: inv_8F3K2QW7, run_7Hq2xK9Amounts, counts and percentages, in tabular-nums
API keys, tokens, webhook secrets and codesKeyboard keys: Kbd is set in Label type
Template variables: {{supplier_name}}, see Variable chipField labels, column headers and headings
E.164 phone numbers, SIP URIs, DNS records and JSONDates and times written for people: Oct 2, 2026 at 2:00 PM
Timecodes in recordings and transcriptsNames 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>    );}
  • truncate only works on an element that can shrink. Inside flex and grid rows, give the text's container min-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.

The 16px input rule
// 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.

h1

Northwind Freight

h2

Open invoices

View all
h3

Due 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 heading prop 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.