RizTech Academy logo
RizTech Academy
CSS FoundationsLesson 8 of 825 min

Typography and web fonts

Most of a web page is text, so most of design is typography. Five properties do nearly all the work, and the one genuinely consequential decision is whether to load a font at all.

The five that matter

body {
  font-family: system-ui, sans-serif;
  font-size: 1rem;
  line-height: 1.6;
  font-weight: 400;
  letter-spacing: 0;
}

line-height, which is the one people get wrong

line-height: 1.6;      /* unitless — 1.6 × this element's font size */
line-height: 24px;     /* fixed */
line-height: 150%;     /* computed once, then inherited as a length */

Always unitless. The reason is inheritance: a unitless value is inherited as a ratio and recalculated per element, while 24px or 150% is computed on the parent and inherited as a fixed length. So with line-height: 150% on body, your h1 inherits 24px of line height for 40px text and the lines overlap.

Useful values: 1.5 to 1.7 for body text — WCAG asks for at least 1.5 — and 1.1 to 1.25 for large headings, because big text needs proportionally less space. A heading at 1.6 looks like it is falling apart.

body { line-height: 1.6; }
h1, h2, h3 { line-height: 1.2; }

Line length, which nobody teaches and everybody notices

A comfortable measure is 45 to 75 characters. Longer and the eye loses its place returning to the left; shorter and it jumps too often.

The ch unit makes this direct:

.prose {
  max-width: 65ch;
}

1ch is the width of a 0 in the current font, so 65ch is roughly 65 characters. This single declaration does more for readability than any font choice, and a full-width paragraph on a 1440px monitor is the most common design mistake on the web.

font-weight

font-weight: 400;      /* normal */
font-weight: 700;      /* bold */
font-weight: 600;      /* semibold */

Numbers, not bold, because they are unambiguous. A weight only renders if the font has it. Ask for 600 from a font with only 400 and 700 and the browser synthesises it by smearing the 400 — which looks slightly wrong in a way that is hard to name. Check which weights you actually loaded.

letter-spacing

Leave it at 0 for body text; the font designer already chose it. Two cases where it helps: large headings, which often want slightly tighter (-0.02em), and all-capitals text, which always wants looser (0.05em) because capitals were not designed to sit together.

Use em, not px, so it scales with the font size.

Font stacks, and the free option

font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;

system-ui is the operating system's own interface font — San Francisco on iOS and macOS, Roboto on Android, Segoe UI on Windows. It is already on the device: zero bytes, zero requests, renders on the first paint.

For a small business site this is very often the right answer, and it is the default this course recommends. The page looks native, it is instant even on a bad connection, and you have spent none of your visitor's data.

The honest counter-argument: a brand may need a specific typeface, and system-ui means your site looks slightly different on every platform. If that matters, load a font — knowing what it costs.

Loading a font, and what it costs

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-variable.woff2") format("woff2-variations");
  font-weight: 100 900;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+2000-206F, U+20B9;
}

Six things in there, and each is a decision.

woff2 only. It is compressed, supported by every browser you care about, and roughly 30% smaller than woff. The long src lists with eot and ttf you will find in older tutorials are for Internet Explorer.

Self-host. Do not link to Google Fonts' CDN. It is an extra DNS lookup and connection before the font is even requested, the shared-cache benefit stopped existing when browsers partitioned caches by site, and in several jurisdictions it is a privacy problem because the visitor's IP goes to a third party. Download the files into /fonts/ and serve them yourself — it is faster and simpler.

A variable font gives you every weight from 100 to 900 in one file, usually smaller than three static weights. font-weight: 100 900 declares the range.

font-display: swap is the important one. Without it, the browser hides the text for up to three seconds waiting for the font — a blank page with a working layout, which is worse than any font substitution. swap shows the fallback immediately and swaps when the font arrives.

The cost of swap is a visible reflow when the swap happens, because the fallback has different metrics. You reduce it by choosing a fallback with similar proportions, and by:

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter.woff2") format("woff2");
  font-display: swap;
  size-adjust: 107%;          /* match the fallback's x-height */
  ascent-override: 90%;
  descent-override: 22%;
}

Those three override the font's own metrics so the fallback and the real font occupy the same space, and the swap stops moving the page. This is what "zero layout shift font loading" means, and it is worth doing for the one font on your critical path.

unicode-range tells the browser which characters the file covers, so it only downloads what the page needs. Note U+20B9 — that is the rupee sign, and a Latin subset that omits it will fall back to a different font for ₹ alone, which looks visibly wrong on an Indian price list. Check it.

Preload the one font you need first:

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

crossorigin is required even for a same-origin font, and leaving it off means the font is downloaded twice. That is a genuinely common mistake.

Preload at most one or two files. Preloading everything makes the preloads compete with each other and with your CSS.

How much is too much

One variable font, Latin-subset, is roughly 25–40 KB. Three static weights of a static font is 60–100 KB. Two families at three weights each is 200 KB, which on a slow connection is a second of blank-or-wrong text before anything settles.

Budget one family, two weights. If you need a display face for headings, that is the second family, and body text should then be system-ui.

Practical text properties

text-align: left;                  /* start/end are the logical versions */
text-transform: uppercase;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 0.2em;
text-wrap: balance;                /* headings: even line lengths */
text-wrap: pretty;                 /* paragraphs: avoids single-word last lines */
hyphens: auto;
overflow-wrap: break-word;
font-variant-numeric: tabular-nums;

Three of these are worth adopting immediately.

text-wrap: balance on headings. A two-line heading with one word on the second line looks like a mistake; balance evens the lines out. Apply it only to short text — it is limited to a handful of lines by design.

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

tabular-nums for any column of numbers. By default many fonts give digits different widths, so a column of prices does not line up. This makes every digit the same width. Essential in a price table.

Never text-align: justify on the web. Without proper hyphenation and justification algorithms it produces rivers of white space, and it is measurably harder to read. Print can justify; browsers cannot, well.

And text-transform: uppercase changes only the rendering — a screen reader reads the original text, which is correct and is why you should not type in capitals to get the effect.

A type scale

Do not pick sizes at random. Pick a ratio and multiply:

:root {
  --step-0: 1rem;         /* 16px  body */
  --step-1: 1.25rem;      /* 20px */
  --step-2: 1.563rem;     /* 25px */
  --step-3: 1.953rem;     /* 31px */
  --step-4: 2.441rem;     /* 39px */
}

That is a 1.25 ratio. Common choices are 1.2 (subtle) through 1.333 (dramatic). Five steps is plenty for a small site, and having a fixed set stops the "is this 22px or 24px" question entirely.

Module 4 makes these fluid with clamp() so they shrink on a phone.

The reading-comfort checklist

For body text, all five at once:

.prose {
  font-size: 1rem;         /* never below 16px on a phone */
  line-height: 1.6;
  max-width: 65ch;
  color: #222;             /* not #000 — pure black on white is harsh */
  text-wrap: pretty;
}

Never below 16px for body text on mobile. Below that, iOS Safari zooms the page when a form field is focused, which is disorienting and looks broken. It is also simply too small.

And #222 rather than #000: pure black on pure white is uncomfortably high-contrast for long reading. #222 on #fff is still about 15:1, far above the 4.5:1 WCAG AA requirement.

Check your work

Why line-height must be unitless. A unit is computed once and inherited as a fixed length, so headings overlap.

Values for body and headings. 1.5–1.7, and 1.1–1.25.

The comfortable line length. 45–75 characters — max-width: 65ch.

Why numeric font-weight. Unambiguous, and a weight the font lacks is synthesised and looks wrong.

Where letter-spacing helps. Tighter on large headings, looser on capitals.

Why system-ui is a real option. Already on the device: zero bytes, instant.

Why self-host rather than a font CDN. An extra connection, no shared-cache benefit any more, and a third party gets the visitor's IP.

What font-display: swap prevents. Up to three seconds of invisible text.

What size-adjust and the override descriptors do. Make the fallback occupy the same space, so the swap does not move the page.

Why unicode-range needs checking. A Latin subset without U+20B9 renders ₹ in a different font.

What happens without crossorigin on a font preload. It downloads twice.

What tabular-nums fixes. Digits of different widths, so price columns do not align.

Why not justify. Rivers of white space; browsers do not hyphenate well enough.

Why body text is never below 16px on mobile. iOS Safari zooms on focus.

Why #222 rather than #000. Pure black on white is harsh for long reading, and #222 is still ~15:1.

Practice

  1. Set line-height: 150% on body and look at an h1. Change to 1.6.
  2. Set line-height: 1.6 on headings, then 1.2. Compare a two-line heading.
  3. Put a long paragraph at full width on a wide monitor, then max-width: 65ch.
  4. Ask for font-weight: 600 from a font with only 400 and 700. Compare with a genuine 600.
  5. Add letter-spacing: 0.05em to an all-caps label, then remove it.
  6. Build a page with system-ui and measure the total page weight.
  7. Load a variable font, self-hosted, with font-display: swap. Throttle to Slow 4G and watch the swap.
  8. Remove font-display and reload on Slow 4G. Time how long the text is invisible.
  9. Add size-adjust and the override descriptors and watch whether the page still jumps on swap.
  10. Put ₹450 on a page using a Latin-subset font and check in devtools which font rendered the ₹.
  11. Preload a font without crossorigin and count the requests in the Network tab.
  12. Add text-wrap: balance to a two-line heading. Then pretty to a paragraph.
  13. Build a price column with and without tabular-nums.
  14. Set body text to 15px and focus a form field on a real iPhone.
  15. Compare #000 and #222 body text on white for a full screen of reading.
  16. Build the five-step scale above and use only those sizes on a page.

Official documentation

Next module: layout — Flexbox and Grid.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship