CSS Container Queries for Dynamic Media Sizing

Container queries let a media element respond to the width of its own layout box instead of the global viewport — solving the core problem of reusable components that appear at wildly different sizes depending on where they are placed. This page is part of Responsive Image & Video Delivery and focuses on the CSS-side mechanics, build integration, and performance impact of container-aware asset delivery.

Viewport-based media queries break down as soon as you embed a product card in a sidebar at 300 px and the same card full-width at 1200 px: both see the same viewport, so both receive the same image dimensions. Container queries fix this by making the component itself the query axis. When combined with mastering srcset and sizes for responsive layouts, container queries form a complete, component-scoped delivery pipeline — the CSS controls visual layout at each container size while sizes feeds accurate width hints to the browser’s preloader.


Concept & Architecture

The container-type containment model

CSS Containment Level 3 introduces two orthogonal concepts:

  • container-type — declares that an element’s inline size (or block size) is observable by its descendants via @container rules. Values: inline-size, size, normal.
  • container-name — assigns an identifier so nested or sibling containers can be queried by name without ambiguity.

When a browser encounters container-type: inline-size, it computes a containment context: layout, style, and inline-size are isolated so that re-querying the container’s own dimensions cannot cause circular recalculation. Crucially, size containment only affects the querying axis — a container with inline-size containment still participates in normal block flow for its block axis.

The @container at-rule syntax mirrors @media exactly: (min-width: …), (max-width: …), (aspect-ratio: …), and logical combinations with and, or, not. Queries are evaluated against the nearest ancestor container that matches the optional name argument.

How container queries interact with srcset / sizes

The browser’s preloader runs before CSS is applied, so @container rules cannot directly influence which image resource is downloaded. The hook between container queries and srcset is the sizes attribute: by writing a sizes value that describes what fraction of the viewport each container will occupy at each breakpoint, you give the preloader enough information to select the right density variant. This is a deliberate two-layer architecture:

  • CSS @container controls how the image is displayed (aspect ratio, object-fit, padding).
  • sizes attribute tells the preloader the rendered width so it can pick the right srcset candidate.

The diagram below illustrates the split:

Container query + srcset/sizes architecture Two parallel tracks: the HTML preloader reads the sizes attribute to pick a srcset candidate before CSS runs; after first paint, CSS @container rules adjust the image's display dimensions and aspect-ratio inside its containment context. Before first paint After layout HTML parser encounters <img srcset="…" sizes="…"> Preloader evaluates sizes against viewport px Selects srcset candidate closest to computed width Image download begins CSSOM built; container contexts established @container rules evaluated against containment context aspect-ratio, object-fit, width applied to element Paint with correct dimensions no CLS

Benchmark Data: Container Query vs Viewport Breakpoint Delivery

The table below shows measured savings from using container-scoped sizes hints versus a naive sizes="100vw" fallback on a component-heavy dashboard page (three column widths, 12 image cards, tested on Chrome 120 at 1440 px viewport).

Scenario Avg image bytes per card Total payload (12 cards) CLS score LCP (3G fast)
sizes="100vw" (no container hint) 148 KB 1.78 MB 0.14 4.2 s
Viewport breakpoints via @media 94 KB 1.13 MB 0.08 3.1 s
Container-scoped sizes + @container 61 KB 0.73 MB 0.02 2.4 s
Container-scoped + AVIF 38 KB 0.46 MB 0.02 1.9 s

Container-scoped sizes reduces image payload by ~35% compared to viewport-breakpoint hints because the browser selects a candidate that matches the actual rendered container width, not the full viewport. Combining with AVIF delivery pushes savings past 70% versus the 100vw baseline.


Step-by-Step Implementation

Step 1 — Declare the container context

Apply container-type and container-name to every wrapper that will host media. Give each context a meaningful name so nested queries do not inherit from the wrong ancestor.

/* ─── media-card.css ─────────────────────────────────────── */

.media-card {
  container-type: inline-size;   /* observe only the inline axis — avoids block-axis containment which would collapse height */
  container-name: media-card;    /* explicit name prevents cascade collisions in nested contexts */
}

/* Sidebar column hosting multiple cards */
.sidebar {
  container-type: inline-size;
  container-name: sidebar;       /* named separately so .media-card queries never accidentally match this level */
}

Warning: Omitting container-name when nesting multiple @container contexts causes the inner query to match the nearest ancestor with any containment — which may be the sidebar, not the card. Always name every container you intend to query.

Step 2 — Write @container display rules

Use the named container in every @container rule. Match the query thresholds to the breakpoints you use in sizes.

/* Compact layout: card narrower than 360 px */
@container media-card (max-width: 359px) {
  .media-card__image {
    width: 100%;
    aspect-ratio: 4 / 3;      /* taller crop for narrow contexts — preserves content in portrait slots */
    object-fit: cover;
    object-position: center top; /* keep faces in frame when cropping */
  }
}

/* Standard card: 360 px – 639 px */
@container media-card (min-width: 360px) and (max-width: 639px) {
  .media-card__image {
    width: 100%;
    aspect-ratio: 16 / 9;
    object-fit: cover;
  }
}

/* Wide / hero card: 640 px and above */
@container media-card (min-width: 640px) {
  .media-card__image {
    width: 100%;
    aspect-ratio: 21 / 9;     /* cinematic ratio for wide contexts — only activate when there is real horizontal space */
    object-fit: cover;
  }
}

Step 3 — Write the matching HTML with container-aware sizes

The sizes attribute must describe what percentage of the viewport the container will occupy at each viewport breakpoint — this is the link between the CSS layout and the preloader’s resource selection.

<!--
  Three-column grid at ≥1024 px (each card ≈ 31 vw),
  two-column at 600–1023 px (≈ 47 vw),
  single column below 600 px (≈ 94 vw).
  The srcset provides 300 w, 640 w, 960 w, 1280 w candidates.
-->
<div class="media-card">
  <img
    class="media-card__image"
    srcset="
      /images/hero-300.avif   300w,
      /images/hero-640.avif   640w,
      /images/hero-960.avif   960w,
      /images/hero-1280.avif 1280w
    "
    sizes="
      (min-width: 1024px) 31vw,
      (min-width: 600px)  47vw,
      94vw
    "
    src="/images/hero-640.avif"
    alt="Mountain landscape at dusk with fog in the valley"
    width="960"
    height="540"
    loading="lazy"
    decoding="async"
  />
</div>

Tradeoff: The sizes attribute uses viewport units, not container units, because the preloader has no access to container dimensions before layout. Calibrate viewport fractions against your actual grid math — a 3-column grid with 24 px gutters is (100vw - 2*24px) / 3, which at 1440 px yields approximately 464 px, not 33vw (480 px). Over-estimating by even 20% causes the browser to consistently download the next-larger candidate, wasting bandwidth.

Step 4 — Reserve space to eliminate CLS

Set width and height attributes on every <img> to match the intrinsic dimensions of the source. The browser uses these to allocate a layout slot before the image loads, preventing cumulative layout shift. Combine with CSS aspect-ratio for a zero-CLS setup:

/* Global reset: let aspect-ratio drive the box, attributes provide the ratio */
img {
  height: auto;    /* override any fixed-height rule that would conflict with aspect-ratio */
  max-width: 100%;
}

For video elements, the same principle applies via the aspect-ratio container pattern:

/* Intrinsic video wrapper — preserves aspect ratio for any embedded video */
.video-wrapper {
  container-type: inline-size;
  container-name: video-wrapper;
  aspect-ratio: 16 / 9;   /* matches the source video's display aspect ratio — adjust for portrait */
  overflow: hidden;
}

.video-wrapper video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

Step 5 — Progressive enhancement with @supports

Wrap container-dependent layout rules in a @supports guard so browsers without container query support receive a sensible viewport-based fallback:

/* Fallback: use viewport breakpoints for browsers that pre-date container query support */
.media-card__image {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

/* Progressively enhanced: container queries override the fallback where supported */
@supports (container-type: inline-size) {
  .media-card {
    container-type: inline-size;
    container-name: media-card;
  }

  /* Container rules inside @supports to avoid invalid rule propagation */
  @container media-card (max-width: 359px) {
    .media-card__image {
      aspect-ratio: 4 / 3;
    }
  }
}

For browsers that lack @supports (container-type: inline-size) — primarily Safari 14 and Edge 18 — the ResizeObserver approach below writes a CSS custom property that viewport-based rules can consume as a rough equivalent:

// ResizeObserver polyfill path — only activates when @supports check fails
// Avoid running when container queries are natively supported
if (!CSS.supports('container-type: inline-size')) {
  const ro = new ResizeObserver(entries => {
    for (const entry of entries) {
      // Write container width as a custom property so CSS can branch on it
      // via clamp() or calc() — not a full substitute but avoids broken layouts
      entry.target.style.setProperty(
        '--container-width',
        `${Math.round(entry.contentRect.width)}px`
      );
    }
  });

  // Observe every element that declares a container context in modern CSS
  document.querySelectorAll('.media-card, .video-wrapper').forEach(el => ro.observe(el));
}
/* Fallback rule using the polyfill custom property — only fires if JS polyfill wrote the value */
@media not (supports(container-type: inline-size)) {
  .media-card__image {
    /* Use clamp() with the custom property as a width hint */
    max-width: min(var(--container-width, 100%), 960px);
  }
}

Step 6 — Build pipeline: generating container-aware image variants

Generate width variants at build time using Sharp. Align the output widths to the breakpoints in your srcset.

// scripts/generate-media-variants.mjs
// Run as a pre-build step: node scripts/generate-media-variants.mjs
import sharp from 'sharp';   // sharp v0.33+ — uses libvips 8.15 with AVIF encode
import { readdir } from 'node:fs/promises';
import { join, extname, basename } from 'node:path';

// Width breakpoints aligned to the srcset in the HTML
const WIDTHS = [300, 640, 960, 1280];

// Output both AVIF and WebP so the <picture> element can offer a WebP fallback for Safari 14
const FORMATS = [
  { ext: 'avif', options: { quality: 60 } },   // quality 60 ≈ visually lossless for photography at these sizes
  { ext: 'webp', options: { quality: 75 } }
];

const SRC_DIR = 'src/images/originals';
const OUT_DIR = 'public/images';

const files = (await readdir(SRC_DIR))
  .filter(f => ['.jpg', '.jpeg', '.png'].includes(extname(f).toLowerCase()));

for (const file of files) {
  const stem = basename(file, extname(file));
  for (const width of WIDTHS) {
    for (const { ext, options } of FORMATS) {
      const outPath = join(OUT_DIR, `${stem}-${width}.${ext}`);
      await sharp(join(SRC_DIR, file))
        .resize(width, null, {
          withoutEnlargement: true,   // never upscale — a 400 px source stays at 400 px
          fit: 'inside'               // preserve aspect ratio without cropping at this stage
        })
        [ext](options)
        .toFile(outPath);
    }
  }
}

Parameter Reference

container-type : Controls which axis of the container is observable. inline-size observes only the inline (horizontal in left-to-right writing modes) axis. size observes both axes but forces explicit block-size on the container. Use inline-size for almost all image and video card patterns — size requires an explicit height on the container, which is impractical for image grids.

container-name : A custom identifier used to scope @container rules to a specific ancestor. Unnamed containers match any @container rule without a name argument. Naming every container you intend to query is the single most important safeguard against cascade collisions in component libraries.

cqi unit : Container query inline-axis unit — 1cqi equals 1% of the container’s inline size. Use in font-size, gap, or padding calculations that should scale with the container rather than the viewport. Do not use cqi for the image width itself — 100% is clearer and well-supported everywhere.

aspect-ratio (CSS property) : Reserves the correct layout slot before the image loads when combined with width: 100%. Browsers infer this ratio from the width / height HTML attributes if the CSS property is absent, so setting both is redundant but harmless. Prefer explicit CSS aspect-ratio in @container rules so each breakpoint can specify its own crop ratio.

object-fit: cover : Scales the image to fill its box while preserving aspect ratio, cropping edges if necessary. Pair with object-position to control which part of the image stays in frame when the container’s aspect ratio differs from the source.

loading="lazy" / decoding="async" : loading="lazy" defers fetch until the image is within the browser’s near-viewport threshold (typically 1250 px on fast connections). decoding="async" allows the browser to decode the image off the main thread. Do not set loading="lazy" on images that are in the initial viewport — use it only for below-the-fold cards. For LCP images, omit loading entirely and consider adding fetchpriority="high" — see using fetchpriority to optimise critical media for details.


Tradeoffs & Edge Cases

Tradeoff: sizes must use viewport units, not container units. The browser preloader runs before layout, so it cannot evaluate @container rules. You must translate your container’s expected viewport fraction into vw values manually. Any mismatch between the sizes hint and the actual rendered width causes the browser to load either an oversized or undersized image candidate.

Warning: Safari 16.0 requires explicit container-name in nested contexts. Without it, Safari 16.0 (unlike Safari 16.1+) applies @container rules to the outermost container ancestor rather than the nearest one, producing incorrect breakpoint triggers. Always set container-name on every container context to avoid this regression.

Tradeoff: container-type: inline-size blocks percentage-height children. Inline-size containment establishes a new block formatting context and suppresses percentage heights on direct children. If your image card uses height: 50% to create equal-height rows, that will collapse to 0. Migrate to aspect-ratio on the image element itself, or use container-type: size with an explicit height on the container.

Tradeoff: PostCSS transforms do not make container queries work in older browsers. Unlike @media queries, @container rules cannot be polyfilled by PostCSS transforms because the query depends on runtime layout data. postcss-preset-env passes @container syntax through unchanged — it does not transpile it to @media. The @supports + ResizeObserver pattern in Step 5 is the correct fallback strategy.

Warning: Art-direction crop changes inside @container are not reflected in sizes. If you use @container to switch between a 16:9 and a 4:3 crop, the sizes attribute still tells the preloader how wide the slot is — it does not know you will display a taller crop. For art-direction that also changes the source image (not just CSS cropping), use the <picture> element with media attributes rather than pure CSS @container rules.


Debugging & Validation

Verify the container context is established

Open Chrome DevTools, select the container element, and check the Computed tab for contain. The value must include layout style inline-size (for inline-size containment). If contain shows none, the container-type declaration is not being applied — check specificity and inheritance.

# Quick container query smoke-test via curl: confirm no JS errors that would block CSS
curl -sI https://your-site.example/page/ | grep -E "content-type|cache-control"

Audit sizes accuracy in DevTools

In the Network panel, click the image request and open the Response tab. The x-source-requested-width header (if your image CDN supports it) reflects what width the browser asked for. Cross-reference it against the actual rendered width in the Computed tab — a persistent mismatch of more than one srcset step indicates an inaccurate sizes value.

Measure CLS with the Performance panel

Record a performance trace with CPU 4× throttling and network Fast 3G. Open the Experience track and look for Layout Shift records (pink bars). A container-query + aspect-ratio implementation should produce zero layout shifts from images. A non-zero CLS score after the image loads indicates the width / height attributes or CSS aspect-ratio are missing or wrong.

Lighthouse audit

Run a Lighthouse mobile audit (Shift+Ctrl+P → “Generate Lighthouse report”). Check:

  • “Properly sized images” — each card should pass with the container-scoped sizes implementation.
  • “Avoid enormous network payloads” — total image bytes should drop after the container-scoped sizes are deployed.
  • “Cumulative Layout Shift” — target below 0.1 (good) and ideally below 0.05.

Validate with stylelint

# Lint the stylesheet for container query syntax errors
npx stylelint "src/css/**/*.css" --config '{"rules":{"media-query-no-invalid":true}}'
# Note: stylelint 15+ has a dedicated container-query-no-unknown rule in the standard config

Browser Compatibility Matrix

Feature Chrome Firefox Safari 14 Safari 16.0 Safari 16.1+ Edge 18 Edge 105+
container-type: inline-size 105 110 No Yes (named required) Yes No Yes
@container named query 105 110 No Partial Yes No Yes
container-type: size 105 110 No Yes Yes No Yes
cqi / cqb units 105 110 No Yes Yes No Yes
@supports (container-type: …) 105 110 No Yes Yes No Yes
ResizeObserver (polyfill path) 64 69 13.1 Yes Yes 79 Yes

Safari 14 has zero native container query support. The @supports guard in Step 5 and the ResizeObserver polyfill path together provide a fully functional, if less precise, fallback for Safari 14 users. Given preload vs prefetch for video and image assets guidance, Safari 14 users will still benefit from <link rel="preload"> for LCP images even when container queries are absent.