Skip to content
academia.sh

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 sizes declaration 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 picture element, 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 width and height; writing a single ratio produces layout shift when the source changes.
  • object-fit and object-position provide 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.

Start typing to search.

↑↓ Esc navigate · open · close