Skip to content
Chapter 21Lesson 1

Type, scale, and the reading surface

The Tailwind typography layer, where you learn the font stack, type scale, and text utilities that style every heading, paragraph, and number in a SaaS interface.

You render a heading, say a pricing page title, and reach for the classes that always make text look sharp: text-3xl font-semibold tracking-tight. It comes out crisp. Then the copy changes by two words, the line wraps, and now the last line of your heading is a single word stranded on its own. It looks broken. The junior fix is to open the markup and hand-insert a line break to push a word down, which holds until the next copy edit, or the next screen width, breaks it again. An experienced developer reaches for one class instead, text-balance, which tells the browser to even out the lines itself, on every width, for good.

That gap, the same heading one class apart, is what this lesson is about. You already write utility classes on a clean Preflight slate with theme tokens, and typography is the next layer of that same system. By the end you’ll have installed the whole surface a developer writes daily in 2026: the font stack, the type scale and why you stay on it, the line-height and letter-spacing reflexes, the modern text-wrap properties that retired the manual line break, the reading width that keeps long text legible, and the handful of text utilities (truncate, line-clamp-*, tabular-nums) that show up in every card and dashboard you’ll build.

None of this is about learning that the utilities exist. The work is in the reflexes: which utility goes on which surface, and why. A heading and a paragraph want opposite settings on almost every axis, and knowing those defaults cold is most of what separates type that looks considered from type that looks like nobody decided.

The font stack: system first, one branded face

Section titled “The font stack: system first, one branded face”

Before any utility, a page needs a font. The 2026 stack has two layers, and the first one you already have for free.

Layer one is the fallback the browser uses for everything by default. Preflight, the reset that ships with Tailwind, sets the document’s font-family to ui-sans-serif, system-ui, -apple-system, ..., a stack that resolves to whatever sans-serif the operating system already uses for its own UI. So unstyled text renders in the native system font (San Francisco on a Mac, Segoe on Windows, Roboto on Android) instantly, with zero network cost, because that font is already installed. For a lot of internal tools and dashboards, this is all you need.

Layer two is the one decision a product-facing SaaS usually makes: it ships one branded typeface. In 2026 the default picks are Inter, Geist, and Manrope, all clean, neutral sans-serifs designed for screens. You don’t hand-write an @font-face rule for it. You load it through Next.js’s next/font, which self-hosts the file, generates the CSS, and hands you a class name to apply. The font then flows into your theme so that font-sans, and therefore every unstyled element, resolves to your brand face instead of the system fallback.

You’ll wire next/font for real later in the course. Here the goal is just to recognize its shape: the three moving parts that connect a font file to a font-sans utility. The following walkthrough steps through them.

app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body>{children}</body>
</html>
);
}
/* app/globals.css */
@theme {
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}

The next/font/google import gives you a helper per font. Calling it self-hosts the file at build time and returns an object. variable names the CSS custom property the font’s family will be exposed under.

app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body>{children}</body>
</html>
);
}
/* app/globals.css */
@theme {
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}

Applying inter.variable as a class sets that custom property (--font-inter) on the document root, so it’s in scope for every element below.

app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable}>
<body>{children}</body>
</html>
);
}
/* app/globals.css */
@theme {
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
}

Binding --font-sans to var(--font-inter) makes the font-sans utility resolve to Inter, with the system stack kept as the fallback. The default font-family Preflight set resolves to Inter too, since it reads the same token. This is the same --font-* namespace named earlier in the course. The full next/font setup (subsetting, preloading, weight axes) is covered later, in the chapter on Next.js fonts and assets.

1 / 1

You ship one branded face rather than five separate weights of it because of the variable font. A variable font packs the entire weight range, thin through black, into one file. So font-thin through font-black all work off a single download, with no extra request per weight and no separate file for bold. Inter, Geist, and Manrope all ship as variable fonts, so reaching for any weight costs you nothing more than the one file you already loaded.

One more default is worth naming. next/font sets font-display: swap, which tells the browser to render text immediately in the fallback font and swap in the web font once it arrives, rather than blocking the page on the font download. The brief moment the fallback shows is called FOUT , and with a self-hosted, subset variable font the swap is fast enough to be imperceptible. So the system stack from layer one isn’t just a nicety. It’s the font your users see during that swap, and the only font that users who block web fonts ever see, which means your branded face is never the only thing that renders.

This is the system that underpins the rest of the lesson, so it’s worth taking slowly. Tailwind gives you a font-size scale that runs text-xs, text-sm, text-base, text-lg, text-xl, text-2xl, all the way up to text-9xl. Two things make it a system rather than a list of sizes.

First, it’s the same theme-scale idea as spacing. Just as p-4 is a token that resolves to a value rather than a raw pixel count, text-lg is a token that resolves to a rem -based font size. Sizing type in rem rather than pixels means the whole scale respects the user’s browser font-size preference: a reader who bumps their default size up sees your entire interface scale with them. This is the --text-* namespace named earlier in the course, now cashed in.

Second, and this is what makes it a real system, each step pairs a font-size with a sensible default line-height. When you write text-sm, you don’t just get a smaller font; you get a smaller font and a tighter line-height tuned for that size, because small text needs proportionally less line spacing than large text. The scale has already made that decision for you. You’ll override the pairing sometimes, which is the next section, but the default is deliberate, not arbitrary.

The habit to build is to write off the scale. An arbitrary value like text-[17px] is a smell, not because 17px is wrong in some absolute sense, but because it’s a number someone typed instead of a decision the system made. It sits awkwardly between text-base (16px) and text-lg (18px), belonging to neither, and the next person to touch the file has no idea whether 17 was meaningful or a guess. So when you want a size, pick the nearest scale step. If the scale genuinely doesn’t have the step your design needs, don’t reach for a bracket: grow the scale in @theme, in one place, so the new size becomes a named token everyone shares. Staying on the scale costs a little discipline and buys you consistency across every screen.

The fastest way to feel why arbitrary sizes are a smell is to put one next to the scale. In the playground below, the top line is driven by the scale select: step through text-sm to text-5xl and watch the deliberate jumps. The bottom line is driven by the slider, an arbitrary pixel size you set yourself. Park the slider between two scale steps and compare. The scale sizes feel like rungs on a ladder, and the arbitrary one feels like it’s standing between rungs.

Step through the scale on the top line; drag the arbitrary size on the bottom. Park the slider between two steps and feel the difference.

Weight, style, and the variable-font range

Section titled “Weight, style, and the variable-font range”

These three utilities are small, so you’ll absorb them fast.

Weight runs font-thin (100) through font-black (900), with the everyday picks being font-normal (400) for body text, font-medium (500) for slightly emphasized labels and buttons, font-semibold (600) for most headings, and font-bold (700) for strong emphasis. Because you loaded a variable font, every one of these weights is available from the single file you already downloaded, so there’s no extra cost to using font-medium and font-semibold on the same page.

italic sets the font style, and not-italic resets it, which you reach for when a parent or a default has already turned italics on and you want this element upright.

underline is a text-decoration utility, and it deserves an explicit callout because of a Preflight behavior you met earlier: Preflight strips the default underline off links. So an <a> does not render underlined for free the way browser defaults would give you. If you want a link underlined, you opt back in with underline. (Styling links as elements, the full treatment of link states and decoration, is its own topic earlier in the course; here underline is just a utility you apply where you want a line under text.)

<p className="font-normal">Body copy sits at the normal weight.</p>
<span className="font-medium">Filters</span>
<h2 className="font-semibold">Recent invoices</h2>
<em className="italic">draft</em>
<a href="/docs" className="underline">Read the docs</a>

Line-height and letter-spacing: the paired-scale overrides

Section titled “Line-height and letter-spacing: the paired-scale overrides”

Both of these utilities exist for the same reason, so it’s worth teaching them as a pair: each one overrides a default that the text-* step already set. You don’t reach for them to set line-height or letter-spacing from scratch. You reach for them to change the pairing the scale chose, when a particular surface wants something different.

Line-height is controlled by leading-*, and the reflexes split cleanly by surface. Body text wants room to breathe, so it reads best a little loose: leading-relaxed, or the fixed leading-7 when you want an exact value. Headings are short and large, so the default line spacing looks like a gap; they want it pulled in tight with leading-tight, or leading-none for big display text where you want the lines to nearly touch. Long-form prose, such as an article or documentation, can go all the way to leading-loose, which reads slowly on purpose, giving the eye more vertical rest on a page meant to be read top to bottom rather than scanned.

Letter-spacing is controlled by tracking-*, and it follows size in the opposite direction. Large headings look better with the letters pulled slightly together, using tracking-tight, or tracking-tighter for very large display type, because at big sizes the default gaps between letters start to look loose. Body text wants the normal default, so don’t touch it. The one place you open spacing up is small all-caps labels, the little “OVERVIEW” or “SETTINGS” eyebrow above a section, where tracking-wide or tracking-widest keeps the capitals from crowding. (Negative tracking for display type is available through the bracket form, e.g. tracking-[-0.04em]; reach for it only on genuinely large headlines.)

The three canonical pairings are easiest to hold as units rather than as separate utilities. The tabs below show a heading, a body paragraph, and an eyebrow label, each with the text-* / leading-* / tracking-* choices that suit it.

<h2 className="text-3xl font-semibold leading-tight tracking-tight">
Everything your team needs to get paid
</h2>

Large, short, tight. Big type wants line-height pulled in (leading-tight) and letters pulled together (tracking-tight), or the heading looks loose and gappy.

Balance and pretty: the 2026 line-wrap reflex

Section titled “Balance and pretty: the 2026 line-wrap reflex”

These two utilities are the part of the lesson you’re least likely to have seen before, and they fix the orphan-word problem from the introduction without a single manual line break.

The browser’s default text wrapping is greedy: it fills each line with as many words as fit, then drops whatever’s left onto the next line. On a paragraph that’s fine. On a short heading it’s how you end up with three full lines and a fourth line holding one lonely word, the orphan. text-balance sets text-wrap: balance, which tells the browser to even out the lines instead: it works out a line length where every line carries a similar amount of text, so no single word gets stranded. This is the reach on every h1, h2, and h3 you write.

There’s a reason it’s a heading tool specifically. Balancing is computationally expensive, since the browser has to try different line lengths, so engines only do it for blocks up to a small line count (around six lines in Chromium, ten in Firefox); past that they fall back to normal wrapping. That cap is a feature, not a limitation: it’s exactly why text-balance belongs on short headings and not on body copy.

For body copy there’s text-pretty, which sets text-wrap: pretty. It runs a lighter-weight pass over a paragraph that specifically avoids leaving a too-short last line, the paragraph-level version of the orphan problem, where a long paragraph ends with one dangling word on its own line. This is the reach on every long-form paragraph.

Before you adopt these as defaults, here’s the honest support picture, because the two utilities are at different stages:

  • text-balance is Baseline. Chrome, Edge, Firefox, and Safari have all supported it since 2024. You can reach for it on every heading without a second thought.
  • text-pretty is not Baseline yet. Chromium and Safari support it; Firefox does not as of early 2026. The reason you still apply it by default is that it degrades gracefully: an engine that doesn’t understand text-wrap: pretty simply wraps the paragraph normally, exactly as it would have without the class. There’s no broken layout and no fallback to write. Supporting browsers get the nicer wrap, others get the ordinary one. That’s the definition of a safe progressive enhancement, so it stays in your default reflex.

The orphan problem is easier to see than to read about: the fix only clicks once you watch the word jump into place. The figure below renders the same heading at a constrained width across three tabs: the browser’s default wrap, the same heading with text-balance, and a paragraph with text-pretty for contrast. Switch between the first two tabs and watch the orphan on the default tab disappear when the lines balance.

heading

Get paid faster with less busywork

Greedy wrapping strands the last word on its own line — the orphan.

Two watch-outs come up alongside these utilities, so they belong here.

Reading width: max-w-prose and the 65ch rule

Section titled “Reading width: max-w-prose and the 65ch rule”

Long text needs more than the right font and line-height: it needs the right width. There’s a well-established heuristic for it called the measure : body text reads best at roughly 60–75 characters per line. Go wider and the reader’s eye loses its place on the return sweep from the end of one line to the start of the next; by the time it travels back across a too-wide column it can’t reliably find the next line down. Go much narrower and the text turns choppy, breaking every few words. The 60–75 band is the sweet spot the eye tracks comfortably.

The reflex is max-w-prose, which Tailwind defines as max-w-[65ch], a maximum width of 65 of the ch unit. The ch unit is the width of the “0” glyph in the current font, which is what makes it the right unit for this job: because it tracks the actual font, a column sized in ch holds roughly the same number of characters regardless of which typeface or size it’s rendered in. You’re constraining the thing that matters, characters per line, not a pixel width that drifts as the font changes. Put max-w-prose on any long-form text column and you’ve made the measure decision correctly without thinking about it again.

Drag the measure in the playground below and feel both failure modes. Pull it wide and notice your eye struggling to find the next line on the return; pull it narrow and watch the text get choppy. The readout flags when you’re in the comfortable 60–75 band, so let go there and you’ll land right around the 65ch default.

Drag the measure and find the comfortable band: too wide loses the eye, too narrow chops the text.

The rest of the surface is a toolbox. Rather than a flat list, here’s a reflex map grouped by what each set is for, so you leave knowing which drawer to open.

Alignment. text-left, text-center, and text-right are the three you’ll read most often, and text-left is so common it’s usually the default. For interfaces that need to support right-to-left languages, the logical pair text-start and text-end flip automatically with the writing direction: text-start means “left in English, right in Arabic.” That’s why production code reaches for the logical forms, while the physical left/right are just the names you’ll see most.

Overflow ellipsis, the two reaches. When text might be longer than its container, you clip it with an ellipsis, and there are two utilities depending on how many lines you allow:

  • truncate clips to a single line with a trailing ellipsis. It’s shorthand for three CSS declarations at once (overflow: hidden, text-overflow: ellipsis, and white-space: nowrap), so the text stays on one line and gets cut with a when it runs out of room.
  • line-clamp-* clips to multiple lines, such as line-clamp-2 or line-clamp-3, putting the ellipsis at the end of the last allowed line. This is the canonical reach for card descriptions, list previews, and comment teasers: anywhere you want a consistent block of two or three lines no matter how long the underlying text is. (Arbitrary counts work via line-clamp-[8], and you can drive it from a CSS variable with line-clamp-(--teaser-lines) when the count is dynamic, which is useful to know exists but rarely needed.)

Whitespace and wrapping. whitespace-nowrap keeps text on one line without the clipping that truncate adds, for short labels you never want to break. whitespace-pre-wrap preserves the line breaks and spacing in the source text, the reach for rendering user-entered multi-line content like a comment. And break-words lets an unbreakable run, such as a long URL or a no-spaces email address, break mid-word rather than blowing out of its container.

Case. uppercase, lowercase, and capitalize transform how text renders without touching the underlying string, so your data stays clean and only the presentation changes.

Numbers and mono. font-mono switches to the monospace stack for code and anything that should align character-by-character. tabular-nums sets font-variant-numeric: tabular-nums, which forces every digit to the same width.

That last one is worth dwelling on, because the problem it solves stays invisible until you see it. In most fonts a 1 is narrower than an 8, which is fine in prose but wrong the instant you stack numbers in a column: a price list or a dashboard with right-aligned figures will have its digits fail to line up vertically, the decimal points wandering by a pixel or two per row. tabular-nums makes every digit occupy the same width so the columns snap into a clean grid. The figure below shows the same column of figures without it and with it.

default
$1,141.00
$9,888.10
$211.18
$1,010.91
tabular-nums
$1,141.00
$9,888.10
$211.18
$1,010.91
Without tabular-nums the digit 1 is narrower than 8, so the rows drift; with it, every digit is the same width and the column snaps to a grid.

Most of these show up together, not in isolation, so here they are in the one place you’ll meet them most: a card component. Step through it and watch each utility do its job in context.

export const InvoiceCard = ({ invoice }: { invoice: Invoice }) => {
return (
<article className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center gap-3">
<h3 className="min-w-0 flex-1 truncate font-semibold text-card-foreground">
{invoice.customerName}
</h3>
<span className="shrink-0 tabular-nums text-card-foreground">
{invoice.amount}
</span>
</div>
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
{invoice.note}
</p>
</article>
);
};

A flex row carries the customer name and the amount on one line: the name flexes to fill the space, and the amount holds its size over on the right.

export const InvoiceCard = ({ invoice }: { invoice: Invoice }) => {
return (
<article className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center gap-3">
<h3 className="min-w-0 flex-1 truncate font-semibold text-card-foreground">
{invoice.customerName}
</h3>
<span className="shrink-0 tabular-nums text-card-foreground">
{invoice.amount}
</span>
</div>
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
{invoice.note}
</p>
</article>
);
};

The title fills the row with flex-1 and clips to a single line with truncate. Crucially it carries min-w-0, the same trap from the flexbox lesson: a flex item defaults to min-width: auto and won’t shrink below its content, so without min-w-0 the title refuses to truncate and pushes the amount off the card.

export const InvoiceCard = ({ invoice }: { invoice: Invoice }) => {
return (
<article className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center gap-3">
<h3 className="min-w-0 flex-1 truncate font-semibold text-card-foreground">
{invoice.customerName}
</h3>
<span className="shrink-0 tabular-nums text-card-foreground">
{invoice.amount}
</span>
</div>
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
{invoice.note}
</p>
</article>
);
};

The amount uses tabular-nums so a column of these cards aligns digit-for-digit, and shrink-0 so the number never gets squeezed by a long name beside it.

export const InvoiceCard = ({ invoice }: { invoice: Invoice }) => {
return (
<article className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center gap-3">
<h3 className="min-w-0 flex-1 truncate font-semibold text-card-foreground">
{invoice.customerName}
</h3>
<span className="shrink-0 tabular-nums text-card-foreground">
{invoice.amount}
</span>
</div>
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
{invoice.note}
</p>
</article>
);
};

The note clamps to two lines with line-clamp-2, giving every card the same height no matter how long the underlying text runs. This is the canonical card-description reach. text-sm and the muted token quietly mark it as secondary.

1 / 1

A few watch-outs that ride along with these utilities:

These utilities are thin wrappers over the underlying CSS (overflow-wrap, word-break, text-overflow: ellipsis, and line-clamp), and it pays to know how those behave underneath the class names.

Why this all worked so cleanly: the Preflight payoff

Section titled “Why this all worked so cleanly: the Preflight payoff”

Now that you’ve seen what sits on top of it, the foundation is worth naming: every utility in this lesson worked cleanly because Preflight cleared the ground first. Preflight removed the default margins browsers put on headings and paragraphs, stripped the bullets and indentation off lists, removed the underline browsers add to links, and made form elements inherit their font properties from the page instead of falling back to the browser’s own default. Each of those is a browser default you would otherwise have had to override before any of your typography choices could take effect.

Because that reset is already in place, you never spent a single class undoing a browser default in this entire lesson. You set the size you wanted, the weight you wanted, the line-height you wanted, and nothing pushed back. That’s the quiet payoff of the clean slate: the typography surface is purely additive. You’re decorating an empty canvas, not one the browser already wrote on.

Now apply the whole surface end to end. Below is a stat card with the raw text in place but no typography utilities, a flat, undecided block. Style it to match the target: the heading wants text-balance so it never orphans a word, the supporting paragraph wants text-pretty and max-w-prose for clean, readable wrapping, and the figure wants tabular-nums.

Style this card to match the target. Give the heading `text-balance` so it never strands a word; give the paragraph `text-pretty` and `max-w-prose` for clean wrapping at a comfortable width; give the stat `tabular-nums`. Keep the existing sizes and weights — you're adding the wrap, width, and numeric reflexes.

Target
Your output LIVE

Run through the reflex map one more time. Each utility below belongs on a particular surface. Drag it where it goes; the point is to make “which utility on which surface” automatic.

Sort each utility onto the surface it belongs on. Drag each item into the bucket it belongs to, then press Check.

Heading Short, large display text
Body paragraph Running long-form text
Dashboard number A column of figures
Card description A capped text preview
text-balance
leading-tight
tracking-tight
text-pretty
leading-relaxed
max-w-prose
tabular-nums
line-clamp-2

You now hold the whole typography surface as a set of reflexes: a system font stack with one branded face, a rem-based scale you write on, line-height and tracking that move in opposite directions with size, text-balance on every heading and text-pretty on every paragraph, max-w-prose on every long column, and the clipping and numeric utilities that show up in every card and table. Type on the web is a settled, token-driven system, and you write it off the scale. Next you’ll do the same for color.