Lesson 11 / 23
Responsive Images
Separating resolution switching from art direction, resolving a source size declaration, the candidate selection rule, the effect of source order, and reserved space preventing layout shift.
Contents
Text now changes with scale, layout adapts to its container. What remains is the page’s heaviest element: the image in the location section.
An image carries two separate problems at the same time, and mixing them up solves neither. The first is a size problem: the same shot gets downloaded at an unnecessarily large file size on a narrow screen, or looks blurry on a dense screen. The second is a content problem: a wide landscape shot loses its detail when shrunk on a narrow screen; what is needed is not shrinking but cropping.
This lesson separates the two problems and shows the tool for each.
Resolution Switching
The first problem is called resolution switching: copies of the same image are prepared at different pixel sizes, and the browser decides which one to download.
The decision rests on two pieces of information. The srcset attribute gives a candidate
set; each candidate is written together with the file’s real pixel width. The sizes
attribute announces the space the image will occupy on the page — this is called the
source size.
Why is the second one needed? The browser starts downloading the image before it resolves the
stylesheet; it cannot know at that moment how much space the image will take on the page.
sizes writes this information into the document, independent of the stylesheet.
// srcset.mjs — resolving source size and candidate selection // sizes: tried in order, the first matching condition's length is the source size. const SIZES = [ ["(min-width: 812px)", "33vw"], ["(min-width: 500px)", "50vw"], [null, "calc(100vw - 48px)"], ]; // srcset: each candidate is declared with its real pixel width (the w descriptor) const CANDIDATES = [320, 480, 640, 960, 1280, 1920]; function conditionTrue(condition, viewport) { if (condition === null) return true; const m = condition.match(/^\((min|max)-width:\s*(\d+)px\)$/); return m[1] === "min" ? viewport >= Number(m[2]) : viewport <= Number(m[2]); } function length(expr, viewport) { let m = expr.match(/^(\d+(?:\.\d+)?)vw$/); if (m) return (Number(m[1]) * viewport) / 100; m = expr.match(/^calc\((\d+(?:\.\d+)?)vw\s*-\s*(\d+(?:\.\d+)?)px\)$/); if (m) return (Number(m[1]) * viewport) / 100 - Number(m[2]); m = expr.match(/^(\d+(?:\.\d+)?)px$/); if (m) return Number(m[1]); throw new Error(`unresolved length: ${expr}`); } function sourceSize(viewport) { for (const [condition, expr] of SIZES) { if (conditionTrue(condition, viewport)) return { expr, px: length(expr, viewport) }; } throw new Error("sizes list is empty"); } // Selection rule: the smallest candidate that meets the needed pixel width. // If none does, the largest candidate is chosen. function pick(needed) { return CANDIDATES.find((c) => c >= needed) ?? CANDIDATES[CANDIDATES.length - 1]; } console.log("sizes: " + SIZES.map(([k, i]) => (k ? `${k} ${i}` : i)).join(", ")); console.log("srcset candidates: " + CANDIDATES.map((c) => `${c}w`).join(", ")); console.log("\nviewport source size expr source pixel needed picked extra"); console.log(" size ratio candidate pixels"); for (const V of [360, 500, 700, 812, 1280, 1600]) { for (const dpr of [1, 2, 3]) { const { expr, px } = sourceSize(V); const needed = px * dpr; const picked = pick(needed); const extra = picked - needed; console.log( `${String(V).padStart(6)} ${expr.padEnd(23)} ${px.toFixed(0).padStart(6)} ` + `${String(dpr + "x").padStart(6)} ${needed.toFixed(0).padStart(7)} ` + `${String(picked + "w").padStart(9)} ${extra.toFixed(0).padStart(6)}`, ); } } // pixel count of the candidate widths (assuming a 3:2 aspect ratio) console.log("\n--- pixel count of the candidate files (3:2 aspect ratio) ---"); console.log("candidate width x height pixels how many times 320w"); for (const c of CANDIDATES) { const h = Math.round((c * 2) / 3); const pixels = c * h; const base = 320 * Math.round((320 * 2) / 3); console.log( `${String(c + "w").padStart(9)} ${String(c + " x " + h).padStart(15)} ` + `${String(pixels).padStart(9)} ${(pixels / base).toFixed(1).padStart(19)}x`, ); } // if sizes is declared wrong: image occupies 33vw in reality but 100vw is declared console.log("\n--- if sizes is declared wrong (really 33vw, declared 100vw) ---"); console.log("viewport pixel correct sizes wrong sizes ratio of picked pixels"); console.log(" ratio picks picks"); for (const V of [812, 1280, 1600]) { for (const dpr of [1, 2]) { const correct = pick(sourceSize(V).px * dpr); const wrong = pick(V * dpr); const h1 = Math.round((correct * 2) / 3), h2 = Math.round((wrong * 2) / 3); const ratio = (wrong * h2) / (correct * h1); console.log( `${String(V).padStart(6)} ${String(dpr + "x").padStart(5)} ` + `${String(correct + "w").padStart(13)} ${String(wrong + "w").padStart(11)} ${ratio.toFixed(1).padStart(19)}x`, ); } }
sizes: (min-width: 812px) 33vw, (min-width: 500px) 50vw, calc(100vw - 48px)
srcset candidates: 320w, 480w, 640w, 960w, 1280w, 1920w
viewport source size expr source pixel needed picked extra
size ratio candidate pixels
360 calc(100vw - 48px) 312 1x 312 320w 8
360 calc(100vw - 48px) 312 2x 624 640w 16
360 calc(100vw - 48px) 312 3x 936 960w 24
500 50vw 250 1x 250 320w 70
500 50vw 250 2x 500 640w 140
500 50vw 250 3x 750 960w 210
700 50vw 350 1x 350 480w 130
700 50vw 350 2x 700 960w 260
700 50vw 350 3x 1050 1280w 230
812 33vw 268 1x 268 320w 52
812 33vw 268 2x 536 640w 104
812 33vw 268 3x 804 960w 156
1280 33vw 422 1x 422 480w 58
1280 33vw 422 2x 845 960w 115
1280 33vw 422 3x 1267 1280w 13
1600 33vw 528 1x 528 640w 112
1600 33vw 528 2x 1056 1280w 224
1600 33vw 528 3x 1584 1920w 336
--- pixel count of the candidate files (3:2 aspect ratio) ---
candidate width x height pixels how many times 320w
320w 320 x 213 68160 1.0x
480w 480 x 320 153600 2.3x
640w 640 x 427 273280 4.0x
960w 960 x 640 614400 9.0x
1280w 1280 x 853 1091840 16.0x
1920w 1920 x 1280 2457600 36.1x
--- if sizes is declared wrong (really 33vw, declared 100vw) ---
viewport pixel correct sizes wrong sizes ratio of picked pixels
ratio picks picks
812 1x 320w 960w 9.0x
812 2x 640w 1920w 9.0x
1280 1x 480w 1280w 7.1x
1280 2x 960w 1920w 4.0x
1600 1x 640w 1920w 9.0x
1600 2x 1280w 1920w 2.3x
The first table shows the two steps separately. First the sizes list is tried left to right,
and the first length whose condition is true becomes the source size — 33 viewport units,
that is 268 pixels, at an 812-unit viewport. Then this value is multiplied by the device’s
pixel ratio: at a double-density screen, 536 real pixels are needed. From the candidate
set, the smallest file that meets this number is picked.
The numbers make one point immediately visible: at the same page, the same viewport width, a 320-, 640-, or 960-pixel file gets downloaded depending on pixel ratio. A large file is not picked just because the viewport is wide; it is the product of source size and pixel ratio that picks.
The second table gives the magnitude of the difference between candidates. Pixel count grows with the square of width: the 1920-pixel file carries 36 times the pixels of the 320-pixel file. This is why one wrong step in the candidate set is a cost that grows by a multiple.
When Source Size Is Declared Wrong
The third table gives the size of the most commonly made mistake. If the image really occupies
a third of the viewport but the sizes declaration says it occupies the whole thing, the
browser picks an unnecessarily large candidate. The difference ranges between 2.3 and 9 times.
The mistake is easy to hide, because the image looks correct — just with an unnecessarily large file. The way to check is this: the source size declaration is a copy of the width rules in the stylesheet, and must be updated together whenever the layout changes. The two sitting in separate places is this notation’s structural weakness.
Two more notes. A candidate set can also be written with a pixel-ratio descriptor instead of
width (image.jpg 1x, [email protected] 2x); in this notation source size is not taken into
account, and only the device’s pixel ratio picks. It is enough for small, fixed-size images —
an icon, a badge.
Second, the selection rule here is a model. Candidate selection is left to the browser’s discretion; a smaller candidate may be picked based on network conditions or a user setting. The declaration’s job is to give correct information, not to force the choice.
Art Direction
The second problem is not solved by size. However much a wide panorama shot is shrunk on a narrow screen, the point where the measurement station sits becomes indistinguishable. What is needed is a different crop of the same shot.
This distinction is called art direction, and its tool is the picture element. The
element carries conditional source elements and one img element.
// art-direction.mjs — source selection in a picture element, and computing reserved space // Rule: sources are tried in document order, the FIRST source whose condition is true is picked. const SOURCES = [ { name: "location-wide", condition: "(min-width: 812px)", w: 1440, h: 600 }, // 12:5 panorama { name: "location-medium", condition: "(min-width: 500px)", w: 960, h: 640 }, // 3:2 { name: "location-narrow", condition: null, w: 480, h: 600 }, // 4:5 crop ]; const SIZES = [ ["(min-width: 812px)", 0.33], ["(min-width: 500px)", 0.5], [null, null], // calc(100vw - 48px) ]; function conditionTrue(condition, V) { if (condition === null) return true; const m = condition.match(/^\(min-width:\s*(\d+)px\)$/); return V >= Number(m[1]); } function displayedWidth(V) { for (const [condition, ratio] of SIZES) { if (conditionTrue(condition, V)) return ratio === null ? V - 48 : V * ratio; } } function pick(list, V) { return list.find((s) => conditionTrue(s.condition, V)); } console.log("sources in document order:"); for (const s of SOURCES) { console.log(` ${s.name.padEnd(16)} ${String(s.condition ?? "(unconditional img)").padEnd(20)} ${s.w}x${s.h} aspect ratio ${(s.w / s.h).toFixed(2)}`); } console.log("\nviewport displayed picked aspect reserved"); console.log(" width source ratio height"); for (const V of [360, 500, 700, 812, 1280, 1600]) { const width = displayedWidth(V); const s = pick(SOURCES, V); const ratio = s.w / s.h; console.log( `${String(V).padStart(6)} ${width.toFixed(0).padStart(9)} ${s.name.padEnd(16)} ` + `${ratio.toFixed(2).padStart(6)} ${(width / ratio).toFixed(0).padStart(8)}`, ); } // if the order breaks: conditional sources ordered narrow to wide const REVERSED = [SOURCES[1], SOURCES[0], SOURCES[2]]; console.log("\n--- if conditional sources are ordered narrow to wide ---"); console.log("viewport picked source correct pick same"); for (const V of [360, 500, 812, 1280]) { const r = pick(REVERSED, V), c = pick(SOURCES, V); console.log( `${String(V).padStart(6)} ${r.name.padEnd(17)} ${c.name.padEnd(15)} ${(r.name === c.name ? "yes" : "NO").padStart(6)}`, ); } // reserved space varies from source to source: if each source does not declare its own dimensions console.log("\n--- heights of sources at the same displayed width (500px) ---"); for (const s of SOURCES) { console.log(` ${s.name.padEnd(16)} ${(500 / (s.w / s.h)).toFixed(0).padStart(4)}px`); }
sources in document order:
location-wide (min-width: 812px) 1440x600 aspect ratio 2.40
location-medium (min-width: 500px) 960x640 aspect ratio 1.50
location-narrow (unconditional img) 480x600 aspect ratio 0.80
viewport displayed picked aspect reserved
width source ratio height
360 312 location-narrow 0.80 390
500 250 location-medium 1.50 167
700 350 location-medium 1.50 233
812 268 location-wide 2.40 112
1280 422 location-wide 2.40 176
1600 528 location-wide 2.40 220
--- if conditional sources are ordered narrow to wide ---
viewport picked source correct pick same
360 location-narrow location-narrow yes
500 location-medium location-medium yes
812 location-medium location-wide NO
1280 location-medium location-wide NO
--- heights of sources at the same displayed width (500px) ---
location-wide 208px
location-medium 333px
location-narrow 625px
Three sources, three different aspect ratios. The narrow layout uses a vertical crop: it takes up more vertical space at the same width, and the point where the station sits stays at scale. The wide layout’s panorama crop also shows the shape of the surrounding terrain.
The second table measures a rule that departs from media queries. In the stylesheet, the
last matching rule won; inside picture, the first source whose condition is true is
picked. When sources are ordered narrow to wide, the wide conditions never get their turn: at
812 and 1280 units, the medium crop is picked and the panorama is never used. Sources are
ordered from the wide condition to the narrow one.
The third block shows how differently the crops occupy space. At the same 500-unit width, the panorama needs 208 units of height, the vertical crop 625. This difference directly produces the reserved-space problem.
Reserved Space
Before an image downloads, the browser cannot know how much space to give it. If it does not know, it reserves no space; when the image arrives, the content beneath it gets pushed down. This is the layout shift introduced in the Web Fundamentals and HTML course.
The fix is to write the aspect ratio into the document. When an img element is given width
and height attributes, the browser computes the ratio from their quotient and reserves the
box the image will occupy in advance. The attributes are pixel values but do not mean a fixed
size; the inline-size declaration in the stylesheet determines the image’s real width, the
attributes only carry the ratio.
Art direction needs one more thing. Since each crop has a different ratio, the space to be
reserved differs too; this is why every source element declares its own width and
height. If a single ratio is written, the space gets recomputed the moment the source
changes and a shift results.
The same result can also be built on the style side with an aspect-ratio declaration; when
the two are used together, the style wins.
<figure class="location-image"> <picture> <source media="(min-width: 812px)" srcset="location-wide-960.jpg 960w, location-wide-1440.jpg 1440w" sizes="33vw" width="1440" height="600"> <source media="(min-width: 500px)" srcset="location-medium-640.jpg 640w, location-medium-960.jpg 960w" sizes="50vw" width="960" height="640"> <img src="location-narrow-480.jpg" srcset="location-narrow-320.jpg 320w, location-narrow-480.jpg 480w" sizes="calc(100vw - 48px)" width="480" height="600" loading="lazy" decoding="async" alt="Southwest view of the ridge where the North Slope measurement station sits"> </picture> <figcaption>The measurement station sits on the ridge's southwest slope, at 1840 meters.</figcaption> </figure>
/* station.css — step 11: the image's box */ .location-image img { inline-size: 100%; block-size: auto; object-fit: cover; object-position: 50% 35%; background-color: var(--surface); } .location-image figcaption { margin-block-start: 0.5rem; color: var(--text-muted); font-size: 0.875em; }
There are four declarations on the document side, and each answers a separate question:
media says which crop to use, srcset says what sizes that crop is available in, sizes
says how much space it will take on the page, width and height say what ratio of space to
reserve. The alternative text is independent of the crop and is written once on the img
element — all three sources show the same scene.
On the style side, inline-size: 100% fits the image to its container, block-size: auto
preserves the ratio. The object-fit: cover declaration crops the image instead of stretching
it when the box’s ratio differs from the image’s own; object-position says which point the
crop centers on. The 35 percent value keeps the horizon line in the upper third.
The counterpart for background images is the image-set() function; it builds the same
candidate logic in the stylesheet. Background images, however, are decorative images with no
counterpart in the document; a shot carrying content is not placed as a background.
Summary
- An image carries two separate problems: choosing the size of the same shot (resolution switching) and showing a different crop in the narrow layout (art direction).
- Candidate selection is determined by two factors: the source size resolved from the
sizesdeclaration and the device’s pixel ratio; viewport width alone does not pick. - Candidate files’ pixel count grows with the square of width; when source size is declared wrong, the picked file carries 2 to 9 times too many pixels.
- In a
pictureelement, the first source whose condition is true is picked; this is the reverse of the stylesheet’s “last one wins” rule, and sources are ordered from the wide condition to the narrow one. - Since crops have different aspect ratios, each source declares its own
widthandheight; writing a single ratio produces layout shift when the source changes. object-fitandobject-positionprovide cropping instead of stretching, and choice of crop center, when the box’s ratio differs from the image’s own.
Next Step
Up to this point, style has always adapted to the medium’s measurable properties: width, a container’s size, pixel ratio. The user’s own preferences can be read the same way — whether they want a dark or light color scheme, whether they want increased contrast, whether they want motion reduced. The next lesson takes up these preferences, and the last of them will leave a question: what exactly does the reduced-motion preference reduce?
To keep your progress and take notes, Log in
My notes
Log in to take notes.