Typography
Geist Sans + Geist Mono on Tailwind's stock sizes, with Geist's own optical metrics declared globally.
Last updated
The type system is Geist (sans UI and prose) and Geist Mono (code and identifiers),
on Tailwind's stock size scale. Hierarchy comes from size, weight and colour, and the utilities
mean exactly what they mean everywhere else: text-sm is 14px, text-base is 16px.
Sizes are stock; the metrics on top of them are ours and are declared once. Letter-spacing, line-height above the copy tier, the default body size and text rendering all live in the theme, never in a component.
Geist display — hero heading
Geist — section heading
Geist sans — body copy.
Geist sans — caption / muted.
Geist Mono — $1,234.56 · code · numbers
One scale, everywhere
This used to be two scales. text-xs…text-3xl were re-pointed at a denser product ladder
(text-base = 14px) while the docs shell stayed on the classic 16px documentation ladder, and a
pair of CSS bindings moved a subtree between them. That is gone: a pasted shadcn snippet now
renders at the size shadcn drew it, and text-sm means 14px on a product surface, in a component
preview and in this sentence alike.
Optical metrics — the one thing that is not stock
Geist's own specification splits a copy tier from a heading tier, and so does this system.
The copy sizes here are 12 / 14 / 16 — exactly Geist's copy tier — so they keep stock size and
line-height and declare only their zero letter-spacing, which stops copy-size text inside a
heading inheriting the heading's tracking (a slash form such as text-xs/6 sets no tracking, so
pair text-xs with leading-* there). Everything from text-lg up carries optical
metrics declared as per-size --text-*--line-height and --text-*--letter-spacing in the
@theme bridge:
| Step | Size | Line-height | Letter-spacing |
|---|---|---|---|
text-xs | 12px | stock | 0 |
text-sm | 14px | stock | 0 |
text-base | 16px | stock | 0 |
text-lg | 18px | 26px | −0.012em |
text-xl | 20px | 26px | −0.02em |
text-2xl | 24px | 32px | −0.04em |
text-3xl | 30px | 38px | −0.04em |
text-4xl | 36px | 44px | −0.05em |
text-5xl | 48px | 56px | −0.06em |
text-6xl | 60px | 64px | −0.06em |
text-7xl | 72px | 72px | −0.06em |
Sizes never move, so a pasted shadcn snippet still renders at the size shadcn drew it. Two of the line-heights get looser, not tighter: Tailwind's stock ratios collapse toward 1.0 at display sizes, which clips Geist's descenders once negative tracking pulls the glyphs together.
Never write your own tracking-*. Tailwind compiles the ramp as
letter-spacing: var(--tw-tracking, …), so a local utility silently wins and that one component
quietly stops following the ramp — design-lint rejects it. The single exception is
tracking-widest on a keyboard-shortcut hint, where the spacing separates glyph keys. And never
write an arbitrary size like text-[13px]: it bypasses the --text-* namespace and receives
neither half of the ramp.
Rendering and the default size
Two rules on body, for every product surface:
-webkit-font-smoothing: antialiased— Geist is drawn for smoothed rendering, and without it the same weight renders heavier and softer.- A declared 14px default, matching the component layer. It is set on
bodyand never onhtml:remresolves against the root, so anhtmlsize would rescale every token and override the reader's own browser font-size preference.
These docs are the deliberate exception — a reading surface stays at 16px prose, the same line Vercel draws between its dashboard and its documentation.
Case, and when to use mono
Sentence case for everything — buttons, headings, labels, body, toasts. There is no uppercase
in this system, and the uppercase utility is rejected outright rather than restricted: a CSS
transform rewrites whatever it is handed, which is how a token name like --text-lg once rendered
as --TEXT-LG. If a string is uppercase, write it uppercase in the string.
Mono is for code and identifiers, not for texture and not for numbers. Geist Mono carries code,
token names and identifiers — a reference like SYS-1042 or T-88. A section label or a sentence
is language, so it is font-sans — a small mono label reads technical, which is exactly why it is
easy to reach for and wrong for prose.
Numbers are in the regular font with tabular digits. Counts, dates, amounts and quantities are
font-sans tabular-nums: every digit takes the same width, so figures line up down a column and a
ticking value does not jitter, without the technical read of mono. Right-align a numeric column.
Only an identifier that happens to contain digits stays mono.
Weight and roles
There are no named role tokens. text-h1…text-h4, text-label, text-code, text-mono-label
and the text-display-* tier were utilities that baked size, line-height, weight and tracking
together; they are replaced by the two or three stock utilities each one stood for — a section
title is font-heading text-base font-medium, a control label is text-sm font-medium, inline code is
font-mono text-sm.
The 400/500 weight ladder is gone with them: font-semibold and font-bold are ordinary
utilities again, and upstream uses font-medium for titles and font-semibold where a heading
earns it. What has not changed is the judgement — at most two weights in one view, and colour
does the rest of the hierarchy work.
| Role | Utilities |
|---|---|
| Page heading | font-heading text-2xl font-semibold, through PageHeader |
| Section heading | font-heading text-base font-medium (an h2) |
| Card / dialog title | font-heading text-base font-medium |
| Group label | text-xs font-medium text-muted-foreground |
| Control label | text-sm font-medium |
| Compact label | text-xs font-medium |
| Body | text-sm (product) · text-base (prose) |
| Inline code | font-mono text-sm |
| Numbers | tabular-nums in the regular font — counts, dates, amounts, quantities |
| Identifiers | font-mono — references and keys such as SYS-1042, never a count or a sum |
One page heading. A page's title goes through PageHeader, at font-heading text-2xl font-semibold;
never hand-size an h1. Below it, a section is font-heading text-base font-medium and a group
label inside a list or a menu is text-xs font-medium text-muted-foreground.
Font tokens
--font-family-sans→ Geist (UI + prose)--font-family-mono→ Geist Mono (code and identifiers; numbers usetabular-numsin Geist)--font-family-serif→ Newsreader serif accent (display emphasis and pull-quotes only, never running text)--font-family-display→ Geist display tier (a split token so a display face can be retuned independently)
Use the font-sans, font-mono, font-serif, font-heading (bridged from --font-family-sans)
and font-display (bridged from --font-family-display) utilities. --font-family-pixel and the
Geist Pixel flourish were deleted with the marketing layer.
Font loading
One path: next/font/google. Load Geist, Geist Mono (and Newsreader, if you use the serif
accent) through next/font/google, which self-hosts the subset woff2 at build time — so the app
serves every face itself: no render-blocking external request, no layout shift, and the fonts work
offline. The Quickstart and Theming snippets are
this same setup:
import { 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}`}>Loading alone is not enough: next/font registers hashed family names. Bind the loaded faces onto
the runtime font tokens in your global stylesheet (with system fallbacks):
:root {
--font-family-sans:
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--font-family-mono: var(--font-geist-mono), ui-monospace, monospace;
}This is the one-file override point: a consuming app supplies its own
faces by redefining --font-family-sans / -mono / -serif, and every
font-sans / font-mono / font-serif utility repaints — the @theme inline bridge maps these tokens to Tailwind's font utilities.