Skip to content
Component installs need the registry setup
VegaStack Design

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.

Geist — the quick brown foxtext-7xl · 72 / 72 · −0.06em · display
Geist — the quick brown foxtext-6xl · 60 / 64 · −0.06em · display
Geist — the quick brown foxtext-5xl · 48 / 56 · −0.06em · display
Geist — the quick brown foxtext-4xl · 36 / 44 · −0.05em
Geist — the quick brown foxtext-3xl · 30 / 38 · −0.04em
Geist — the quick brown foxtext-2xl · 24 / 32 · −0.04em
Geist — the quick brown foxtext-xl · 20 / 26 · −0.02em
Geist — the quick brown foxtext-lg · 18 / 26 · −0.012em
Geist — the quick brown foxtext-base · 16 / 24 · prose body
Geist — the quick brown foxtext-sm · 14 / 20 · default body · the control voice
Geist — the quick brown foxtext-xs · 12 / 16

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:

StepSizeLine-heightLetter-spacing
text-xs12pxstock0
text-sm14pxstock0
text-base16pxstock0
text-lg18px26px−0.012em
text-xl20px26px−0.02em
text-2xl24px32px−0.04em
text-3xl30px38px−0.04em
text-4xl36px44px−0.05em
text-5xl48px56px−0.06em
text-6xl60px64px−0.06em
text-7xl72px72px−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 body and never on html: rem resolves against the root, so an html size 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.

RoleUtilities
Page headingfont-heading text-2xl font-semibold, through PageHeader
Section headingfont-heading text-base font-medium (an h2)
Card / dialog titlefont-heading text-base font-medium
Group labeltext-xs font-medium text-muted-foreground
Control labeltext-sm font-medium
Compact labeltext-xs font-medium
Bodytext-sm (product) · text-base (prose)
Inline codefont-mono text-sm
Numberstabular-nums in the regular font — counts, dates, amounts, quantities
Identifiersfont-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 use tabular-nums in 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:

app/layout.tsx (fragment)
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.

Do
Use the stock steps and let the ramp supply tracking and leading — text-sm product body, text-base prose, text-sm font-medium labels, tabular-nums for numbers, font-mono for code and identifiers, sentence case throughout.
Don't
Write your own tracking-*, an arbitrary text-[13px], an uppercase transform, or mono for running prose.

On this page