Container Queries vs Media Queries for Card Images

The same card component lands in a 296 px sidebar rail and a 912 px main column on one 1280 px page. @media (min-width: 1024px) is true for both instances; @container media-card (min-width: 640px) is true only for the second. The decision you actually have to make is therefore not “which query type is better” but “which half of the card does each one own” — because only media-query-shaped conditions can influence the HTTP request, and only container queries can see the box the image is painted into. This page is the classification procedure and the resulting markup, sitting under CSS Container Queries for Dynamic Media Sizing in the Responsive Image & Video Delivery section.

Prerequisite checklist

The rule that resolves every case

Run each proposed change through three questions in order and stop at the first “yes”. The order matters: the first question is about a decision made by the preload scanner before any box exists, the second about a decision made during layout, and the third about a decision that has nothing to do with either.

Classifying a card image change Four stacked decision rows. The first asks whether the change alters which file is downloaded and routes yes to srcset, sizes and source media. The second asks whether it depends on the card's own inline size and routes yes to a named container query. The third asks whether it depends on a device capability and routes yes to a media feature query. The final row says everything else needs no query at all, only fluid CSS. Ask in this order; stop at the first yes 1 · Does it change WHICH FILE the browser downloads? yes Viewport lane: sizes, srcset, <source media> — never @container no 2 · Does it depend on the CARD'S own inline size? yes Container lane: @container media-card (min-width: …) no 3 · Does it depend on a DEVICE capability or user preference? yes @media feature query: hover, prefers-reduced-motion, dynamic-range no 4 · No query at all — express it fluidly: width: 100%, clamp(), aspect-ratio, minmax() grid tracks. Most card rules that people write as breakpoints belong here; a breakpoint is a fallback for arithmetic you did not do.

Question one catches everything about bytes: srcset candidates, sizes lengths, and <source media> art direction. None of those can ever be a container query, because the resource selection algorithm runs in the preload scanner, before the card has a box. Question two catches everything about the painted result: crop ratio, object-position, gutter, the point at which a caption moves from below the image to beside it. Question three catches the genuinely viewport-independent conditions — prefers-reduced-motion gating an animated WebP poster, dynamic-range: high selecting an HDR variant.

The residue that reaches question four is larger than most teams expect. A card image that is width: 100% inside a minmax(280px, 1fr) grid track needs no query whatsoever to be responsive; it only needs queries when something discontinuous happens — a ratio flip, a crop change, a different source file.

What each query type actually sees

Concretely, on a 1280 px viewport with a 296px 1fr two-track grid, a 24 px gutter and 24 px page padding, the sidebar card resolves to 296 CSS px and the main card to 912 CSS px (1280 − 48 − 296 − 24). Every @media condition on that page evaluates identically for both.

One viewport, two containers A browser frame labelled viewport 1280 pixels contains a narrow sidebar card of 296 pixels and a wide main card of 912 pixels. An annotation above shows that a min-width 1024 pixel media query is true for both cards. Annotations below each card show the container query resolving to different values: false in the sidebar and true in the main column. viewport width 1280 px — one value for the whole document 4 / 3 crop sidebar card 296 CSS px 21 / 9 crop main column card — same template, same HTML 912 CSS px @media (min-width: 1024px) true for BOTH cards — cannot tell them apart @container media-card (min-width: 640px) false at 296 px · true at 912 px sizes="(min-width: 1024px) 296px" correct for the sidebar only — the main card needs its own hint

That last line is the trap the two-lane pattern exists to close. A single sizes value cannot describe two slots of different width unless the two slots use different markup, so a component that truly renders at two widths on one page needs either two sizes strings passed as a prop, or sizes="auto".

Exact solution: one component, two lanes

The block below is a complete card. Lane A (byte selection) lives entirely in attributes the preload scanner can read. Lane B (painted box) lives entirely inside named @container rules. Neither lane references a breakpoint owned by the other.

<style>
  /* ── Lane A support: page geometry. This is ALL that @media owns here.
       It decides how wide the card's slot is; it never styles the card. ── */
  .card-grid { display: grid; gap: 24px; grid-template-columns: 1fr; }
  @media (min-width: 600px)  { .card-grid { grid-template-columns: 1fr 1fr; } }
  @media (min-width: 1024px) { .card-grid { grid-template-columns: 296px 1fr; } }

  /* ── Lane B: the component. Nothing below knows the viewport exists. ── */
  .media-card {
    container-type: inline-size;  /* observe the inline axis only; `size` would
                                     demand an explicit height on every card */
    container-name: media-card;   /* mandatory: an unnamed query resolves against
                                     the nearest ANY container, often the grid */
  }
  .media-card__image {
    display: block; width: 100%; height: auto;
    object-fit: cover;            /* fill the queried box, crop the overflow */
    aspect-ratio: 16 / 9;         /* default for the range no rule below covers */
  }

  @container media-card (max-width: 359px) {
    .media-card__image {
      aspect-ratio: 4 / 3;        /* taller crop reclaims vertical presence in a rail */
      object-position: 50% 25%;   /* bias the crop upward so faces survive it */
    }
  }
  @container media-card (min-width: 640px) {
    .media-card__image { aspect-ratio: 21 / 9; }  /* cinematic only where there is width */
  }
  @container media-card (min-width: 640px) {
    .media-card__body { padding-inline: 2cqi; }   /* 1cqi = 1% of the CONTAINER inline size */
  }
</style>

<div class="card-grid">
  <article class="media-card">
    <picture>
      <!--
        Lane A, art direction. `media` here is a MEDIA query and must be:
        <source> is resolved by the preload scanner, which has no container
        to measure. Use it only when the FILE differs (square master crop),
        not when only the CSS crop differs.
      -->
      <source
        media="(max-width: 599px)"
        type="image/avif"
        srcset="/img/card-sq-320.avif 320w, /img/card-sq-640.avif 640w"
        sizes="94vw">

      <!--
        Wide master, AVIF first; the <img> below is the JPEG floor.
        sizes lengths are the EXACT grid tracks, not 33vw guesses:
          ≥1024px → the fixed 296px track
          ≥600px  → 50vw minus half the 24px gap and the 24px page padding
          else    → 100vw minus 2 × 24px page padding
      -->
      <source
        type="image/avif"
        srcset="/img/card-300.avif   300w,
                /img/card-640.avif   640w,
                /img/card-960.avif   960w,
                /img/card-1280.avif 1280w"
        sizes="(min-width: 1024px) 296px,
               (min-width: 600px)  calc(50vw - 36px),
               calc(100vw - 48px)">

      <!--
        width/height carry the intrinsic ratio so the layout slot is reserved
        before the bytes land; loading="lazy" is what makes sizes="auto" legal
        if you adopt the alternative noted below; decoding="async" keeps the
        JPEG/AVIF decode off the main thread.
      -->
      <img
        class="media-card__image"
        src="/img/card-640.jpg"
        srcset="/img/card-300.jpg 300w, /img/card-640.jpg 640w, /img/card-960.jpg 960w"
        sizes="(min-width: 1024px) 296px, (min-width: 600px) calc(50vw - 36px), calc(100vw - 48px)"
        width="640"
        height="360"
        alt="Trail switchbacks above a fogged valley"
        loading="lazy"
        decoding="async">
      <!--
        ALTERNATIVE for components that render at two widths on one page:
        replace every sizes value above with  sizes="auto"  (Chrome 126+).
        The browser then uses the element's ACTUAL laid-out width, which is
        the only mechanism that reads the container. Two hard constraints:
          · it is ignored unless loading="lazy" is present, and
          · a lazy image is a poor LCP candidate, so never use it above the fold.
      -->
    </picture>
    <div class="media-card__body"><h3>Ridge Route</h3></div>
  </article>
</div>

The sizes="auto" note deserves emphasis because it is the only part of the platform that closes the gap. Everywhere else, container-aware bytes remain an arithmetic problem you solve at authoring time; sizes="auto" moves it to layout time at the cost of forcing loading="lazy". For below-the-fold cards that is free. For the card that is your Largest Contentful Paint element it is a regression, and you should keep an explicit sizes plus the priority treatment described in using fetchpriority to optimize critical media.

What each lane is worth in bytes

Measured on the sidebar card above: 296 CSS px at devicePixelRatio 2 needs 592 device pixels, so the honest candidate is the 640w file at 61 KB. The chart shows what each sizes strategy actually downloads for that same 296 px box.

Bytes downloaded for a 296 px card at DPR 2 Four horizontal bars. sizes equals 100vw downloads the 1280 wide candidate at 228 kilobytes. sizes equals 33vw downloads the 960 wide candidate at 132 kilobytes. An exact pixel sizes value downloads the 640 wide candidate at 61 kilobytes. sizes equals auto also downloads 61 kilobytes. A dashed line marks the 61 kilobyte ideal. One 296 px card, DPR 2 → 592 device px required 61 KB ideal sizes="100vw" picks 1280w · 3.7× overshoot 228 KB sizes="33vw" picks 960w · 2.2× overshoot 132 KB sizes="… 296px" picks 640w · exact grid track 61 KB sizes="auto" + lazy picks 640w · survives relocation 61 KB 0 240 KB Every bar renders the identical painted box. The 167 KB spread is decided before a container exists.

Note what the chart does not show: no bar changes when you edit a @container rule. Container queries moved zero bytes in this experiment, and they never will — a point worth restating in code review whenever someone proposes them as a performance fix.

Verification

1. Confirm the container lane resolves to the right ancestor

Open DevTools → Elements and select the <article class="media-card">. Chrome 105+ renders a grey container badge next to any element with a container-type; clicking it highlights the containment box and prints the resolved inline size in the overlay. Then select the <img> and read the Styles pane: a matched container rule appears under a header of the form @container media-card (min-width: 640px) with the container element name shown to its right. If the header names the grid instead of the card, your container-name is missing or misspelled.

Now change the container width without touching the viewport — edit grid-template-columns in the Styles pane from 296px 1fr to 700px 1fr. The sidebar card’s rule must flip to the min-width: 640px branch while the viewport stays at 1280 px. That flip is the whole proof; a media query cannot produce it.

2. Measure the byte lane from the console

// Overshoot audit: how many more pixels did we download than we painted?
// Run after the page has settled (images resolved, layout stable).
const rows = [...document.querySelectorAll('.media-card__image')].map(img => {
  const cssW = img.getBoundingClientRect().width;          // painted CSS px
  const needW = Math.round(cssW * devicePixelRatio);       // device px required
  // currentSrc is the candidate the selection algorithm actually chose
  const gotW = Number((img.currentSrc.match(/-(\d+)\.(?:avif|webp|jpe?g)/) || [])[1]);
  return {
    src: img.currentSrc.split('/').pop(),
    paintedCss: Math.round(cssW),
    neededDevice: needW,
    downloadedW: gotW,
    overshoot: gotW ? +(gotW / needW).toFixed(2) : 'n/a',   // target ≤ 1.3
  };
});
console.table(rows);

An overshoot above roughly 1.3 means the next-smaller candidate would still have covered the box; below 0.9 means you are upscaling and the image will look soft. Fix both by editing sizes, never by editing a @container rule.

3. Lock the behaviour in with a fixed-viewport Playwright assertion

// container-lane.spec.js — proves the card adapts at a CONSTANT viewport.
import { test, expect } from '@playwright/test';

test('card crop follows its container, not the viewport', async ({ page }) => {
  await page.setViewportSize({ width: 1280, height: 900 });   // never changes
  await page.goto('/cards/');

  const img = page.locator('.media-card__image').first();
  const ratioAt = async (track) => {
    // Resize only the grid TRACK; the viewport is untouched.
    await page.locator('.card-grid').evaluate(
      (el, t) => { el.style.gridTemplateColumns = `${t} 1fr`; }, track);
    await page.waitForTimeout(50);            // one layout + style pass
    return img.evaluate(el => getComputedStyle(el).aspectRatio);
  };

  expect(await ratioAt('296px')).toBe('4 / 3');     // compact branch
  expect(await ratioAt('500px')).toBe('16 / 9');    // default, no rule matches
  expect(await ratioAt('820px')).toBe('21 / 9');    // wide branch
});

4. Check the art-direction lane separately

# <source media> is viewport-driven, so it must be tested by resizing the viewport.
# Render at 480 px and 1200 px and diff which master crop was requested.
npx playwright screenshot --viewport-size=480,900  http://localhost:8080/cards/ narrow.png
npx playwright screenshot --viewport-size=1200,900 http://localhost:8080/cards/ wide.png
# Then confirm the square master only appears below 600 px:
grep -o 'card-sq-[0-9]*\.avif' server-access.log | sort | uniq -c

Common mistakes

1. Trying to switch <picture><source> from a container query

There is no mechanism for this and there will not be one: <source media> is evaluated by the resource selection algorithm, which runs in the preload scanner with no layout tree. Authors sometimes attempt a workaround with display: none inside @container, which does nothing — <source> is never rendered, so display has no effect on selection. If the file must change with container width, the honest options are sizes="auto" (same file family, different width) or moving the decision server-side and rendering two different components. For genuine crop changes, use viewport-driven art direction with the <picture> element.

2. Expecting container queries to reduce payload

/* WRONG: written in a perf ticket as "shrink images in the sidebar" */
@container media-card (max-width: 359px) { .media-card__image { width: 296px; } }
/* The 1280w file is already downloaded by the time this rule runs. */
<!-- CORRECT: the payload decision is an attribute, and only an attribute. -->
<img srcset="/img/card-300.avif 300w, /img/card-640.avif 640w"
     sizes="(min-width: 1024px) 296px, calc(100vw - 48px)" alt="">

3. Putting container-type on the element you want to style

An element cannot query itself; @container only matches descendants of the container. This is the single most common first-day error.

/* WRONG: the image is the container, so no @container rule can ever style it. */
.media-card__image { container-type: inline-size; container-name: media-card; }
@container media-card (max-width: 359px) { .media-card__image { aspect-ratio: 4/3; } }
/* CORRECT: the wrapper is the container; the image is a descendant of it. */
.media-card { container-type: inline-size; container-name: media-card; }
@container media-card (max-width: 359px) { .media-card__image { aspect-ratio: 4/3; } }

4. Mirroring the same numbers in both lanes

Writing @container media-card (min-width: 640px) and @media (min-width: 640px) for the same visual change couples the component to one page layout. The moment the card is reused in a modal or a 3-column grid, the two rules disagree and you get a 21:9 crop in a 300 px box. Keep container thresholds in component units (what the card needs) and media thresholds in page units (how many grid tracks fit), and let them land on different numbers. A card that flips at 640 px of itself has no reason to flip at 640 px of viewport.

5. Reaching for sizes="auto" on a hero card

sizes="auto" is ignored unless the image also carries loading="lazy"; without it, the browser falls back to treating the value as 100vw and you silently download the largest candidate. And a lazy image is deferred until it clears the viewport-distance threshold described in native lazy loading for images and iframes, which is exactly wrong for an above-the-fold card.

<!-- WRONG: auto without lazy → treated as 100vw on the LCP card. -->
<img sizes="auto" srcset="" src="/img/hero-1280.avif" alt="">

<!-- CORRECT: explicit sizes + eager fetch for the LCP card, auto for the rest. -->
<img sizes="(min-width: 1024px) 912px, 100vw" srcset="" fetchpriority="high" alt="">
<img sizes="auto" loading="lazy" srcset="" alt="">

Tradeoff: the two-lane split means the same breakpoint intent is expressed twice, in two vocabularies, and nothing in CSS keeps them in sync. Budget a comment in the stylesheet pointing at the grid math, and treat the console overshoot audit in verification step 2 as a lint you run on every layout change.