Custom typography
Fonts with next/font, the text scale as tokens and composed styles with @utility.
Updated
What custom typography means
A design usually has 5–8 fixed text styles: a big page title, section headings, body text, small text, labels. Each style = a combination of font + size + line height + weight + letter spacing.
The classic mistake: the combination is rewritten by hand everywhere.
<h1 class="font-display text-[2.75rem] leading-[1.1] font-bold tracking-[-0.02em]">
<!-- ... and the same 5 classes in 30 other places -->The goal: a single name — text-display, text-h2, text-body — defined once and used everywhere, like a class. You change the design in a single place.
Step 1 — the fonts
You load the font with next/font (self-hosted, no layout "jump") and expose it as a CSS variable:
// app/layout.tsx
import { IBM_Plex_Sans, JetBrains_Mono } from 'next/font/google'
const sans = IBM_Plex_Sans({ variable: '--font-plex-sans', subsets: ['latin', 'latin-ext'], weight: ['400', '500', '600', '700'] })
const mono = JetBrains_Mono({ variable: '--font-jetbrains-mono', subsets: ['latin', 'latin-ext'] })
<html className={`${sans.variable} ${mono.variable}`}>Add the subsets your languages need: latin-ext for accented letters (é, ñ, ș), cyrillic for Russian.
Then you map them into the Tailwind theme:
@theme inline {
--font-sans: var(--font-plex-sans); /* font-sans — the default across the site */
--font-mono: var(--font-jetbrains-mono); /* font-mono */
--font-display: var(--font-plex-sans); /* font-display — a new class */
}Step 2 — the size scale as tokens
Every --text-* in @theme becomes a text-* class. You can also attach the line height, letter spacing and weight — they all apply from a single class:
@theme {
--text-display: 3rem;
--text-display--line-height: 1.1;
--text-display--letter-spacing: -0.02em;
--text-display--font-weight: 700;
--text-h2: 1.75rem;
--text-h2--line-height: 1.25;
--text-h2--font-weight: 600;
--text-body: 1rem;
--text-body--line-height: 1.65;
--text-caption: 0.8125rem;
--text-caption--line-height: 1.4;
}<h1 class="text-display">Title</h1>
<h2 class="text-h2">Section</h2>
<p class="text-body text-muted">Paragraph</p>
<span class="text-caption">Label</span>They work with any variant: text-h2 md:text-display.
If you want to completely replace the default scale (text-sm, text-xl...) with your own: --text-*: initial; at the start of @theme. That way the team can use only the sizes from the design.
Step 3 — composed styles with @utility
When a style has more than a font-size (e.g. a different font, text-transform, a color), you create your own utility class:
@utility text-eyebrow {
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-accent);
}<p class="text-eyebrow md:text-sm">Lesson 3</p>@utility is better than a normal CSS class: it goes into the utilities layer, it works with variants (hover:, md:, dark:) and it's detected like any Tailwind class.
Typography options — a comparison
| Method | When |
|---|---|
direct Tailwind classes (text-xl font-semibold) |
once, a special case |
a --text-* token with sub-properties |
the design's size scale — the default |
@utility |
a composed style with a font / transform / color |
a <Heading level={2}> component |
tag + style + logic (e.g. an id for anchors) |
the @tailwindcss/typography plugin (prose) |
long content coming from markdown / a CMS — like the lessons here |
Where you keep them
Everything in a single global stylesheet (app/globals.css or src/app/styles/) — the color tokens, fonts, the text scale, the custom utilities. Components only use them.
Summary
next/font→ a CSS variable →@theme inline { --font-* }.--text-name+--text-name--line-height / --font-weight / --letter-spacing= a complete style in one class.@utilityfor composed styles; works with every variant.