Typography on the web is not a design problem — it is a rendering problem. The decisions that matter most are made in CSS, not in a type specimen or a Figma mockup. Getting them right means understanding what each property actually does at the browser level, not just what it looks like on your 27-inch monitor at 1x.

This covers the CSS rendering side of web typography: sizing units, line height mechanics, OpenType features, spacing, and the newer text-wrap properties. For font loading strategy — font-display, preloading, subsetting — see the web fonts loading guide.

Font Size: Why rem Is the Right Unit for Body Text

The px unit feels precise. It is also the wrong choice for body text, and the reason is user preferences — the full explanation of why lives in our guide to CSS units, but the short version is that rem respects a user’s browser-level font-size preference and px does not.

Browsers ship with a default font size — typically 16px — and users can change it. Someone with low vision may set their browser default to 20px or 24px. When body text is set in px, that preference is ignored entirely. The text renders at whatever pixel value the stylesheet declares, regardless of what the user asked for.

rem (root em) is relative to the <html> element’s font size, which inherits from the browser default unless you override it. Set font-size: 1rem on body text and you get the user’s preferred size by default, scaled proportionally if they’ve changed it. This is what WCAG Success Criterion 1.4.4 (Resize Text, Level AA) is driving at: text must be resizable up to 200% without loss of content or functionality.

The common pattern of setting html { font-size: 62.5%; } to make 1rem = 10px deserves caution. It makes the math convenient — 1.6rem reads as “16px” — but it still anchors the scale to 16px as the assumed base. A user whose browser default is 20px will now get 1rem = 12.5px instead of 20px, which defeats the whole point.

A better approach: leave the html font size alone (or set it to 100% as an explicit pass-through), and write your type scale in rem values that assume 16px as the base for your own mental model, while letting the browser adapt for users who need it.

/* Leaves user preference intact */
:root {
  font-size: 100%;
}

body {
  font-size: 1rem;      /* inherits user's default */
  line-height: 1.5;
}

h1 { font-size: 2.5rem; }
h2 { font-size: 2rem; }
h3 { font-size: 1.5rem; }

For fluid type scaling with clamp(), the same principle applies — use rem as the min and max values, not px:

body {
  font-size: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
}

Line Height: The Case for Unitless Values

line-height has a subtle inheritance trap that catches developers who use px or em values.

When you write line-height: 24px on a parent element, every descendant inherits 24px — not a ratio, the computed pixel value. A child element with a larger font size will have cramped lines. A child with a smaller font size will have excessive spacing. The value doesn’t scale.

The same problem affects em. If you write line-height: 1.5em on a parent with font-size: 16px, the browser computes 24px and that computed value is what inherits. Descendants don’t get 1.5em relative to their own font size — they get 24px.

Unitless values work differently. line-height: 1.5 inherits as a ratio. Each element multiplies its own font size by 1.5 to produce its line height. This is what the CSS specification intends, and it is almost always what you want.

/* Problematic: computed px value inherits */
body {
  font-size: 16px;
  line-height: 1.5em; /* computes to 24px, this value inherits */
}

h1 {
  font-size: 2rem; /* 32px */
  /* inherits 24px line-height — lines will be cramped */
}

/* Correct: ratio inherits and scales */
body {
  font-size: 1rem;
  line-height: 1.5; /* ratio inherits */
}

h1 {
  font-size: 2rem;
  /* inherits 1.5, computes to 48px — proportional */
}

Practical line-height values: body text reads well at 1.4–1.6. Headings, which are set larger, typically need tighter values — 1.1–1.3 — because their size already creates visual separation between lines. Small text (captions, labels) can tolerate slightly tighter values but rarely benefits from them.

Font Weight: Numeric Values and Variable Fonts

The keyword values normal and bold map to 400 and 700 respectively. Every other value in the 100–900 range requires the font to actually have that weight available — requesting font-weight: 300 when only regular and bold are loaded will trigger the browser’s weight synthesis algorithm, which artificially lightens or bolds the available glyphs. The results are rarely good.

With variable fonts, font-weight becomes a continuous axis. A variable font with a weight range of 100–900 can render any value in that range without synthesis. This is the practical case for variable fonts beyond file-size arguments: you get precise weight control instead of a handful of discrete stops.

/* Static font — only works if the 600 weight is loaded */
.label {
  font-weight: 600;
}

/* Variable font — any value in the font's range */
@font-face {
  font-family: 'InterVariable';
  src: url('/fonts/inter-variable.woff2') format('woff2');
  font-weight: 100 900;
}

.label {
  font-family: 'InterVariable', sans-serif;
  font-weight: 550; /* precise intermediate weight */
}

OpenType Features: font-variant and font-feature-settings

Modern fonts ship with OpenType feature tables that most CSS never activates. The font-variant shorthand and the lower-level font-feature-settings property expose these features.

Ligatures replace character pairs like fi, fl, ff with single glyphs that avoid the awkward collision the individual characters would produce. Common ligatures are typically on by default in browsers when the font supports them (font-variant-ligatures: common-ligatures). Discretionary ligatures — more decorative combinations — are off by default and rarely appropriate for body text.

Tabular numbers are essential in any context where numbers appear in columns — data tables, financial figures, scoreboards. Proportional numbers (the default) have varying widths: 1 is narrower than 8. Tabular numbers have equal widths so columns align vertically.

/* Using font-variant (modern, readable) */
.data-table td {
  font-variant-numeric: tabular-nums;
}

/* Using font-feature-settings (lower-level, broader support) */
.data-table td {
  font-feature-settings: "tnum" 1;
}

Small caps render lowercase letters as scaled capital forms, preserving the typographic color of the text while providing visual distinction. The font-variant-caps: small-caps approach uses the font’s actual small-cap glyphs if available; if not, the browser synthesizes them by scaling the uppercase letters, which produces lighter-weight glyphs that look out of place. Check whether your chosen font includes real small caps before relying on this feature.

Old-style figures (numerals that sit on the baseline with ascenders and descenders, like traditional book typography) can help numbers feel less visually disruptive in running text. They are a matter of stylistic judgment, not readability necessity.

.running-text {
  font-variant-numeric: oldstyle-nums;
  font-feature-settings: "onum" 1; /* fallback */
}

The MDN font-feature-settings reference maintains the full list of standard OpenType tags.

Letter Spacing and Word Spacing

Both properties exist on a spectrum from helpful to harmful depending on context.

letter-spacing (tracking in print terminology) adds space uniformly between all characters. The primary legitimate use cases are: all-caps labels and headings (where tracking opens up the compressed visual feel of capital-only text), and small text that needs to breathe at tight sizes. Adding tracking to body text at normal size typically degrades readability — readers perceive words as character sequences, and widening the space between characters makes word shapes harder to recognize at a glance.

/* Appropriate: tracking on an all-caps label */
.section-label {
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}

/* Inappropriate: tracking on body text */
p {
  letter-spacing: 0.02em; /* avoid — slows reading */
}

Use em units for letter-spacing, not px. em values scale proportionally with font size; px values that look reasonable at 16px become too tight at 12px and too loose at 24px.

word-spacing is rarely needed. Browser word spacing defaults are calibrated for normal reading, and most typefaces are designed with word spacing already built into their sidebearings. Adding word spacing is occasionally useful for stretched navigation items or stylistic display text, but in body copy it almost always creates rivers of white space that interrupt the reading flow.

Measure: Line Length and the 65–75 Character Range

The measure — the width of a line of text — is arguably the single most consequential typographic decision for readability, and CSS gives you the tools to control it precisely.

Research in readability consistently points to a range of 45–85 characters per line for comfortable reading, with the practical sweet spot around 65–75 characters. Lines shorter than 45 characters cause excessive hyphenation and constant return sweeps. Lines longer than 85–90 characters make it difficult for the eye to locate the start of the next line, increasing re-reading errors.

The ch unit measures the width of the 0 character in the current font, which serves as a reasonable proxy for average character width. Setting max-width: 70ch on a text container gives you a column that scales with the font size and remains within the readable range across different type settings.

article,
.prose {
  max-width: 70ch;
  margin-inline: auto;
}

This approach pairs naturally with responsive layouts — the container will be narrower on small screens (where the viewport itself constrains the width) and capped at 70ch on large screens where unconstrained text would otherwise span the full viewport width.

text-wrap: balance and text-wrap: pretty

Two relatively recent additions to the CSS specification address longstanding annoyances in typesetting.

text-wrap: balance distributes text evenly across all lines of a block, rather than filling each line to its maximum before wrapping. The primary use case is headings and short display text where an orphan word on the last line looks awkward. The browser performs additional layout calculations to find an optimal line distribution.

h1, h2, h3 {
  text-wrap: balance;
}

The CSS Text Level 4 specification notes that balance is intended for short strings — applying it to long body text paragraphs would be computationally expensive and visually unusual. Most implementations limit it to a few lines.

text-wrap: pretty targets a different problem: orphaned words at the end of paragraphs. It allows the browser to consider the full paragraph when making line-break decisions, trading a minor reflow of earlier lines to avoid a single word sitting alone on the last line. It is appropriate for body text where the slightly looser optimization won’t affect layout stability.

p {
  text-wrap: pretty;
}

Browser support for both properties has grown substantially since their introduction. Check MDN’s compatibility table before relying on either in production without a fallback strategy — the fallback behavior (normal wrapping) is acceptable but loses the typographic refinement.

Putting It Together

These properties are not independent levers. A large font size at a generous line height with a controlled measure creates the conditions for readable text; any one of them out of balance undermines the others. Tracking added to body text at a line height that is already loose compounds the problem. Tabular numbers in a context where columns never appear adds complexity with no benefit.

The useful heuristic is to ask what each property is doing for the reader. rem sizing respects the reader’s system preferences. Unitless line height scales proportionally so text at any size gets appropriate leading. Controlled measure prevents the reader’s eye from losing its place. OpenType features like tabular numbers serve specific reading contexts. text-wrap: pretty removes the mild irritation of orphan words.

Readability is not a property you add — it is the result of not introducing friction. The CSS properties covered here are the levers closest to the reading experience, and the HTML semantics article covers the structural layer that this rendering layer sits on top of.