CSS Module 12 – Responsive design: media and container queries
Fluid typography and spacing scales
Build fluid type and spacing scales with clamp(), work out each line from two sizes, keep every step growing with zoom, and test the scale at three widths.
What you will learn
- Build a fluid type scale with clamp() that still respects user zoom
- Create a matching fluid spacing scale with custom properties
- Check the scales at small, medium and large widths and at high zoom
Before you start
What you’ll learn · Before you start
What you will learn
- Build a fluid type scale with clamp() that still respects user zoom
- Create a matching fluid spacing scale with custom properties
- Check the scales at small, medium and large widths and at high zoom
Before you start
On this page
Fluid typography lets text grow smoothly with the layout instead of jumping at breakpoints. A size such as
clamp(1rem, 0.9583rem + 0.1852vw, 1.125rem) is 16 px up to a 360 px wide layout, 18 px from 1440 px, and on a straight
line in between. A scale is a small set of such sizes, one per step from small print to the main heading, plus a
matching set of spaces, all kept as custom properties so every component uses the same values. Two decisions make a
scale good or bad: where its lines start and end, and whether its text still doubles when someone zooms, which WCAG
2.2 asks through success criterion 1.4.4 (Resize Text). This lesson builds a six-step type scale and an eight-step
spacing scale, measures them in Chromium 153, Firefox 155 and WebKit 26.6, and checks every step against zoom.
From two sizes to one clamp()
A fluid size is a line through two points, chosen by design: a size at a narrow layout width and a size at a wide one. For body text of 16 px at 360 px and 18 px at 1440 px:
- the slope is the growth per px of width, (18 − 16) ÷ (1440 − 360) = 0.001852, which is 0.1852vw, because 1vw is 1 % of the width;
- the intercept is where the line would meet a 0 px layout, 16 − 0.001852 × 360 = 15.33 px, which is 0.9583rem;
clamp()keeps the result between the two sizes, so the line goes flat below 360 px and above 1440 px.
The math functions lesson of this track works through this algebra for one value. A scale needs it for every step, so a small program writes all of them.
A type scale with six steps
A modular scale multiplies each step by the same ratio: with 16 px text and a ratio of 1.2, the steps above are 19.2, 23.04 and 27.65 px. A fluid scale uses two such scales, one for each end. Narrow screens get a small ratio, so a main heading doesn’t fill half the screen with three words; wide screens get a larger one, so headings stand out from the text. This program uses 16 px and 1.2 at 360 px, 18 px and 1.25 at 1440 px, and prints the steps as custom properties, with eight spaces that the next section explains:
// A fluid type scale and a spacing scale from two ends. At a 360 px viewport the text is 16 px and each type step is
// 1.2 times the one below; at 1440 px the text is 18 px and each step 1.25 times. Spaces are multiples of the text
// size at each end. Every value is a straight line between its two sizes, written as
// clamp(MIN, INTERCEPT + SLOPE vw, MAX) in rem (1rem = 16 px).
const NARROW = { width: 360, text: 16, ratio: 1.2 };
const WIDE = { width: 1440, text: 18, ratio: 1.25 };
const num = (x) => Number(x.toFixed(4));
const rem = (px) => `${num(px / 16)}rem`;
function fluid(minPx, maxPx) {
const slope = (maxPx - minPx) / (WIDE.width - NARROW.width); // px of size per px of viewport
const intercept = minPx - slope * NARROW.width; // where the line meets a 0 px viewport
return `clamp(${rem(minPx)}, ${rem(intercept)} + ${num(slope * 100)}vw, ${rem(maxPx)})`;
}
console.log(':root {');
for (let n = -1; n <= 4; n++) {
const min = NARROW.text * NARROW.ratio ** n;
const max = WIDE.text * WIDE.ratio ** n;
console.log(` --step-${n}: ${fluid(min, max)}; /* ${num(min)} → ${num(max)} px */`);
}
const SPACES = { '3xs': 0.25, '2xs': 0.5, xs: 0.75, s: 1, m: 1.5, l: 2, xl: 3, '2xl': 4 };
for (const [name, k] of Object.entries(SPACES)) {
console.log(` --space-${name}: ${fluid(k * NARROW.text, k * WIDE.text)}; /* ${num(k * NARROW.text)} → ${num(k * WIDE.text)} px */`);
}
// A jump: one value that moves from the s space on phones to the xl space on wide screens.
console.log(` --space-s-xl: ${fluid(SPACES.s * NARROW.text, SPACES.xl * WIDE.text)}; /* 16 → 54 px */`);
console.log('}'); Output
:root {
--step--1: clamp(0.8333rem, 0.8111rem + 0.0988vw, 0.9rem); /* 13.3333 → 14.4 px */
--step-0: clamp(1rem, 0.9583rem + 0.1852vw, 1.125rem); /* 16 → 18 px */
--step-1: clamp(1.2rem, 1.1312rem + 0.3056vw, 1.4063rem); /* 19.2 → 22.5 px */
--step-2: clamp(1.44rem, 1.3341rem + 0.4708vw, 1.7578rem); /* 23.04 → 28.125 px */
--step-3: clamp(1.728rem, 1.5716rem + 0.6952vw, 2.1973rem); /* 27.648 → 35.1563 px */
--step-4: clamp(2.0736rem, 1.8493rem + 0.997vw, 2.7466rem); /* 33.1776 → 43.9453 px */
--space-3xs: clamp(0.25rem, 0.2396rem + 0.0463vw, 0.2813rem); /* 4 → 4.5 px */
--space-2xs: clamp(0.5rem, 0.4792rem + 0.0926vw, 0.5625rem); /* 8 → 9 px */
--space-xs: clamp(0.75rem, 0.7188rem + 0.1389vw, 0.8438rem); /* 12 → 13.5 px */
--space-s: clamp(1rem, 0.9583rem + 0.1852vw, 1.125rem); /* 16 → 18 px */
--space-m: clamp(1.5rem, 1.4375rem + 0.2778vw, 1.6875rem); /* 24 → 27 px */
--space-l: clamp(2rem, 1.9167rem + 0.3704vw, 2.25rem); /* 32 → 36 px */
--space-xl: clamp(3rem, 2.875rem + 0.5556vw, 3.375rem); /* 48 → 54 px */
--space-2xl: clamp(4rem, 3.8333rem + 0.7407vw, 4.5rem); /* 64 → 72 px */
--space-s-xl: clamp(1rem, 0.2083rem + 3.5185vw, 3.375rem); /* 16 → 54 px */
}
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node fluid_scale.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Its output is the token file, pasted as it was printed:
/* The type and spacing tokens that programs/fluid_scale.mjs prints: 16 px text with type steps of 1.2 at a 360 px
viewport, 18 px text with steps of 1.25 at 1440 px, spaces as multiples of the text size, and straight lines
in between. */
:root {
--step--1: clamp(0.8333rem, 0.8111rem + 0.0988vw, 0.9rem); /* 13.3333 → 14.4 px */
--step-0: clamp(1rem, 0.9583rem + 0.1852vw, 1.125rem); /* 16 → 18 px */
--step-1: clamp(1.2rem, 1.1312rem + 0.3056vw, 1.4063rem); /* 19.2 → 22.5 px */
--step-2: clamp(1.44rem, 1.3341rem + 0.4708vw, 1.7578rem); /* 23.04 → 28.125 px */
--step-3: clamp(1.728rem, 1.5716rem + 0.6952vw, 2.1973rem); /* 27.648 → 35.1563 px */
--step-4: clamp(2.0736rem, 1.8493rem + 0.997vw, 2.7466rem); /* 33.1776 → 43.9453 px */
--space-3xs: clamp(0.25rem, 0.2396rem + 0.0463vw, 0.2813rem); /* 4 → 4.5 px */
--space-2xs: clamp(0.5rem, 0.4792rem + 0.0926vw, 0.5625rem); /* 8 → 9 px */
--space-xs: clamp(0.75rem, 0.7188rem + 0.1389vw, 0.8438rem); /* 12 → 13.5 px */
--space-s: clamp(1rem, 0.9583rem + 0.1852vw, 1.125rem); /* 16 → 18 px */
--space-m: clamp(1.5rem, 1.4375rem + 0.2778vw, 1.6875rem); /* 24 → 27 px */
--space-l: clamp(2rem, 1.9167rem + 0.3704vw, 2.25rem); /* 32 → 36 px */
--space-xl: clamp(3rem, 2.875rem + 0.5556vw, 3.375rem); /* 48 → 54 px */
--space-2xl: clamp(4rem, 3.8333rem + 0.7407vw, 4.5rem); /* 64 → 72 px */
--space-s-xl: clamp(1rem, 0.2083rem + 3.5185vw, 3.375rem); /* 16 → 54 px */
} A page using the steps, with two hero headings for the zoom check further down:
type-scale.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>A fluid type and spacing scale</title>
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="using-tokens.css">
<style>
/* Two hero headings that end at 72 px: one starts at 24 px (a third), one at 36 px (half) */
.hero { font-size: clamp(1.5rem, 0.5rem + 4.4444vw, 4.5rem); }
.hero-gentle { font-size: clamp(2.25rem, 1.5rem + 3.3333vw, 4.5rem); }
</style>
</head>
<body>
<main class="section stack">
<h1>Step 4</h1><h2>Step 3</h2><h3>Step 2</h3><h4>Step 1</h4><p>Step 0</p><small>Step -1</small>
<p class="hero">The steep hero</p>
<p class="hero-gentle">The gentle hero</p>
</main>
<script>
const px = (el, prop) => parseFloat(getComputedStyle(el)[prop]).toFixed(2);
const sizes = [...document.querySelectorAll('h1, h2, h3, h4, main > p:not([class]), small, .hero, .hero-gentle')]
.map((el) => `${el.textContent} ${px(el, 'fontSize')}`);
const main = document.querySelector('main');
console.log(`${innerWidth} px: ${sizes.join(' · ')}`);
console.log(`${innerWidth} px: section padding ${px(main, 'paddingTop')} · gap ${px(main, 'rowGap')}`);
</script>
</body>
</html> using-tokens.css
/* The tokens at work: type steps for text, spaces for the gaps between things, and the jump for section padding. */
body { font-size: var(--step-0); line-height: 1.5; }
h1 { font-size: var(--step-4); line-height: 1.1; }
h2 { font-size: var(--step-3); line-height: 1.2; }
h3 { font-size: var(--step-2); line-height: 1.3; }
h4 { font-size: var(--step-1); line-height: 1.4; }
small { font-size: var(--step--1); }
.section {
padding-block: var(--space-s-xl); /* 16 px on a phone, 54 px on a wide screen */
padding-inline: var(--space-s);
}
.stack {
display: flex;
flex-direction: column;
gap: var(--space-m); /* the layout owns the space between its children */
}
.card {
padding: var(--space-s);
border-radius: var(--space-2xs);
} | Step | 320 px | 768 px | 900 px | 1440 px |
|---|---|---|---|---|
4, h1 |
33.18 | 37.25 | 38.56 | 43.95 |
3, h2 |
27.65 | 30.48 | 31.40 | 35.16 |
2, h3 |
23.04 | 24.96 | 25.58 | 28.12 |
1, h4 |
19.20 | 20.45 | 20.85 | 22.50 |
| 0, text | 16.00 | 16.76 | 17.00 | 18.00 |
−1, small |
13.33 | 13.74 | 13.87 | 14.40 |
The sizes are Chromium’s and WebKit’s, in px; Firefox was within 0.03 px of them everywhere, and all three matched the program’s line. Two checks are built into the widths. At 320 px every step sits at its minimum, because the line goes flat below 360 px. And 900 px is halfway between the ends, so each size there must be the average of its two ends: step 0 is (16 + 18) ÷ 2 = 17 px, which is what the browsers drew.
Each fluid size is a line between two widths, and zoom moves along it
Text description of the diagram
The chart plots font size in px, from 0 to 80, against the layout width in CSS px, with marks at 360, 720 and 1440. Each size is flat up to 360 px, rises in a straight line to 1440 px and is flat again after it.
- The steep hero, drawn thick and blue, rises from 24 px to 72 px.
- Step 4 of the type scale rises from 33 px to 44 px.
- Step 0, the body text, rises from 16 px to 18 px, almost flat.
A dashed vertical line at 720 px marks a 1440 px window at 200 % zoom, with a dot where it crosses each line: 40 px for the hero, 36.8 px for step 4 and 16.7 px for step 0. Zoom moves the layout left along each line, and every CSS px is then drawn twice as large, so the steep hero is drawn at 80 px, only a little bigger than its 72 px at 100 %.
Keep the scale small, about six steps of type and eight of space, and give the steps neutral names (--step-2,
--space-m) rather than names of elements, so a card title can use step 2 without pretending to be an h3.
Zoom: does every step still double?
Browser zoom does two things at once. The layout gets narrower in CSS px, by the zoom factor, and every CSS px is drawn that many times larger. A fluid size therefore slides down its line while it is drawn bigger: in a 1440 px window at 200 %, the layout is 720 px wide and step 0 is 16.67 px, drawn at 33.3 px, only 1.85 times its 18 px at 100 %. WCAG’s failure F94 describes the extreme case, text in viewport units alone, which never grows at all. This program checks every step, using the zoom levels Chromium offers:
// Does each step of the type scale still reach twice its size when a 1440 px window is zoomed? At zoom z the layout
// is 1440 / z CSS px wide, so the computed size falls along the line, and every CSS px is drawn z times larger.
// WCAG 2.2 success criterion 1.4.4 asks that text can be resized to 200 %. The zoom levels are Chromium's presets.
const ZOOMS = [1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5];
const WINDOW = 1440;
function line(minPx, maxPx, minWidth = 360, maxWidth = 1440) {
const slope = (maxPx - minPx) / (maxWidth - minWidth);
return (width) => Math.min(Math.max(minPx, minPx + slope * (width - minWidth)), maxPx);
}
const SIZES = [];
for (let n = -1; n <= 4; n++) SIZES.push([`step ${n}`, line(16 * 1.2 ** n, 18 * 1.25 ** n)]);
SIZES.push(['steep hero', line(24, 72)]);
SIZES.push(['gentle hero', line(36, 72)]);
const drawn = (size, z) => z * size(WINDOW / z); // in px of the screen at 100 %
console.log('size 100 % 200 % 250 % 500 % doubles at');
for (const [name, size] of SIZES) {
const base = drawn(size, 1);
const first = ZOOMS.find((z) => drawn(size, z) >= 2 * base - 1e-9);
const cells = [1, 2, 2.5, 5].map((z) => drawn(size, z).toFixed(1).padStart(6)).join(' ');
console.log(`${name.padEnd(11)}${cells} ${first ? `${first * 100} %` : 'never, even at 500 %'}`);
} Output
size 100 % 200 % 250 % 500 % doubles at step -1 14.4 27.4 33.9 66.7 250 % step 0 18.0 33.3 41.0 80.0 250 % step 1 22.5 40.6 49.6 96.0 250 % step 2 28.1 49.5 60.1 115.2 250 % step 3 35.2 60.3 72.9 138.2 250 % step 4 43.9 73.5 88.3 165.9 250 % steep hero 72.0 80.0 84.0 120.0 never, even at 500 % gentle hero 72.0 96.0 108.0 180.0 400 %
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node zoom_check.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The measured pages agreed: laid out at 720, 576 and 288 px wide, the widths of a 1440 px window at 200 %, 250 % and 500 %, the three engines computed the sizes the program used, within 0.03 px. Every step of the scale reached twice its size at 250 %, so readers can enlarge all text to 200 %, as success criterion 1.4.4 asks. The steep hero, 24 px on phones and 72 px on wide screens, never did: at 500 % the layout is 288 px wide, the heading sits at its 24 px minimum, and 5 × 24 = 120 px is short of 2 × 72 = 144 px.
The rule follows from that arithmetic. At the highest zoom the layout is narrower than the line’s start, so text is at its minimum, drawn as many times larger as the zoom. Chromium’s highest desktop zoom is 500 %, so a size doubles by then only if its maximum is at most 2.5 times its minimum. The gentle hero, 36 to 72 px, is twice its minimum and doubled at 400 %. The ratios inside this scale are below 1.4, which is why every step doubled by 250 %.
The reader’s text-size setting is the other way to enlarge text, and it scales only the rem parts. With the default
raised from 16 to 20 px, Chromium and Firefox drew step 0 at 21.83 px instead of 18 px in a 1440 px window, 1.21 times
larger rather than 1.25, and the steep hero grew only from 72 to 74 px, because nearly all of its size came from vw.
The smaller the vw part, the more text follows both zoom and the reader’s setting.
A spacing scale that matches
Spaces belong to the same system as text: padding inside a card, the gap between paragraphs, the margin around a
section. The program above makes each space a multiple of the text size at each end: the s space is the text size
itself, 16 px at 360 px and 18 px at 1440 px, m is 1.5 times, xl 3 times, from 3xs (a quarter) to 2xl (four
times). Eight steps cover a whole interface.
Some spacing should change more than text does. A section’s padding can be the s space on a phone and the xl space
on a wide screen, so the token --space-s-xl runs from 16 px to 54 px. In using-tokens.css the section’s padding
uses it and the stack’s gaps use --space-m; measured in all three engines, the padding was 16, 30.35, 35 and 54 px
at 320, 768, 900 and 1440 px, while the gap moved only from 24 to 27 px. Let layouts own their spacing through gap,
as the stack does, so a component dropped into a stack needs no margins of its own.
Inside components, use cqi
vw follows the window, which is right for the page’s own text and wrong for a component that can sit in a sidebar or
in a wide column. Inside a component, put cqi in the middle of the clamp(): the container queries lesson sizes a
card title with clamp(1.125rem, 0.75rem + 3cqi, 2rem), which measured 20.4 px in a 280 px slot and 31.2 px in a
640 px one on the same page. With no container around it, cqi falls back to the small viewport width, so the same
token still works outside a container.
Checking a scale
- Three widths. Measure each step at 320, 768 and 1440 px and compare with the numbers the scale was built from; anything more than half a pixel off is a typo in a token.
- The middle. At the halfway width, each size is the average of its ends; a different value means the slope or the intercept is wrong.
- Zoom. Find the first zoom level that doubles each step in a 1440 px window, as the program does; a step that never doubles needs a larger minimum or a smaller maximum.
- The reader’s setting. Raise the browser’s default text size to 20 px and check that every step grows.
- The longest words. At 320 px, the main heading must still fit its longest word without overflowing.
Practise: a fluid scale that passes zoom
Exercise · Medium · JavaScript
Write fluid clamp() values and check them against zoom
Write three functions in fluid.mjs and export them. Sizes and widths are in px, and 1rem is 16 px.
- fluidClamp(minPx, maxPx, minWidth, maxWidth) returns the CSS for a size that is minPx up to a layout width of minWidth, maxPx from maxWidth, and a straight line in between, as clamp(MINrem, INTERCEPTrem + SLOPEvw, MAXrem). Round every number to four decimals and drop trailing zeros, so 16 to 18 px between 360 and 1440 px is clamp(1rem, 0.9583rem + 0.1852vw, 1.125rem). - spaceScale(narrowText, wideText, steps, minWidth, maxWidth) takes steps as an object such as { s: 1, m: 1.5 } and returns an object with one custom property per step, { '--space-s': '…', '--space-m': '…' }: each step's size is its multiple of narrowText at minWidth and of wideText at maxWidth. - doublesAt(minPx, maxPx, minWidth, maxWidth, windowWidth) returns the first zoom level, as a percentage, at which a window windowWidth px wide draws the text at least twice as large as at 100 %, or null when none does. Try 110, 125, 150, 175, 200, 250, 300, 400 and 500 %. At zoom z the layout is windowWidth / z px wide, and every px is drawn z times larger.
The sample tests import the three functions from fluid.mjs and run in your browser.
Starter code · fluid.mjs
/** clamp(MINrem, INTERCEPTrem + SLOPEvw, MAXrem) for a size from minPx at minWidth to maxPx at maxWidth. */
export function fluidClamp(minPx, maxPx, minWidth, maxWidth) {
// Replace this line with your code.
return '';
}
/** One --space-<name> custom property per step: a multiple of the text size at each end. */
export function spaceScale(narrowText, wideText, steps, minWidth, maxWidth) {
// Replace this line with your code.
return {};
}
/** The first zoom level, in %, that draws the text at least twice as large, or null. */
export function doublesAt(minPx, maxPx, minWidth, maxWidth, windowWidth) {
// Replace this line with your code.
return null;
} The sample tests · fluid.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { fluidClamp, spaceScale, doublesAt } from './fluid.mjs';
/** The size, in px, that a clamp(Arem, Brem + Cvw, Drem) string gives at a layout `width` px wide. */
function sizeAt(css, width) {
const m = /^clamp\((-?[\d.]+)rem, (-?[\d.]+)rem \+ (-?[\d.]+)vw, (-?[\d.]+)rem\)$/.exec(css);
assert.ok(m, `not in the form clamp(Arem, Brem + Cvw, Drem): ${css}`);
const [min, intercept, slope, max] = m.slice(1).map(Number);
return Math.min(Math.max(min * 16, intercept * 16 + (slope * width) / 100), max * 16);
}
const near = (actual, expected) => assert.ok(Math.abs(actual - expected) <= 0.5, `expected about ${expected} px, got ${actual}`);
test('body text from 16 to 18 px between 360 and 1440 px', () => {
assert.equal(fluidClamp(16, 18, 360, 1440), 'clamp(1rem, 0.9583rem + 0.1852vw, 1.125rem)');
});
test('a heading line hits its sizes at both ends and in the middle', () => {
const css = fluidClamp(28, 56, 375, 1440);
near(sizeAt(css, 375), 28);
near(sizeAt(css, 1440), 56);
near(sizeAt(css, 907.5), 42);
near(sizeAt(css, 320), 28);
near(sizeAt(css, 1920), 56);
});
test('a space scale has one custom property per step', () => {
const scale = spaceScale(16, 18, { s: 1, m: 1.5, xl: 3 }, 360, 1440);
assert.deepEqual(Object.keys(scale), ['--space-s', '--space-m', '--space-xl']);
assert.equal(scale['--space-m'], 'clamp(1.5rem, 1.4375rem + 0.2778vw, 1.6875rem)');
near(sizeAt(scale['--space-xl'], 360), 48);
near(sizeAt(scale['--space-xl'], 1440), 54);
});
test('a gentle scale doubles at 250 % in a 1440 px window', () => {
assert.equal(doublesAt(16, 18, 360, 1440, 1440), 250);
assert.equal(doublesAt(33.18, 43.95, 360, 1440, 1440), 250);
});
test('a size that triples never doubles, one that doubles needs 400 %', () => {
assert.equal(doublesAt(24, 72, 360, 1440, 1440), null);
assert.equal(doublesAt(36, 72, 360, 1440, 1440), 400);
});
test('a size that does not change doubles at 200 %', () => {
assert.equal(doublesAt(16, 16, 360, 1440, 1280), 200);
}); A hint
The slope is (maxPx - minPx) / (maxWidth - minWidth), in px of size per px of width; as vw it is 100 times that. The intercept is minPx - slope * minWidth, in px; divide by 16 for rem. Number(x.toFixed(4)) rounds and drops the trailing zeros. For the zoom, compute the size along the line, clamped, at windowWidth / z, and multiply it by z.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · Why should a fluid font size keep rem in its middle value instead of using vw alone?
Viewport units follow the layout’s width in CSS px, and zooming makes that width smaller by exactly the zoom factor,
so text sized only in vw is drawn the same size at 100 % and at 400 %. WCAG’s failure F94 describes it: such text
can’t be enlarged to 200 % by zooming, and the browser’s text-size setting doesn’t change it either. A rem part, as in
clamp(1rem, 0.9583rem + 0.1852vw, 1.125rem), doesn’t shrink with the layout and follows the reader’s text size, so
the text still grows: that size reached twice its 100 % size at 250 % zoom in a 1440 px window. Keeping the vw part
small and the maximum within 2.5 times the minimum keeps the text able to double at the zoom levels browsers offer.
Key takeaways
- A fluid size is a line between two (width, size) points: slope in
vw, intercept inrem, kept between the ends byclamp(). - Build a scale from two modular scales, a small ratio for narrow screens and a larger one for wide screens, and keep it to about six steps of type and eight of space, as custom properties.
- Check each step at 320, 768 and 1440 px, and at the halfway width, where it must be the average of its ends.
- Zoom narrows the layout while drawing it larger: every step of the lesson’s scale doubled at 250 %, while a hero from 24 to 72 px never did.
- Keep each maximum within 2.5 times its minimum so the text doubles by 500 % zoom; prefer a small
vwpart. - Inside components use
cqi, and let layouts own their spacing withgap.
References
- CSS Values and Units Module Level 4: comparison functions min(), max() and clamp() (W3C CSS Working Group)
- CSS Values and Units Module Level 4: viewport-percentage lengths (W3C CSS Working Group)
- Web Content Accessibility Guidelines (WCAG) 2.2: Resize Text (W3C)
- Understanding WCAG 2.2: Resize Text (W3C)
- F94: Failure of Success Criterion 1.4.4 due to incorrect use of viewport units to resize text (W3C)
- CSS Conditional Rules Module Level 5: container relative lengths (W3C CSS Working Group)
- Chromium source: preset browser zoom factors (page_zoom.cc) (The Chromium Authors)
- clamp() (MDN Web Docs) (Mozilla)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress