Tailwind typography utilities
The Tailwind typography layer: the font stack, type scale, and text utilities that style every heading, paragraph, and number in your interface.
You render a heading and reach for text-3xl font-semibold tracking-tight. It looks sharp, until the copy changes by two words, the line wraps, and the last word is stranded alone on its own line. The junior fix is to hand-insert a line break in the markup, which holds until the next copy edit or screen width breaks it again. An experienced developer reaches for one class, text-balance, and the browser evens the lines out on every width.
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; typography is the next layer of that system. You’ll set up the surface a developer writes daily: the font stack, the type scale, the line-height and letter-spacing reflexes, the text-wrap properties, the reading width that keeps long text legible, and the utilities (truncate, line-clamp-*, tabular-nums) that fill every card and dashboard.
The work isn’t learning that these utilities exist. It’s the reflexes: which utility goes on which surface, and why. A heading and a paragraph want opposite settings on nearly 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”Every page needs a font, and the 2026 stack has two layers. The first one you already have for free.
Layer one is the browser’s default fallback. Preflight, the reset that ships with Tailwind, sets the document’s font-family to ui-sans-serif, system-ui, -apple-system, ..., which resolves to whatever sans-serif the operating system uses for its own UI. Unstyled text renders in the native system font (San Francisco on a Mac, Segoe on Windows, Roboto on Android) instantly and with zero network cost, because that font is already installed. For many internal tools and dashboards, this is all you need.
Layer two is the one decision a product-facing web app usually makes: it ships one branded typeface. The common 2026 picks are Inter, Geist, and Manrope, all clean, neutral sans-serifs designed for screens. You don’t hand-write an @font-face rule. You load the font through Next.js’s next/font, which self-hosts the file, generates the CSS, and gives you a class name to apply. The font then flows into your theme, so font-sans, and therefore every unstyled element, resolves to your brand face instead of the system fallback.
Three moving parts connect the font file to the font-sans utility:
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 is exposed under.
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.
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 as the fallback. Preflight’s default font-family resolves to Inter too, since it reads the same token. This is the same --font-* namespace from earlier in the course. The full next/font setup (subsetting, preloading, weight axes) comes later, in the chapter on Next.js fonts and assets.
You ship one face rather than five separate weights 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. Inter, Geist, and Manrope all ship this way, so reaching for any weight costs nothing beyond the file you already loaded.
One more default matters. next/font sets font-display: swap, which renders text immediately in the fallback font and swaps in the web font once it arrives, rather than blocking the page on the download. That brief flash of the fallback is called FOUT , and with a self-hosted, subset variable font the swap is fast enough to be imperceptible. So layer one’s system stack isn’t just a nicety: it’s the font users see during the swap, and the only font users who block web fonts ever see.
The Tailwind type scale
Section titled “The Tailwind type scale”Tailwind gives you a font-size scale that runs text-xs, text-sm, text-base, text-lg, text-xl, text-2xl, 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 resolves to a value rather than a raw pixel count, text-lg resolves to a rem -based font size. Sizing in rem rather than pixels means the whole scale respects the user’s browser font-size preference: bump that default up, and your entire interface scales too.
Second, each step pairs a font-size with a tuned line-height. text-sm gives you a smaller font and a tighter line-height, because small text needs proportionally less line spacing than large text. The scale made that decision for you. You’ll override the pairing sometimes, which is the next section, but the default is deliberate.
So build the habit of writing on the scale. An arbitrary value like text-[17px] is a smell, not because 17px is wrong, but because it’s a number someone typed instead of a step the system chose. It sits between text-base (16px) and text-lg (18px), belonging to neither, and the next person can’t tell whether 17 was meaningful or a guess. Pick the nearest scale step instead. If the scale genuinely lacks the size 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.
To feel why, put an arbitrary size next to the scale. In the playground below, step the top line through text-sm to text-5xl and watch the deliberate jumps; then drag the slider on the bottom line to an arbitrary pixel size and park it between two steps. The scale sizes feel like rungs on a ladder; the arbitrary one feels stuck between them.
scale step
Ship the invoice
arbitrary px
Ship the invoice
Font weight, italics, and underlines
Section titled “Font weight, italics, and underlines”Weight runs font-thin (100) through font-black (900). The everyday picks are font-normal (400) for body text, font-medium (500) for labels and buttons, font-semibold (600) for most headings, and font-bold (700) for strong emphasis. Because you loaded a variable font, all of these come from the single file you already downloaded, so mixing font-medium and font-semibold on one page costs nothing extra.
italic sets the font style; not-italic resets it, which you reach for when a parent or default has already turned italics on and you want this element upright.
underline adds a text-decoration, and it earns a callout because of the Preflight behavior you met earlier: Preflight strips the default underline off links, so an <a> renders without one. When you want a link underlined, opt back in with underline.
<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: overriding the scale’s defaults
Section titled “Line-height and letter-spacing: overriding the scale’s defaults”Each text-* step already pairs a line-height and letter-spacing with the size. You reach for leading-* and tracking-* only to override that pairing when a particular surface wants something different, never to set either value from scratch.
Line-height is controlled by leading-*, and the reflexes split by surface. Body text reads best a little loose: leading-relaxed, or leading-7 for an exact value. Headings are short and large, so the default spacing looks like a gap; pull it in with leading-tight, or leading-none for big display text where the lines should nearly touch. Long-form prose can go to leading-loose, which slows the eye for 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 drawn together using tracking-tight, or tracking-tighter for very large display type, since the default gaps look loose at big sizes. Body text wants the default, so leave 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 genuinely large headlines is available through the bracket form, e.g. tracking-[-0.04em].
The three canonical pairings are easiest to hold as whole units. 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 drawn together (tracking-tight), or it looks loose and gappy.
<p className="text-base leading-relaxed"> Send branded invoices, track what's paid, and chase what isn't — without leaving your dashboard.</p>Roomy, default tracking. Running text reads best with more line-height (leading-relaxed); letter-spacing stays at the default, since body copy never wants tracking touched.
<p className="text-xs font-medium uppercase tracking-widest text-muted-foreground"> Billing</p>Small caps, opened up. A tiny all-caps label is the one place letter-spacing goes wider (tracking-widest) so the capitals don’t crowd.
text-balance for headings, text-pretty for body
Section titled “text-balance for headings, text-pretty for body”These two utilities fix the orphan-word problem from the introduction without a single manual line break.
Default text wrapping is greedy: the browser fills each line with as many words as fit, then drops the rest onto the next line. On a paragraph that’s fine. On a short heading it produces three full lines and a fourth holding one stranded word, the orphan. text-balance sets text-wrap: balance, which tells the browser to even out the lines instead, finding a width where every line carries a similar amount of text so no word is left alone. Reach for it on every h1, h2, and h3.
Balancing is computationally expensive, since the browser tries several line lengths, so engines only do it for blocks up to a few lines (around six in Chromium, ten in Firefox) and fall back to normal wrapping past that. That cap is why text-balance belongs on short headings, not body copy.
For body copy there’s text-pretty, which sets text-wrap: pretty. It runs a lighter pass over a paragraph to avoid a too-short last line, the paragraph-level orphan where a long block ends with one dangling word. Reach for it on every long paragraph.
The two utilities are at different stages of support:
text-balanceis Baseline. Chrome, Edge, Firefox, and Safari have supported it since 2024. Reach for it on every heading without a second thought.text-prettyis not Baseline yet. Chromium and Safari support it; Firefox does not as of early 2026. You still apply it by default because it degrades gracefully: an engine that doesn’t understandtext-wrap: prettywraps the paragraph normally, exactly as it would without the class. There’s no broken layout and no fallback to write, which makes it a safe progressive enhancement.
The fix is easier to see than to describe. The figure below renders the same heading at a constrained width across three tabs: the 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 disappear once the lines balance.
Get paid faster with less busywork
Get paid faster with less busywork
Send branded invoices, track exactly what your customers have paid, and chase down whatever is still outstanding from your dashboard.
text-wrap: prettySend branded invoices, track exactly what your customers have paid, and chase down whatever is still outstanding from your dashboard.
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. The heuristic is the measure : body text reads best at roughly 60–75 characters per line. Wider, and the eye loses its place on the return sweep from the end of one line to the start of the next. Much narrower, and the text turns choppy, breaking every few words.
The reflex is max-w-prose, which Tailwind defines as max-w-[65ch]: a maximum width of 65 of the ch unit, the width of the “0” glyph in the current font. Because ch tracks the font, a column sized in it holds roughly the same number of characters whatever typeface or size renders it, so you constrain characters per line rather than a pixel width that drifts. Put max-w-prose on any long-form column and the measure is handled.
Drag the measure below and feel both failure modes: pull it wide and watch your eye struggle to find the next line on the return, pull it narrow and watch the text get choppy. The readout flags the comfortable 60–75 band, so let go there and you’ll land near the 65ch default.
Text utilities, grouped by purpose
Section titled “Text utilities, grouped by purpose”The rest of the surface is a toolbox. Here it is as 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, with text-left usually the default. The logical pair text-start and text-end flip with the writing direction, so text-start means “left in English, right in Arabic.” Production code reaches for the logical forms when an interface must support right-to-left languages.
Overflow ellipsis. When text might outrun its container, you clip it with an ellipsis, and which utility you pick depends on how many lines you allow:
truncateclips to a single line with a trailing ellipsis. It bundles three CSS declarations (overflow: hidden,text-overflow: ellipsis,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 asline-clamp-2orline-clamp-3, with the ellipsis at the end of the last allowed line. This is the canonical reach for card descriptions, list previews, and comment teasers: a consistent block of two or three lines no matter how long the underlying text is.
Whitespace and wrapping. whitespace-nowrap keeps text on one line without the clipping truncate adds, for short labels you never want to break. whitespace-pre-wrap preserves the line breaks and spacing in the source, the reach for user-entered multi-line content like a comment. break-words lets an unbreakable run, such as a long URL or a no-spaces email, break mid-word rather than blow out of its container.
Case. uppercase, lowercase, and capitalize change 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, forcing 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 of right-aligned figures has its digits drift, the decimal points wandering a pixel or two per row. tabular-nums gives every digit the same width so the column snaps to a grid.
These rarely show up alone, so here they are together in a card component. Step through it and watch each utility work 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, the amount holds its size 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 one line with truncate. Crucially it carries min-w-0, the 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 text runs. text-sm and the muted token quietly mark it as secondary.
These utilities are thin wrappers over the underlying CSS, and it pays to know how that behaves underneath the class names.
Check your understanding
Section titled “Check your understanding”Now apply the whole surface end to end. The stat card below has its text in place but no typography utilities, so it reads as a flat, undecided block. Style it to match the target.
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.
You now hold the whole typography surface as a set of reflexes, written on the scale. Next you’ll do the same for color.
External resources
Section titled “External resources”The reference for every text utility in this lesson — sizes, weights, leading, tracking, line-clamp, and the rest.
The authoritative reference on text-wrap: balance and pretty, including current browser support.
Ahmad Shadeed's interactive deep dive on text-wrap: balance and pretty — sliders and toggles that let you feel each value live.
From the team that shipped it: how the browser balances lines, why it caps at six lines, and where to reach for it.
The measure explained by a working typographer — why ~45–90 characters per line is the readable band behind max-w-prose.