Container Query Units for Responsive Image Sizing
cqi, cqb, cqw, cqh, cqmin and cqmax let a media element’s box, padding and caption scale with the width of the component it sits in rather than the width of the window — but only if there is an eligible ancestor query container, and only on the axis that container actually contains. Get either condition wrong and the unit does not error: it silently resolves against the small viewport instead, producing a value that looks plausible on your laptop and is wildly wrong in a 300 px sidebar. This page covers the resolution rules, one production-shaped component that uses them correctly, and the DevTools probes that prove the numbers. It sits under CSS container queries for dynamic media sizing, which covers the @container rule itself; here the subject is only the length units.
Prerequisite checklist
What each unit actually measures
There are six units in two families. cqw and cqh are physical: 1 % of the container’s width and height respectively. cqi and cqb are logical: 1 % of the container’s inline and block size, resolved through the container’s writing mode, not the element’s. In horizontal-tb — almost everything — cqi equals cqw and cqb equals cqh, which is why the two families are so easily confused; in a vertical-rl sidebar they swap, and only the logical pair keeps meaning what you intended. cqmin and cqmax are the smaller and larger of cqi and cqb.
The critical asymmetry is that container-type: inline-size — the value you want in 95 % of layouts, because size requires an explicit height — contains only the inline axis. The block axis is not contained, so there is no block size to take a percentage of, and per CSS Containment Level 3 the unit falls back to the small viewport size for that axis. 1cqb becomes 1svh. No warning, no invalid declaration, no strikethrough in the Styles panel.
Unit reference
| Unit | Measures | Meaningful only when | Typical media use |
|---|---|---|---|
cqi |
1 % of the container’s inline size | container-type: inline-size or size |
image box height, gutters, caption type — the default choice |
cqw |
1 % of the container’s physical width | same, in horizontal writing modes | width maths in a layout whose writing mode is fixed |
cqb |
1 % of the container’s block size | container-type: size |
vertical rhythm inside an explicitly sized container |
cqh |
1 % of the container’s physical height | container-type: size |
full-bleed media whose height is a fraction of a sized container |
cqmin |
the smaller of cqi and cqb |
container-type: size |
square badges and overlays on a sized container |
cqmax |
the larger of cqi and cqb |
container-type: size |
blur radii and shadows that track the longest edge |
Read the third column as a hard constraint rather than a recommendation. The four block-axis-dependent units are all syntactically valid under inline-size containment — they parse, they compute, they show no strikethrough in the Styles panel — and every one of them quietly returns a viewport measurement. In practice that reduces the usable set for a component library to one unit, cqi, and that is not a limitation worth fighting: an image box, a gutter and a caption are all things you want proportional to the component’s width anyway.
The complete solution
One component, self-contained. Every declaration that uses a container unit is annotated with the value it resolves to at a 280 px container and at an 860 px container, so you can check the Computed panel against a written expectation instead of a feeling.
<style>
/* ── 1. Establish the query container ──────────────────────────────────────
inline-size contains only the inline axis, which is what we want: the card's
height stays driven by its content. Naming the container is not optional in
a component library — an unnamed container is matched by any descendant's
unnamed @container rule, including ones from unrelated components. */
.card {
container-type: inline-size;
container-name: card;
}
/* ── 2. The media box ──────────────────────────────────────────────────────
height in cqi (not cqb) makes the image box a fixed FRACTION of the card's
width, which is an aspect ratio expressed the long way round — but unlike
aspect-ratio it can be clamped at both ends, so a 1200 px card does not get
a 500 px tall banner.
42cqi @ 280 px container = 117.6 px -> clamped up to 140 px
42cqi @ 860 px container = 361.2 px -> clamped down to 320 px */
.card__img {
display: block;
width: 100%; /* percentage, not 100cqi — see mistake 1 */
height: clamp(140px, 42cqi, 320px);
object-fit: cover; /* crop rather than distort when clamped */
object-position: 50% 35%; /* keep subject in frame at the tight end */
border-radius: clamp(4px, 1.2cqi, 14px); /* 3.4 px @280, 10.3 px @860 */
}
/* ── 3. Internal spacing that tracks the component, not the page ───────────
3cqi keeps the optical gutter proportional: 8.4 px in a sidebar card,
25.8 px in a full-width hero card, with no breakpoint anywhere. */
.card__body {
padding: 3cqi;
}
/* ── 4. Caption typography — ALWAYS clamped with rem bounds ────────────────
Raw cqi typography ignores the user's root font size entirely. The rem
floor and ceiling restore that: below 340 px the floor wins, above 560 px
the ceiling does, and only the band between them is container-driven.
4cqi @ 240 px = 9.6 px -> floor 13.6 px
4cqi @ 360 px = 14.4 px -> in band
4cqi @ 560 px = 22.4 px -> at the ceiling */
.card__caption {
font-size: clamp(0.85rem, 4cqi, 1.4rem);
line-height: 1.45;
margin-block-start: 2cqi;
}
/* ── 5. Fallback for engines without container units ───────────────────────
One @supports test covers both @container and the units — they shipped
together in Chrome 105, Safari 16 and Firefox 110. Anything older gets a
fixed, viewport-agnostic layout rather than a broken one. */
@supports not (width: 1cqi) {
.card__img { height: 200px; border-radius: 8px; }
.card__body { padding: 16px; }
.card__caption { font-size: 1rem; margin-block-start: 8px; }
}
</style>
<article class="card">
<img
class="card__img"
srcset="/img/reef-320.avif 320w, /img/reef-640.avif 640w,
/img/reef-960.avif 960w, /img/reef-1440.avif 1440w"
sizes="auto"
src="/img/reef-640.avif"
alt="Coral outcrop with a school of anthias above it"
width="960" height="540"
loading="lazy"
decoding="async">
<div class="card__body">
<p class="card__caption">Reef survey, north transect — March.</p>
</div>
</article>
sizes="auto" is the part that closes the loop, and it is the only mechanism that lets byte selection follow container geometry. It instructs the browser to use the image’s actual laid-out width as the sizes value instead of a hand-computed vw expression — which is exactly what a container-sized image needs, because no vw expression can describe a width the viewport does not determine. The HTML specification only permits it on images that are also loading="lazy", because lazy images are selected after layout, when a real width exists. Firefox shipped it in 123 and Chromium in 133; browsers without it treat the value as invalid and fall back to 100vw, so keep the width/height attributes present to hold the layout slot either way. Verify current support before relying on it for your LCP candidate — an LCP image should not be lazy in the first place, which is the subject of using fetchpriority to optimise critical media.
Tradeoff: sizes="auto" gives up preload-scanner discovery. The browser cannot know the layout width before layout, so the request is issued later than a sizes="(min-width: 900px) 420px, 92vw" hint would be. For below-the-fold cards that is free; for anything above the fold, compute the vw expression by hand using the method in how to calculate optimal sizes attribute values.
Verification steps
1. Read the resolved pixels in the Computed panel
Select .card__img, open Computed, and untick “Show all”. height, border-radius and the body padding are reported in resolved pixels, never in cqi. Resize the container — not the window — by editing the grid track width in the Styles panel, and watch the numbers move. Chrome also renders a container badge next to any element with container-type in the Elements tree; clicking it highlights the containment box and, on hover, prints the container’s current inline size. If the badge is absent, the declaration is not in effect and every cq* unit below it is already falling back.
Cross-check the badge against the contain entry in the Computed panel of the container itself. container-type: inline-size computes to contain: layout style inline-size; a value of none means the declaration lost the cascade or is sitting on an element that cannot establish containment. Both failures produce the same symptom — units that track the window — so distinguishing them by reading contain saves a round of guessing at specificity.
Measured against the component above, this is what changes and what does not:
2. Probe the units directly from the Console
The Computed panel tells you what a declaration resolved to; it does not tell you which container supplied the number, and it will not reveal a silent svh fallback. This probe answers both by measuring the units in isolation:
// Injects a hidden element inside `selector` and reads back what each unit
// resolves to there. Compare 100cqb against 100svh: if they are equal, the
// block axis is NOT contained and cqb/cqh/cqmin are viewport-driven.
function probeContainerUnits(selector) {
const host = document.querySelector(selector);
const probe = document.createElement('div');
// position:absolute keeps the probe out of flow; container-type establishes
// a containing block for absolutely positioned descendants, so the probe is
// still resolved against the same container as its in-flow siblings.
probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;height:0;';
host.append(probe);
const read = (expr) => {
probe.style.width = expr;
// getComputedStyle returns used values in px for width — the resolution we want.
return Math.round(parseFloat(getComputedStyle(probe).width) * 10) / 10;
};
const result = {
'host border-box width': Math.round(host.getBoundingClientRect().width * 10) / 10,
'100cqi': read('100cqi'),
'100cqw': read('100cqw'),
'100cqb': read('100cqb'), // equals 100svh below if the axis is uncontained
'100cqmin': read('100cqmin'),
'100svh': read('100svh'),
'100svw': read('100svw'), // equals 100cqi if there is no container at all
};
probe.remove();
console.table(result);
}
probeContainerUnits('.card__body');
Two readings decide everything. If 100cqi equals 100svw, there is no eligible query container above the probe and the whole component is viewport-sized. If 100cqb equals 100svh, the container is inline-size only — expected, but it means any cqb, cqh or cqmin in your stylesheet is measuring the window.
3. Confirm sizes="auto" picked a container-appropriate candidate
// After the card has scrolled into view and loaded:
const img = document.querySelector('.card__img');
console.log({
currentSrc: img.currentSrc.split('/').pop(),
layoutWidth: img.clientWidth, // the width sizes="auto" reported
intrinsicWidth: img.naturalWidth, // the candidate the browser fetched
dpr: devicePixelRatio,
// Healthy ratio is between 1.0 and 1.0 × dpr. Well above dpr means the
// container hint was ignored and 100vw was used instead.
ratio: (img.naturalWidth / img.clientWidth).toFixed(2),
});
In a 380 px card at DPR 2 the expected result is reef-640.avif with a ratio near 1.7. A ratio of 3.8 means sizes="auto" was not honoured and the browser assumed the full viewport — the signature of an engine without support, or of an image that lost its loading="lazy" attribute somewhere in a component refactor.
Which ancestor supplies the number
The resolution rule is a single sentence with two exclusions: a container-relative unit resolves against the nearest ancestor query container, never against the element’s own box, and never against a parent that has not declared container-type. Both exclusions bite in ordinary component trees.
The unit travels with the declaration, not with the token
An unregistered custom property holding a container unit stores the token, not a length. --media-gutter: 3cqi is substituted verbatim into whichever declaration consumes it and is resolved there, against that element’s nearest ancestor container. A shared design-token sheet therefore produces a different pixel value in every component that reads it, which is usually the point — and occasionally a nasty surprise when the same token is consumed at page level, where no container exists and it collapses to 3svw.
Registering the property does not change this. @property --media-gutter { syntax: "<length>"; inherits: true; initial-value: 12px } makes the value computationally typed and animatable, but resolution still happens at the point of use, so the inherited value carries the container of the consumer rather than the definer. The practical rule: express shared tokens in rem or px, and confine container units to declarations that live inside the component whose container they mean. If you must ship a container-relative token, name it after the container it assumes — --card-gutter, not --gutter — so a misuse is visible in review rather than only in a sidebar at 300 px.
Common mistakes
1. Putting cqi on the container itself
The most common form is .card { container-type: inline-size; padding: 3cqi; }. The padding does not resolve against .card — it walks past it to whatever container is above, and if there is none, to 50svw. The symptom is a component whose gutters change when you resize the window and stay frozen when you resize the component: precisely inverted.
/* WRONG — .card's own cqi looks up the tree, not at itself. */
.card { container-type: inline-size; padding: 3cqi; }
/* CORRECT — put the container on a wrapper and the units on the child. */
.card { container-type: inline-size; container-name: card; }
.card__body { padding: 3cqi; } /* now resolves against .card */
The same rule explains the overflow in the diagram above: width: 100cqi on an image nested inside a non-container wrapper resolves against the grandparent card, not the wrapper, and paints wider than the box it sits in. For an image that should fill its immediate parent, width: 100% is both correct and shorter.
2. Reaching for cqb or cqh under inline-size containment
/* WRONG — cqb is not contained here; this is 60% of the small viewport height,
so the image box grows and shrinks with the browser window. */
.card { container-type: inline-size; }
.card__img { height: 60cqb; }
/* CORRECT — express the block dimension as a fraction of the INLINE axis,
which is the only axis that is actually contained. */
.card__img { height: clamp(140px, 42cqi, 320px); }
/* ALSO CORRECT, at a cost — size containment contains both axes, but it
requires the container's height to be resolvable without its content. */
.card { container-type: size; block-size: 320px; }
3. Writing container units into the sizes attribute
sizes is parsed by the preload scanner, long before any layout exists, so it accepts viewport-relative and font-relative lengths only. A container unit there is an invalid source-size entry: the browser discards it and falls back to 100vw, which selects the largest candidate in every srcset on the page.
<!-- WRONG — invalid source size; silently degrades to 100vw. -->
<img srcset="/img/reef-320.avif 320w, /img/reef-960.avif 960w"
sizes="90cqi" src="/img/reef-640.avif" alt="Reef outcrop">
<!-- CORRECT (below the fold) — let layout supply the width. -->
<img srcset="/img/reef-320.avif 320w, /img/reef-960.avif 960w"
sizes="auto" loading="lazy" src="/img/reef-640.avif"
width="960" height="540" alt="Reef outcrop">
<!-- CORRECT (above the fold) — hand-computed vw expression, no lazy needed. -->
<img srcset="/img/reef-320.avif 320w, /img/reef-960.avif 960w"
sizes="(min-width: 1024px) 380px, (min-width: 640px) 46vw, 92vw"
src="/img/reef-640.avif" width="960" height="540" alt="Reef outcrop">
4. Assuming cqmin means “the shorter side of the card”
cqmin is min(cqi, cqb), and under inline-size containment cqb is the small viewport height. On a 1200 px-wide card in a 700 px-tall window, 10cqmin is 70 px — derived from the browser window, not from the card at all. It only means what people expect under container-type: size.
/* WRONG — reads as "10% of the card's shorter side", is actually 10svh here. */
.card__badge { inset-inline-start: 10cqmin; }
/* CORRECT — say which axis you mean. */
.card__badge { inset-inline-start: 10cqi; }
5. Unbounded cqi typography on captions
font-size: 4cqi with no clamp() produces text that scales linearly with the component and ignores the root font size entirely, so a reader who has set a 24 px default gets the same 9.6 px caption in a 240 px card as everyone else. Bound both ends in rem so the user’s preference still governs the extremes and the container only governs the middle band — the pattern used in the solution block above. The same reasoning applies to object-position offsets and to any cqi-derived min-height: pick the band you want to be fluid, and pin everything outside it.
Related
- CSS Container Queries for Dynamic Media Sizing — the parent guide:
@containerrules, containment model and build integration - How to Calculate Optimal sizes Attribute Values — the hand-computed
vwarithmetic to use whensizes="auto"is not an option - Art Direction with the HTML Picture Element — when the crop itself must change, CSS units are not enough and
<source media>is required - Debugging Picture Source Selection in DevTools — confirm which file was chosen once the container-driven box is correct