---
title: 'Responsive Typography'
source: 'https://academia.sh/en/courses/layout-and-responsive-design/responsive-typography'
course: 'Layout Systems and Responsive Design'
language: en
updated: '2026-08-17T18:09:28+00:00'
license: 'CC BY-SA 4.0'
---

# Responsive Typography

Deriving a fluid font-size expression from two anchor points, the scale ratio narrowing on a narrow screen, line-length control, and how text responds to zoom and to the user's setting.

The previous lesson placed card text inside a `clamp()` expression and gave three numbers: a
lower bound, a preferred value, an upper bound. How the numbers were chosen was never said.

This lesson turns that choice into computation. Font size changing with scale is not a visual
preference, it is the result of a readability constraint; once the constraint is turned into a
number, the expression follows on its own.

## Two Anchor Points, One Line

In **fluid typography**, the input is two decisions: how large the text should be at the
**narrowest** layout, and how large at the **widest**. Two points determine a line; the
`clamp()` expression's middle term is that line's equation.

The line's slope is written with a viewport unit, its intercept with the base unit. The sum of
the two gives the text's linear growth as the viewport grows.

```js
// fluid.mjs — deriving a fluid font-size expression from two anchor points
const ROOT = 16;

// two anchor points: (viewport, desired font size)
function derive(v1, s1, v2, s2) {
  const slope = (s2 - s1) / (v2 - v1);      // px / px
  const vw = slope * 100;                   // 1vw = one percent of the viewport
  const constPx = s1 - slope * v1;          // the expression's constant term
  return { vw, constRem: constPx / ROOT, constPx };
}

const body = derive(360, 16, 1280, 20);
const heading = derive(360, 24, 1280, 40);

const format = (t, min, max) =>
  `clamp(${(min / ROOT).toFixed(3)}rem, ${t.constRem.toFixed(4)}rem + ${t.vw.toFixed(4)}vw, ${(max / ROOT).toFixed(3)}rem)`;

console.log("anchor points: 360px -> body 16px / heading 24px,  1280px -> body 20px / heading 40px");
console.log("body   :", format(body, 16, 20));
console.log("heading:", format(heading, 24, 40));

const clamp = (lo, target, hi) => Math.max(lo, Math.min(target, hi));
const resolve = (t, min, max, viewport) => clamp(min, t.constPx + (t.vw * viewport) / 100, max);

console.log("\nviewport     body   heading  heading/body  body zone");
for (const V of [320, 360, 500, 700, 900, 1280, 1600]) {
  const g = resolve(body, 16, 20, V);
  const b = resolve(heading, 24, 40, V);
  const zone = g === 16 ? "lower bound" : g === 20 ? "upper bound" : "preferred";
  console.log(
    `${String(V).padStart(11)}  ${g.toFixed(2).padStart(5)}  ${b.toFixed(2).padStart(6)}  ` +
      `${(b / g).toFixed(3).padStart(12)}  ${zone.padStart(13)}`,
  );
}

// line-length check: text column bounded at 675px, character width is half the font size
console.log("\n--- line length check (column upper bound 675px, side padding 48px) ---");
console.log("viewport     column body   characters  in 45-75 range");
for (const V of [320, 360, 500, 700, 900, 1280, 1600]) {
  const column = Math.min(675, V - 48);
  const g = resolve(body, 16, 20, V);
  const characters = column / (g * 0.5);
  console.log(
    `${String(V).padStart(11)}  ${column.toFixed(0).padStart(5)}  ${g.toFixed(2).padStart(5)}  ` +
      `${characters.toFixed(1).padStart(8)}  ${(characters >= 45 && characters <= 75 ? "yes" : "NO").padStart(19)}`,
  );
}
```

```
anchor points: 360px -> body 16px / heading 24px,  1280px -> body 20px / heading 40px
body   : clamp(1.000rem, 0.9022rem + 0.4348vw, 1.250rem)
heading: clamp(1.500rem, 1.1087rem + 1.7391vw, 2.500rem)

viewport     body   heading  heading/body  body zone
        320  16.00   24.00         1.500    lower bound
        360  16.00   24.00         1.500    lower bound
        500  16.61   26.43         1.592      preferred
        700  17.48   29.91         1.711      preferred
        900  18.35   33.39         1.820      preferred
       1280  20.00   40.00         2.000    upper bound
       1600  20.00   40.00         2.000    upper bound

--- line length check (column upper bound 675px, side padding 48px) ---
viewport     column body   characters  in 45-75 range
        320    272  16.00      34.0                   NO
        360    312  16.00      39.0                   NO
        500    452  16.61      54.4                  yes
        700    652  17.48      74.6                  yes
        900    675  18.35      73.6                  yes
       1280    675  20.00      67.5                  yes
       1600    675  20.00      67.5                  yes
```

The two lines of output give two expressions that are hard to write by hand. For body text,
the constant term is 0.9022 base units, the slope 0.4348 viewport units. The numbers being
decimal is expected: a line's coefficients through two anchor points do not come out round.

The expression works in three zones. Below 360 units, the lower bound applies; above 1280, the
upper bound; in between, the line does the work. The values at the anchor points are exactly
the requested numbers — this is the derivation's correctness check.

## The Scale Narrows on a Narrow Screen

The fourth column measures something separate: the ratio between heading and body is not
fixed.

In the narrow layout the ratio is 1.5; in the wide layout, 2.0. This is the unavoidable result
of the two expressions having different slopes, and it is the wanted result. A heading that is
twice the body text splits a line in two on a narrow screen and produces an obstacle instead of
hierarchy information. The same ratio finds its place on a wide screen.

The **modular scale** introduced in the Visual Presentation with CSS course gives the ratio
between steps as a fixed multiplier. In fluid typography, this multiplier itself changes with
scale: it compresses in the narrow layout, opens up in the wide one. In practice this means
writing a separate `clamp()` expression for each scale step; the steps' expressions end up with
different slopes.

One warning: the scale steps' **order** must be preserved. When two expressions are written
with different slopes, it is possible for a lower step to overtake a higher one at a particular
width. The way to check is the derivation itself — if the values at both ends are ordered and
both expressions are linear, the order in between does not break.

## Line-Length Control

Font size carries no meaning on its own; what is meaningful is **line length** — that is, how
many characters sit on a line. The second table checks this: character count is found by
dividing column width by half the font size.

At five widths the result is in range, at two it is not. At 320- and 360-unit viewports, the
line is 34 and 39 characters; below the lower bound.

This is not a bug, it is a constraint. On a narrow screen, the only way to reach 45 characters
is to shrink the text, and the text is already at its lower bound. The two constraints cannot
both be met at once; the readable font size is preserved, and line length is accepted as is.

The table's real value is that the constraint is accepted **knowingly**. The same computation
also catches the case where the upper bound is exceeded — at 700 units the line reaches 74.6
characters, right against the bound. If the column's upper bound had been chosen as 720 units
instead of 675, this line would have gone outside the range.

## Zoom and the User's Setting

A font-size expression's final test is how it responds to the user's wish to enlarge text.

```js
// zoom.mjs — how a font-size expression responds to page zoom and to the user's setting
const DEVICE = 1280;   // the device's physical width (device pixels)

// Zoom ratio Z: 1 CSS pixel becomes Z device pixels,
// the viewport in CSS pixels becomes DEVICE/Z.
function measure(Z, root) {
  const viewportCss = DEVICE / Z;
  const vwOnly = (1.25 * viewportCss) / 100;                    // font-size: 1.25vw
  const remOnly = 1.25 * root;                                   // font-size: 1.25rem
  const preferred = 0.9022 * root + (0.4348 * viewportCss) / 100; // clamp's middle term
  const clamped = Math.max(1 * root, Math.min(preferred, 1.25 * root)); // clamp(1rem, ..., 1.25rem)
  return {
    viewportCss,
    vwCss: vwOnly, vwDevice: vwOnly * Z,
    remCss: remOnly, remDevice: remOnly * Z,
    clampCss: clamped, clampDevice: clamped * Z,
  };
}

console.log(`device width ${DEVICE} device pixels, root font size 16px\n`);
console.log("--- page zoom: text's physical size ---");
console.log("zoom           viewport(css)     1.25vw  1.25rem  clamp  <- device pixels");
for (const Z of [1, 1.25, 1.5, 2]) {
  const o = measure(Z, 16);
  console.log(
    `${(Z * 100 + "%").padStart(13)}  ${o.viewportCss.toFixed(0).padStart(16)}  ` +
      `${o.vwDevice.toFixed(2).padStart(6)}  ${o.remDevice.toFixed(2).padStart(7)}  ${o.clampDevice.toFixed(2).padStart(5)}`,
  );
}
const z1 = measure(1, 16), z2 = measure(2, 16);
console.log("\ngrowth ratio at 200% zoom:");
console.log(`  1.25vw  : ${(z2.vwDevice / z1.vwDevice).toFixed(2)}x`);
console.log(`  1.25rem : ${(z2.remDevice / z1.remDevice).toFixed(2)}x`);
console.log(`  clamp   : ${(z2.clampDevice / z1.clampDevice).toFixed(2)}x`);

console.log("\n--- if the user changes their default font size (no zoom) ---");
console.log("default     1.25vw  1.25rem  clamp  <- css pixels");
for (const root of [16, 20, 24, 32]) {
  const o = measure(1, root);
  console.log(
    `${(root + "px").padStart(10)}  ${o.vwCss.toFixed(2).padStart(6)}  ` +
      `${o.remCss.toFixed(2).padStart(7)}  ${o.clampCss.toFixed(2).padStart(5)}`,
  );
}
const d1 = measure(1, 16), d2 = measure(1, 32);
console.log("\ngrowth ratio when the default is doubled:");
console.log(`  1.25vw  : ${(d2.vwCss / d1.vwCss).toFixed(2)}x`);
console.log(`  1.25rem : ${(d2.remCss / d1.remCss).toFixed(2)}x`);
console.log(`  clamp   : ${(d2.clampCss / d1.clampCss).toFixed(2)}x`);
```

```
device width 1280 device pixels, root font size 16px

--- page zoom: text's physical size ---
zoom           viewport(css)     1.25vw  1.25rem  clamp  <- device pixels
         100%              1280   16.00    20.00  20.00
         125%              1024   16.00    25.00  23.61
         150%               853   16.00    30.00  27.22
         200%               640   16.00    40.00  34.44

growth ratio at 200% zoom:
  1.25vw  : 1.00x
  1.25rem : 2.00x
  clamp   : 1.72x

--- if the user changes their default font size (no zoom) ---
default     1.25vw  1.25rem  clamp  <- css pixels
      16px   16.00    20.00  20.00
      20px   16.00    25.00  23.61
      24px   16.00    30.00  27.22
      32px   16.00    40.00  34.44

growth ratio when the default is doubled:
  1.25vw  : 1.00x
  1.25rem : 2.00x
  clamp   : 1.72x
```

The first table's first column carries the entire result: font size written only with a
viewport unit is **not affected by zoom at all**. The reason is arithmetic. When the zoom
ratio doubles, the viewport in CSS pixels is halved; font size is halved too; since every CSS
pixel is now two device pixels, the result stays unchanged. The user zooms, everything on the
page grows, the text stays where it is.

Size written with the base unit doubles. The mixed expression sits between the two: 1.72
times. The scale ratio is exactly the constant term's share of the total; the heavier the
viewport unit, the weaker the response.

The second table gives the same numbers. This is not a coincidence: when the constant term is
written with the base unit and the variable term with the viewport unit, doubling zoom and
doubling the root font size run through the same result. Both settings pass through the same
expression.

This is where the accessibility criterion is read from. WCAG 1.4.4 requires that text be
enlargeable up to 200 percent without an assistive tool, and without loss of content or
function. A font size written only with a viewport unit does not meet this criterion. The rule
is plain: the preferred value **must have a component written with the base unit**; a viewport
unit is not used alone.

## Text's Other Measures

Font size is not the only variable.

**Line height** is written unitless: `line-height: 1.5` is a ratio and is multiplied by each
element's own font size. When a length is written instead — `line-height: 24px` — what passes
by inheritance is the computed length; a 32-unit heading stays with a 24-unit line height and
its lines overlap. The ratio also changes with scale: a wider line height in small text, a
narrower one in a large heading, both improve readability.

**Letter spacing** narrows at large sizes. As heading size grows, the visual gap between
letters opens up; a small negative value corrects this. Body text needs no such correction.

**Line-break behavior** is set with two declarations. `text-wrap: balance` equalizes line
lengths in headings and prevents a single word from being left on the last line;
`text-wrap: pretty` fixes a single trailing word at the end of a paragraph in long text. Both
are suggestions, not guarantees.

**Hyphenation** is turned on with `hyphens: auto` and works only if the document's language is
declared — the language declaration built in the Web Fundamentals and HTML course gains a
functional counterpart here. It reduces large gaps at line ends in narrow columns.

```css
/* station.css — step 10: fluid typography scale */
:root {
  --font-body:    clamp(1rem, 0.9022rem + 0.4348vw, 1.25rem);
  --font-heading: clamp(1.5rem, 1.1087rem + 1.7391vw, 2.5rem);
}

body {
  font-size: var(--font-body);
  line-height: 1.6;
}

h1 {
  font-size: var(--font-heading);
  line-height: 1.15;
  letter-spacing: -0.01em;
  text-wrap: balance;
}

.station-notes {
  max-inline-size: 675px;
  hyphens: auto;
  text-wrap: pretty;
}
```

Two custom properties carry two derived expressions. Line height is 1.6 in the body, 1.15 in
the heading; being unitless, both are multiplied by their own font size. The column's upper
bound is written in pixels rather than the base unit — a deliberate choice: the column width
comes from the line-length check, and that check already accounts for font size.

## Summary

- Fluid font size is derived from two anchor points; the slope is written with a viewport
  unit, the constant term with the base unit, forming the `clamp()` expression's middle term.
- When scale steps are written with different slopes, the heading-to-body ratio narrows in the
  narrow layout and opens up in the wide one; this is the wanted behavior.
- Line length is column width divided by font size, and is kept in the 45–75 character range;
  in very narrow layouts, the lower bound cannot be met and this constraint is accepted
  knowingly.
- Font size written only with a viewport unit is unaffected by zoom, and fails to meet the
  criterion requiring text to be enlargeable up to 200 percent.
- Doubling zoom and doubling the root font size run through the same expression and give the
  same result.
- Line height is written unitless; when a length is written instead, the computed value that
  passes by inheritance causes lines to overlap in large text.

## Next Step

Text now changes with scale, layout adapts to its container. What remains is the page's
heaviest element: the image. An image can be downloaded at an unnecessarily large file size on
a narrow screen, or look blurry on a wide one; both arise from the same declaration. On top of
that, for some images the problem is not size but content — a wide landscape shot needs to be
cropped to stay meaningful on a narrow screen. The next lesson separates these two questions:
which file does the browser choose, and who directs that choice?
