All WordPress HTML Templates Forms & Webhooks AI & Tools
HTML Templates

Web Typography for Developers: Scale, Rhythm and Readability

A practical guide to web typography for developers: building a type scale in CSS, fixing fluid type zoom bugs, font pairing by metrics and killing font CLS.

A real working moment illustrating the theme of an article about web typography. Wide 16:9 banner, one strong focal point, magazine editorial quality, authentic and unstaged.

Open any client site in DevTools and set font-size on the body to 17px instead of 16px. If three other things move in ways nobody intended, you don’t have a type system. You have a pile of declarations that happen to look fine at one viewport width on one machine.

That’s the state of web typography on most projects we inherit. Twelve different font sizes, none of them related; line-heights copied from a Figma file where the designer was eyeballing it; a Google Fonts link in the head that blocks render for 400ms on a cold 4G connection. It works, in the sense that text appears. It just isn’t built.

The fix isn’t a new framework or another “10 beautiful font pairings” listicle. Four decisions determine everything: the scale, the measure, the leading, and how the fonts get onto the page. Get those right and the spacing system falls out almost for free.

Key Takeaways

  • A pure modular scale (1.25, 1.333, golden ratio) breaks down in UI because you need 11px, 12px, 13px and 14px to all exist. Use a hand-tuned scale of 7 to 9 named steps and stop pretending a single ratio solves it.
  • Never write clamp(1rem, 2vw, 2rem). A viewport-only middle term means text stops responding to browser zoom, which fails WCAG 1.4.4. Always include a rem component: clamp(1rem, 0.9rem + 0.5vw, 1.25rem).
  • Line-height and measure are a single decision. 65 characters at line-height: 1.5 reads well; the same leading at 100 characters does not, because the eye loses the line return.
  • Self-host your fonts and subset them. Browser HTTP cache has been partitioned per top-level site since Chrome 86, so the “someone else already cached Google Fonts” argument died in 2020.
  • Fallback metric overrides (size-adjust, ascent-override, descent-override) remove nearly all font-swap layout shift. This is the highest-value 10 lines of CSS in the whole article.

Pick the type scale before you pick the font

Designers choose typefaces first because that’s the fun part. Build the scale first anyway. The scale is the contract; the typeface is an implementation detail you can swap in a day.

Here’s the position that annoys people: strict modular scales are wrong for product UI. A 1.25 ratio from a 16px base gives you 16, 20, 25, 31.25, 39.06. Lovely in a specimen. Useless when you need a caption at 13px and a label at 14px and a table cell at 15px, because the ratio gives you 12.8 and 16 and nothing between. You end up bolting on “off-scale” values and the system lies to you.

What works on shipped sites is a hand-tuned scale: tighter increments at the small end where UI lives, wider at the display end where hierarchy needs to shout.

:root {
  / Hand-tuned. Small end steps by ~1px, display end steps by ratio. /
  --text-2xs: 0.6875rem; / 11px - legal, timestamps /
  --text-xs:  0.75rem;   / 12px - table meta, badges /
  --text-sm:  0.875rem;  / 14px - UI labels, nav /
  --text-base:1rem;      / 16px - body minimum on mobile /
  --text-md:  1.125rem;  / 18px - long-form body /
  --text-lg:  1.375rem;  / 22px /
  --text-xl:  1.75rem;   / 28px /
  --text-2xl: 2.25rem;   / 36px /
  --text-3xl: clamp(2.75rem, 2.2rem + 2.4vw, 4rem);

  / Leading shrinks as size grows. This is the part people skip. /
  --leading-tight: 1.1;
  --leading-snug:  1.25;
  --leading-body:  1.6;
  --leading-loose: 1.75;
}

Nine steps. Every one of them earns its place. If a design needs a tenth, you have a conversation instead of silently adding font-size: 23px to a component. If you’re already running a variable system for colour and spacing, the type scale belongs in the same file: our design tokens with CSS variables write-up covers how to keep those layers from drifting apart.

Fluid type without breaking zoom

Fluid type is good. The common implementation is broken. If the preferred (middle) value of your clamp() is pure vw, the text size is locked to the viewport and browser zoom does almost nothing, because zooming changes the CSS pixel ratio but the vw relationship holds. Users who zoom to 200% get a page that reflows but barely enlarges. That’s a WCAG 1.4.4 failure, and we’ve seen it flagged in two separate accessibility audits.

The rule: the middle term must contain a rem component large enough to carry the zoom. clamp(1.125rem, 1rem + 0.4vw, 1.375rem) is safe. clamp(1.125rem, 2vw, 1.375rem) is not.

Also, don’t make body copy fluid at all on most sites. Fluid display headings, fixed body. Body text wants one or two sizes chosen deliberately, not a continuous function of viewport width that lands on 16.3px.

A real working moment that fits an article section about

Measure and leading are one decision

The single highest-impact change you can make to a content page is capping the measure. 65 to 75 characters for long-form, and ch is the unit built for it, even though ch measures the advance of the zero glyph rather than an average character, so it runs slightly narrow. In practice max-width: 68ch lands around 72 to 78 real characters for most text faces. Close enough.

Leading depends on the measure, the x-height and the size, in that order. Long lines need more leading because the eye needs help finding the start of the next line. A large x-height font (Inter, Söhne, Public Sans) needs more leading than a small x-height font (Garamond-ish) at identical sizes, because the lowercase mass fills more of the line box.

.prose {
  max-width: 68ch;
  font-size: var(--text-md);
  line-height: var(--leading-body); / unitless, inherits proportionally /
}

/ Headings: leading tightens as size grows, never the same value as body /
.prose h2 { font-size: var(--text-xl);  line-height: var(--leading-snug); }
.prose h3 { font-size: var(--text-lg);  line-height: var(--leading-snug); }
.prose .lede { font-size: var(--text-lg); line-height: 1.45; text-wrap: pretty; }

/ Spacing expressed in line units keeps rhythm when the scale changes /
.prose >  +  { margin-block-start: 1lh; }
.prose h2 { margin-block-start: 1.75lh; }

Always use unitless line-height. A value like line-height: 24px inherits the computed pixel value into children, so your 28px heading inside a card gets 24px leading and the descenders collide. This bug ships constantly.

The lh and rlh units are the quiet win of the last couple of years. margin-block-start: 1lh means “one line of this element’s own text”, so paragraph spacing stays proportional when you bump the body size. Supported across Chromium, Safari and Firefox now, and the fallback (a plain rem value) degrades fine.

Vertical rhythm: abandon the baseline grid, keep the spacing scale

Baseline grids on the web are a trap. Holding a true grid requires every image, video embed, code block, form control and third-party widget to be an exact multiple of the baseline unit. You also need to kill margin collapsing and handle the fact that an h2 wrapping to two lines shifts everything below it by half a baseline. We’ve built two sites with strict baseline grids. Both were abandoned by the second round of content changes.

What survives contact with real content is softer: derive your spacing scale from the body line-height, then snap to it. If body is 18px at 1.6, that’s a 28.8px line box. Round to 28 and use 7, 14, 28, 42, 56, 84 as the spacing steps. Nothing aligns to a pixel-perfect grid, but the page has a consistent beat and that’s what readers actually perceive.

Two things that genuinely tighten rhythm without a grid:

  • Trim the half-leading. text-box: trim-both cap alphabetic removes the space above the cap height and below the baseline from the line box, so a heading’s visual top edge matches its box top edge. Shipped in Safari 18.2 and Chrome 133; Firefox is behind. It makes card headings and buttons align the way the designer drew them, no magic negative margins.
  • Consistent margin direction. Only ever set margin-block-start or only margin-block-end on flow content, never both. Mixed directions plus collapsing is where “why is there 51px here” comes from.

Font pairing for a website is mostly a metrics problem

Most font pairing advice is about mood: “pair a geometric sans with a humanist serif”. Fine, but it skips the part that actually determines whether the pairing looks engineered or accidental. That part is metrics.

Set both faces at the same font-size and screenshot them side by side. If the x-heights differ by more than roughly 5%, the smaller face will look weak and under-set next to the other one, and no amount of weight adjustment fixes it. The correction is not to guess pixel sizes. It’s size-adjust on the @font-face, which scales the glyph outlines while leaving the declared font-size alone, so your scale tokens stay honest.

The other half: pick for weight range, not for looks. One variable family with a 300 to 800 weight axis and real italics will outperform two static families on almost every project, because hierarchy comes from weight and size contrast far more than from typeface contrast. On client work, the pairing we reach for most is a single variable text face plus a genuinely different display face used at two sizes only. Three families is almost always one too many.

Practical screening checklist before you commit to a pairing:

  • Does it have true italics, or does the browser synthesise an oblique? Synthesised italics look cheap at 14px.
  • Does it ship tabular figures (font-variant-numeric: tabular-nums)? If there’s a pricing table or dashboard, non-tabular numerals will jitter on update.
  • How does it render at 13px on Windows with ClearType? Check on an actual Windows machine. Many beautiful fonts fall apart there.
  • What’s the woff2 weight after subsetting to Latin? Over 40KB for a single text weight is worth questioning.
  • Licence: does the EULA cover webfont use at the client’s pageview volume? This bites agencies more than anyone.

Loading fonts without the layout jump

Self-host. The cross-site cache benefit of using the Google Fonts CDN stopped existing when Chrome partitioned the HTTP cache by top-level site in version 86, and Safari did it earlier. What you get from the CDN now is an extra DNS lookup, an extra TLS handshake and an third-party dependency in your privacy policy.

Subset aggressively. pyftsubset from fonttools is still the tool:

# Latin + Latin-1 supplement + punctuation, layout features kept
pyftsubset Inter-Variable.ttf \
  --unicodes="U+0000-00FF,U+0131,U+2000-206F,U+2074,U+20AC,U+2122,U+2212" \
  --layout-features="kern,liga,calt,tnum,ss01" \
  --flavor=woff2 \
  --output-file=inter-latin.woff2

Then the part that kills cumulative layout shift. Declare a fallback @font-face that points at a local system font but overrides its metrics to match your webfont, so the swap is visually near-identical:

@font-face {
  font-family: "Inter var";
  src: url("/fonts/inter-latin.woff2") format("woff2-variations");
  font-weight: 300 800;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+2000-206F, U+20AC, U+2122;
}

/ Fallback shaped to match Inter's metrics. Compute these per pairing. /
@font-face {
  font-family: "Inter fallback";
  src: local("Arial"), local("Helvetica Neue");
  size-adjust: 107%;        / scale Arial up to Inter's x-height /
  ascent-override: 90%;
  descent-override: 22.5%;
  line-gap-override: 0%;
}

body { font-family: "Inter var", "Inter fallback", system-ui, sans-serif; }

Do not copy those percentages blindly. Compute them for your specific webfont and fallback pair using Capsize or Fontaine (Nuxt’s font module and next/font both do this automatically, which is most of why Next sites score well on CLS without anyone thinking about it). Getting these right has taken CLS from 0.14 to under 0.01 on sites where the only change was these descriptors.

Preload only the fonts used above the fold, and only one or two. Preloading six weights is worse than preloading none, because you contend with the CSS and the hero image for bandwidth during the critical window:

<link rel="preload" href="/fonts/inter-latin.woff2" as="font"
      type="font/woff2" crossorigin>

The crossorigin attribute is mandatory even for same-origin fonts. Omit it and the browser fetches the file twice. That one trips up people every year.

A web typography QA pass before launch

Run this list on every project. It takes 20 minutes and catches the things screenshots don’t.

  1. Zoom the browser to 200% and then to 400%. Does body text actually grow? Does anything become unreachable?
  2. Set the browser’s default font size to 20px (not zoom, the actual preference). Anything hard-coded in px will ignore it. Decide if that’s acceptable per element.
  3. Paste in the longest realistic heading the CMS could produce. Then the shortest. Both should look deliberate; text-wrap: balance handles headings up to about six lines, after which the browser bails out for performance.
  4. Throttle to Slow 4G with cache disabled and watch the font swap in slow motion. Measure the shift, don’t eyeball it.
  5. Check a Windows machine and an Android device, not just your Mac. Hinting and rendering differ enough to change the weight you should ship.
  6. Check prefers-reduced-motion is irrelevant here but prefers-contrast is not: your 400-weight grey body text at #6b7280 may fail contrast at small sizes.
  7. Print stylesheet. Serif, 11pt, no fluid sizing. Clients do print pages.

If you’re working inside Bootstrap, the default scale is a reasonable starting point but its heading sizes assume a marketing page, not an app. Override $font-size-base, $h1-font-size through $h6-font-size and $line-height-base in your Sass entry point rather than fighting the generated utilities later; we go into where Bootstrap still earns its keep in Bootstrap 5 in 2026. Templates that ship with a coherent type system save a real day of work here, which is one of the things we obsess over in Canvas, where the scale, leading and heading rhythm are variables you change in one place rather than hunting across 200 demos.

Small things with outsized effect

Tabular figures everywhere numbers change. font-variant-numeric: tabular-nums on tables, counters, prices and timers. Without it, a counter ticking from 19 to 20 visibly jumps.

Optical sizing. If your variable font has an opsz axis, font-optical-sizing: auto is free and makes display sizes tighten and small sizes open up. Fonts with this axis done well (Roboto Flex, Source Serif 4, Newsreader) look noticeably more crafted at both ends.

Hyphenation on narrow columns. hyphens: auto plus lang="en" on the html element. Without the lang attribute the browser has no dictionary and does nothing. Only enable it below roughly 45ch, where ragging gets ugly.

Don’t justify. Browsers still have no hyphenation-and-justification engine worth the name, and text-align: justify on the web produces rivers. The exception is narrow columns with hyphens: auto in a print-like layout, and even then, test it.

Letter-spacing is size-dependent. Tighten display sizes (-0.02em at 48px and above), leave body alone, open up small caps and all-caps labels (0.06em). A single global letter-spacing value is always wrong for something.

Frequently Asked Questions

What type scale ratio should I actually use?

Use 1.2 (minor third) as a starting reference for content-heavy sites and 1.25 for marketing pages where headings need more presence, then hand-adjust every step. Ratios above 1.333 leave unusable gaps in the middle of the scale and force you to invent off-scale values. Treat the ratio as a sketch, not a rule.

Is 16px still the minimum body size in 2026?

16px is the minimum for mobile body copy, mostly because iOS Safari zooms the viewport when you focus an input smaller than 16px. For long-form reading on desktop, 18px to 20px is the more honest default and we ship 18px on nearly every editorial project. UI labels and table data can and should go smaller.

Should I use font-display: swap or optional?

Use swap with proper fallback metric overrides for most sites, because readers get the branded face even on a slow connection. Use optional only when CLS is your hard constraint and you accept that a meaningful share of first visits will never see your webfont at all. Without metric overrides, optional is the safer choice of the two.

How many font families and weights should a site load?

One variable family covering 300 to 800 plus italics is enough for most projects, which is typically two woff2 files after subsetting. If you add a display face, load one weight of it and restrict it to h1 and h2. Four or more static weights of two families is a common pattern and it’s usually 150KB of avoidable transfer.

Does text-wrap: pretty slow down rendering?

It costs more than normal wrapping because the browser evaluates multiple line-break candidates, but the implementations limit how far back they look, so the cost is bounded. Apply it to headings, ledes and pull quotes rather than globally on body text. On a page with a few thousand paragraphs, measure before shipping it everywhere.

Where to start

If you only do one thing this week, add fallback metric overrides to your existing font stack and measure the CLS difference in your field data. It’s the cheapest fix with the clearest number attached.

If you have a day, rebuild the scale: nine steps, four leading values, one measure cap, all in custom properties. Then delete every hard-coded font-size in your components until the build breaks. What survives is the system. Everything after that, the pairing, the optical sizing, the wrap behaviour, is refinement on top of a foundation that holds.

fluid typography clamp accessibility zoom font pairing website metrics x-height line-height and measure ch unit readability self host webfonts subset woff2 pyftsubset size-adjust ascent-override font fallback cls text-box trim cap alphabetic css type scale css custom properties web typography