Lesson 07 / 23
The Principle of Responsive Design
The three components of responsiveness, deriving breakpoints from content by computation, thresholds that resolve on their own through intrinsic sizing, and how a track definition shifts a threshold.
Contents
The multi-column layout lesson made a decision silently: when a column’s width no longer fit its container, the column count was lowered. The threshold did not come from a list of screen widths, it came from the narrowest measure at which the text stayed readable.
This lesson carries the same reasoning to the whole page. At what width a layout needs to change is a computable question; the answer lies not in device names but in the content’s own measurements.
The Three Components of Responsiveness
Responsive design is not the name of a single declaration; it is three behaviors present together.
A fluid grid. The layout’s dimensions are given not with fixed lengths but with ratios and
remaining space. fr, percentages, minmax(), and auto do this job; fixed pixel widths are
kept only where something genuinely needs to be fixed.
Flexible media. Images and embedded content never exceed their container; their size comes not from their own natural dimensions but from what the container leaves.
Conditional style. When the two above are not enough on their own — when the layout’s structure has to change — style is bound to a condition.
The order matters. The more work the first two components do, the less is left for the third. Every condition written with a query is a branch that has to be maintained in the stylesheet; every threshold resolved through intrinsic sizing never appears in the file at all.
A Breakpoint Is a Measurement
A breakpoint is the width at which a layout’s structure changes. The question asked correctly is this: how narrow can this component get before it loses its meaning? The answer is a number, and it comes from the component’s content.
The program below computes this number for three components of the measurement station page.
// breakpoints.mjs — deriving breakpoints from content // The page's horizontal padding is 24px on each side. const PADDING = 24 * 2; // 1) Text column: body text 18px, average character width 0.5em -> 9px/character const CHAR_WIDTH = 9; const MEASURE_MIN = 45; // fewest readable characters const MEASURE_MAX = 75; // most readable characters const textMin = MEASURE_MIN * CHAR_WIDTH; const textMax = MEASURE_MAX * CHAR_WIDTH; // 2) Measurement cards: the narrowest size a card stays meaningful at, and the gap const CARD = 200; const CARD_GAP = 16; const cardThreshold = (n) => n * (CARD + CARD_GAP) - CARD_GAP; // 3) Page layout: the narrowest sizes the main section and aside need to sit side by side const MAIN_MIN = 480; const ASIDE_MIN = 260; const PAGE_GAP = 24; const twoColumnThreshold = MAIN_MIN + ASIDE_MIN + PAGE_GAP; console.log("--- 1) text column ---"); console.log(`body text 18px, 1 character ~ ${CHAR_WIDTH}px`); console.log(`measure ${MEASURE_MIN}-${MEASURE_MAX} characters -> column ${textMin}-${textMax}px`); console.log(`viewport counterpart: ${textMin + PADDING}-${textMax + PADDING}px`); console.log(`line grows past upper bound: threshold ${textMax + PADDING}px`); console.log("\n--- 2) measurement cards ---"); console.log(`card narrowest ${CARD}px, gap ${CARD_GAP}px`); console.log("columns content area needed viewport threshold"); for (let n = 1; n <= 5; n++) { console.log( `${String(n).padStart(7)} ${String(cardThreshold(n)).padStart(19)} ${String(cardThreshold(n) + PADDING).padStart(18)}`, ); } console.log("\n--- 3) page layout ---"); console.log(`main section narrowest ${MAIN_MIN}px + aside narrowest ${ASIDE_MIN}px + gap ${PAGE_GAP}px`); console.log(`narrowest content area where two columns fit: ${twoColumnThreshold}px`); console.log(`viewport threshold (lower bound): ${twoColumnThreshold + PADDING}px`); console.log("\n--- every derived threshold ---"); const thresholds = [ { name: "card 2 columns", px: cardThreshold(2) + PADDING, query: false }, { name: "card 3 columns", px: cardThreshold(3) + PADDING, query: false }, { name: "text upper bound", px: textMax + PADDING, query: false }, { name: "page two columns*", px: twoColumnThreshold + PADDING, query: true }, { name: "card 4 columns", px: cardThreshold(4) + PADDING, query: false }, { name: "card 5 columns", px: cardThreshold(5) + PADDING, query: false }, ]; thresholds.sort((a, b) => a.px - b.px); console.log("threshold(px) component query needed"); for (const t of thresholds) { console.log( `${String(t.px).padStart(13)} ${t.name.padEnd(20)} ${t.query ? "yes" : "no (intrinsic sizing is enough)"}`, ); } const toWrite = thresholds.filter((t) => t.query); console.log(`\ntotal thresholds: ${thresholds.length}, queries to write: ${toWrite.length} (${toWrite.map((t) => t.px + "px").join(", ")})`); console.log("* lower bound from the sum of the narrowest sizes; rises with track definition");
--- 1) text column ---
body text 18px, 1 character ~ 9px
measure 45-75 characters -> column 405-675px
viewport counterpart: 453-723px
line grows past upper bound: threshold 723px
--- 2) measurement cards ---
card narrowest 200px, gap 16px
columns content area needed viewport threshold
1 200 248
2 416 464
3 632 680
4 848 896
5 1064 1112
--- 3) page layout ---
main section narrowest 480px + aside narrowest 260px + gap 24px
narrowest content area where two columns fit: 764px
viewport threshold (lower bound): 812px
--- every derived threshold ---
threshold(px) component query needed
464 card 2 columns no (intrinsic sizing is enough)
680 card 3 columns no (intrinsic sizing is enough)
723 text upper bound no (intrinsic sizing is enough)
812 page two columns* yes
896 card 4 columns no (intrinsic sizing is enough)
1112 card 5 columns no (intrinsic sizing is enough)
total thresholds: 6, queries to write: 1 (812px)
* lower bound from the sum of the narrowest sizes; rises with track definition
Three components, three separate sources of measurement.
The text column’s size comes from typography. Below 45 characters per line, the eye jumps lines too often; above 75, it becomes hard to find the start of the next line after a line break. In body text at an eighteen-unit base size, character width is roughly nine units; the two bounds come out to 405 and 675 units.
The cards’ size comes from the content itself: in a measurement card, the label, value, and unit must be readable side by side, and this breaks down below 200 units. How many columns fit follows from there.
The page layout’s size is the sum of the two sections’ sizes. The narrowest space they need to sit side by side is the sum of their two narrowest sizes and the gap between them.
The resulting list holds six thresholds, and none of them is named after a device. The numbers are odd: 464, 680, 723, 812. This oddness is not a flaw, it is the sign that the measure really does come from content.
Thresholds That Are Never Written
Of the six thresholds, only one requires conditional style. The remaining five are met automatically by the intrinsic sizing tools built in earlier lessons.
The card grid is defined with repeat(auto-fill, minmax(200px, 1fr)). Column count is computed
from container width, and the 464, 680, 896, and 1112 thresholds are crossed as a natural
result of that computation. Writing these thresholds with a query would mean doing the same
computation by hand a second time.
The text column’s upper bound is given with max-inline-size; the measure never exceeds 675
units, and the remaining space goes to the margin. This too needs no condition.
What is left is the page layout itself. A grid-template-areas declaration is a structure, not
a measure; moving from a two-column layout to a one-column one requires the declaration to be
replaced by another declaration. Intrinsic sizing can compute a measure, it cannot choose a
structure.
The criterion follows from this: if the measure changes, intrinsic sizing; if the structure changes, conditional style.
Track Definition Shifts the Threshold
The 812 figure above is the sum of the narrowest sizes, and it is a lower bound. The real threshold depends on how the tracks are defined.
// tracks.mjs — where the threshold comes from: deriving it from track definitions const PADDING = 48; // total horizontal padding on both sides of the page const GAP = 24; // gap between the two columns const MAIN_MIN = 480; // the narrowest size the main section stays meaningful at const ASIDE_MIN = 260; // the narrowest size the aside stays meaningful at // Sizing minmax(min, Nfr) tracks: if a track's share falls below its minimum, // the track is frozen at its minimum and the remaining space is redistributed. function tracks(viewport, definition) { let remaining = viewport - PADDING - GAP; const result = definition.map((t) => ({ ...t, size: null })); let changed = true; while (changed) { changed = false; const open = result.filter((t) => t.size === null); const frTotal = open.reduce((s, t) => s + t.fr, 0); for (const t of open) { const share = (remaining * t.fr) / frTotal; if (share < t.min) { t.size = t.min; remaining -= t.min; changed = true; break; } } if (!changed) for (const t of open) t.size = (remaining * t.fr) / frTotal; } return result; } const definitions = [ ["minmax(0, 2fr) minmax(0, 1fr)", [{ name: "main", fr: 2, min: 0 }, { name: "aside", fr: 1, min: 0 }]], ["minmax(480px, 2fr) minmax(260px, 1fr)", [{ name: "main", fr: 2, min: MAIN_MIN }, { name: "aside", fr: 1, min: ASIDE_MIN }]], ]; for (const [name, definition] of definitions) { console.log(`--- ${name} ---`); console.log("viewport main col aside col main ok aside ok total overflow"); for (const W of [768, 812, 852, 900, 1024]) { const s = tracks(W, definition); const main = s[0].size, aside = s[1].size; const overflow = main + aside + GAP - (W - PADDING); console.log( `${String(W).padStart(9)} ${main.toFixed(1).padStart(9)} ${aside.toFixed(1).padStart(9)} ` + `${(main >= MAIN_MIN ? "yes" : "NO").padStart(11)} ` + `${(aside >= ASIDE_MIN ? "yes" : "NO").padStart(11)} ${overflow.toFixed(1).padStart(14)}`, ); } // the first width at which both tracks meet their narrowest size without overflow let threshold = 300; while (threshold < 2000) { const s = tracks(threshold, definition); const overflow = s[0].size + s[1].size + GAP - (threshold - PADDING); if (s[0].size >= MAIN_MIN && s[1].size >= ASIDE_MIN && overflow <= 0.001) break; threshold += 1; } console.log(`derived breakpoint: ${threshold}px\n`); } console.log("--- comparison with a threshold taken from device names ---"); const PRESET = 768; for (const [name, definition] of definitions) { const s = tracks(PRESET, definition); const overflow = s[0].size + s[1].size + GAP - (PRESET - PADDING); const narrow = s[0].size < MAIN_MIN || s[1].size < ASIDE_MIN; const diagnosis = narrow ? "tracks are below their narrowest size" : `tracks are adequate but ${overflow.toFixed(0)}px overflow`; console.log(`${PRESET}px, ${name}`); console.log(` main ${s[0].size.toFixed(1)} aside ${s[1].size.toFixed(1)} -> ${diagnosis}`); }
--- minmax(0, 2fr) minmax(0, 1fr) ---
viewport main col aside col main ok aside ok total overflow
768 464.0 232.0 NO NO 0.0
812 493.3 246.7 yes NO 0.0
852 520.0 260.0 yes yes 0.0
900 552.0 276.0 yes yes 0.0
1024 634.7 317.3 yes yes 0.0
derived breakpoint: 852px
--- minmax(480px, 2fr) minmax(260px, 1fr) ---
viewport main col aside col main ok aside ok total overflow
768 480.0 260.0 yes yes 44.0
812 480.0 260.0 yes yes 0.0
852 520.0 260.0 yes yes 0.0
900 552.0 276.0 yes yes 0.0
1024 634.7 317.3 yes yes 0.0
derived breakpoint: 812px
--- comparison with a threshold taken from device names ---
768px, minmax(0, 2fr) minmax(0, 1fr)
main 464.0 aside 232.0 -> tracks are below their narrowest size
768px, minmax(480px, 2fr) minmax(260px, 1fr)
main 480.0 aside 260.0 -> tracks are adequate but 44px overflow
Two definitions, two different thresholds.
In the first definition, the ratio is locked: the aside always takes a third of what remains, at every width. For the aside to reach 260 units, a third has to equal 260; the threshold rises to 852 units. The 812 that is the sum of the lower bounds is not enough here, because the main section is taking more than it needs.
In the second definition, the lower bounds are written into the tracks. The tracks settle into their narrowest sizes first, and the growing space is split by ratio afterward. The threshold comes down to the sum of the lower bounds, 812 units. This definition gains the page 40 units of width and puts the breakpoint at the same place as the computation’s first result.
The last block shows two separate ways an externally chosen threshold breaks. At the 768-unit threshold taken from device names, in the first definition the tracks stay below their narrowest size: the layout applies but the content gets squeezed. In the second definition the tracks keep their lower bounds, but this time their sum exceeds the container by 44 units: horizontal overflow results.
Both results come from the same cause. When a threshold is not derived from content, the bond between the measures and the threshold breaks, and the fault shows up either as squeezing or as overflow.
Starting From the Narrow Side
Once the breakpoint is found, one writing decision remains: which layout should the base style describe?
If the base style describes the narrow layout, conditions are added as width grows. The single-column arrangement is already written; the condition only builds the second column.
If the base style describes the wide layout, conditions are added as width shrinks, and each condition carries declarations that undo what the base built: zeroing area names, bringing the track count down to one, shrinking margins.
Starting from the narrow side eliminates the undoing declarations. There is a second reason too: content has to be prioritized in a narrow space. Which section comes first, which information can be carried at all, is decided at the narrow layout; the wide layout is built on top of these decisions. Working in the reverse direction leaves the narrow layout as whatever is left over from the wide one.
/* station.css — step 7: narrow base, one threshold */ .station-layout { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-areas: "masthead" "measurements" "aside" "location"; gap: var(--spacing-1); } .measurement-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--spacing-0); } .station-notes { max-inline-size: 675px; }
There is not a single query in the base style. The card grid gains columns as width grows, the text column stops at its upper bound, the page is single-column. Only at the 812-unit threshold does the structure change; that declaration will be written in the next lesson.
The Viewport Declaration
One precondition is required for all this computation to hold. On small-screen devices, the browser can assume a wide virtual viewport, to make pages not written for a narrow screen readable, and shrink the result to fit. In this case, the width CSS sees is not the screen’s real width, and the derived thresholds never trigger at all.
The viewport meta tag introduced in the Web Fundamentals and HTML course turns this assumption off:
<meta name="viewport" content="width=device-width, initial-scale=1">
The declaration sets the width CSS sees equal to the device’s own width. Responsive style’s first line is not in the stylesheet, it is in the document’s head.
Summary
- Responsiveness is three components present together: fluid measures, media that never exceeds its container, and conditional style where needed.
- A breakpoint is not a device width; it is a number derived from the narrowest size a component can shrink to before losing its meaning. Derived numbers are not round.
- If the measure changes, intrinsic sizing is enough; if the structure changes, conditional style is needed. On the station page, only one of six thresholds turns into a query.
- Track definition shifts the threshold: 852 units with a locked-ratio definition, 812 units with a definition whose lower bounds are written in.
- When the threshold is chosen externally, the fault appears in two forms — tracks fall below their narrowest size, or their sum exceeds the container and produces overflow.
- When the base style describes the narrow layout, conditions add; starting from the wide layout means every condition carries undoing declarations.
Next Step
The derived threshold is in hand: 812 units. What remains is how this number gets written in the stylesheet. A condition is not built with width alone; style can also branch on height, the screen’s orientation, or whether the output is going to paper or to a screen. The next lesson takes up the writing of conditional style: which features a condition can look at, how two conditions are combined, and which rule wins at the width sitting at the edge of two ranges.
To keep your progress and take notes, Log in
My notes
Log in to take notes.