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

# 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.

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.

```js
// 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, image@2x.jpg 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.

```js
// 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.

```html
<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>
```

```css
/* 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?
