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.

Where Each Container-Relative Unit Gets Its Number A card element declared with container-type inline-size, 300 pixels wide and 190 pixels tall. A double arrow across its width shows that the inline size of 300 pixels makes one cqi and one cqw equal three pixels. A double arrow down its side shows that the block axis is not contained by inline-size containment, so cqb and cqh fall back to one small viewport height unit. An inner box shows an image sized at 90cqi resolving to 270 pixels. Where each container-relative unit gets its number cqi and cqb follow the container's writing mode; cqw and cqh are always physical width and height. article.card container-type: inline-size img { width: 90cqi } resolves to 270 px block axis: cqb, cqh not contained by inline-size, so each one falls back to 1svh inline size 300 px → 1cqi = 1cqw = 3 px cqmin = min(cqi, cqb) · cqmax = max(cqi, cqb) — cqb is 1svh here, so cqmin is rarely what you meant.

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 &#8212; 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:

Resolved Caption Font Size Across Four Container Widths A grouped bar chart with four groups, one per container inline size of 240, 360, 560 and 860 pixels, measured at a fixed 1440 pixel viewport. Each group has three bars: a fixed 14 pixel value which never changes, a 1.6vw value which is 23.04 pixels in all four groups because the viewport never moved, and a clamped 4cqi value which reads 13.6, 14.4, 22.4 and 22.4 pixels. Resolved caption font-size, viewport held at 1440 px font-size: 14px font-size: 1.6vw clamp(0.85rem, 4cqi, 1.4rem) 0 8 16 px 14.0 23.0 13.6 240 px 14.0 23.0 14.4 360 px 14.0 23.0 22.4 560 px 14.0 23.0 22.4 860 px container inline size Only the cq-based value moves. The vw value is identical in all four groups because the viewport never changed — that is the entire failure mode container units exist to remove, and the clamp keeps the result inside a legible 13.6–22.4 px band.

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.

Which Ancestor Each 50cqi Resolves Against A vertical tree of five nested elements: the html root with no container-type, a page grid that is a 1200 pixel container, a card that is a 380 pixel container, a card media wrapper of 190 pixels that is not a container, and an image. Annotations on the right show that the root and the page grid both resolve their own 50cqi to 50svw, the card resolves to 600 pixels from the page grid, the media wrapper resolves to 190 pixels from the card, and the image resolves to 380 pixels from the card and therefore overflows its 190 pixel parent. Which ancestor each 50cqi resolves against (viewport 1440 px) html no container-type no ancestor query container → 50cqi resolves as 50svw = 720 px .page-grid — container-type: inline-size inline size 1200 px a container never queries itself → its own 50cqi is still 50svw = 720 px .card — container-type: inline-size inline size 380 px nearest ancestor is .page-grid → 50cqi = 600 px, not 190 px .card__media — no container-type inline size 190 px nearest ancestor is .card → 50cqi = 190 px, as intended img { width: 100cqi } painted at 380 px still .card, not .card__media → overflows its 190 px parent The unit walks up to the nearest ancestor query container — never the element's own box, never a plain parent.

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.