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.
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.
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.
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.
Related
- CSS Container Queries for Dynamic Media Sizing — the containment model,
cqiunits, and build integration behind the container lane - Fallback Strategies for Container Query Support — what the card renders as when
@containeris unavailable - How to Calculate Optimal
sizesAttribute Values — the grid arithmetic that turns a track width into a correctsizeslength - Art Direction with the HTML
<picture>Element — when a crop change genuinely requires a different file