Media queries ask how wide the browser window is. Container queries ask how wide a component's own box is. You mark an element as a query container with container-type, then write @container rules that restyle its descendants when that box crosses a size you choose. The same card can be a compact stack in a sidebar and a wide feature in the main column, without a single modifier class.

Size queries and the container query units (cqi, cqb, cqmin, cqmax) work in every current browser and have been Baseline Widely available since August 2025. Style queries, which test the value of a custom property with @container style(--name: value), reached all three engines in May 2026 when Firefox 151 shipped them.

The eight demos below are original and self-contained: a card with three layouts, a widget that works in any column, fluid type in cqi units, a navigation bar that collapses by its own width, a dashboard grid, named and nested containers, style-query theming, and a side-by-side comparison with a media query. Each demo keeps working in browsers without the feature and tells you when it is showing the fallback.

A card that switches layout at its own width

The same article card appears three times. Nothing differs between them except the width of the slot they sit in: under 360px the card is stacked, from 360px the image moves beside the text, and from 560px it becomes a featured card with a badge and stat tiles.

Two pieces make it work. The wrapper gets container: trail / inline-size, which is shorthand for container-name: trail and container-type: inline-size. Then each @container trail (width >= 360px) block restyles elements inside the wrapper. The range syntax (width >= 360px) is the same one media queries use, and min-width: 360px works too.

Write the narrow layout as the base styles and add the wider layouts inside @container. A browser that does not understand @container skips those blocks and still shows a complete, readable card.

A container can't style itself from its own query. The rules always target descendants, which is why the card sits inside a separate .trail-slot wrapper.

Open the demo in a new tab
Show the code
HTML
<div class="trail-slot">
  <article class="trail-card">
    <svg class="trail-card__media" viewBox="0 0 160 100" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Turquoise lake below forested mountain slopes"><defs><linearGradient id="gfff7d6" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#8fc1d6"/><stop offset="1" stop-color="#e4f0f2"/></linearGradient></defs><rect width="160" height="100" fill="url(#gfff7d6)"/><circle cx="118" cy="30" r="11" fill="#fff7d6"/><path d="M0 70 L38 30 L62 52 L92 18 L132 60 L160 42 V100 H0Z" fill="#6f8f9c"/><path d="M0 82 L30 62 L58 76 L96 56 L128 74 L160 64 V100 H0Z" fill="#2f5a46"/><rect y="88" width="160" height="12" fill="#3f8a9a"/></svg>
    <div>
      <span class="trail-card__badge">Editor's pick</span>
      <p class="trail-card__kicker">Banff National Park, Canada</p>
      <h2 class="trail-card__title"><a href="#lake-agnes">Lake Agnes Tea House</a></h2>
      <p class="trail-card__excerpt">A steady switchback climb from Lake Louise to a historic tea house beside a hanging lake. Go before 9am in summer.</p>
      <dl class="trail-card__stats">
        <div><dt>Distance</dt><dd>7 km</dd></div>
        <div><dt>Gain</dt><dd>400 m</dd></div>
        <div><dt>Time</dt><dd>3 h</dd></div>
      </dl>
    </div>
  </article>
</div>
CSS
/* 1. The wrapper is the query container. It measures its own inline size. */
.trail-slot { container: trail / inline-size; }

/* 2. Base layout = narrow, stacked. Every browser gets this. */
.trail-card { display: grid; gap: 12px; padding: 12px; border-radius: 16px; background: #fff; box-shadow: 0 1px 2px rgb(31 41 51 / .08), 0 6px 20px rgb(31 41 51 / .06); }
.trail-card__media { aspect-ratio: 16 / 9; border-radius: 10px; display: block; width: 100%; height: auto; }
.trail-card__kicker { margin: 0; font-size: 13px; font-weight: 600; color: #8a3b12; }
.trail-card__title { margin: 2px 0 6px; font-size: 1.125rem; line-height: 1.25; }
.trail-card__title a { color: inherit; text-decoration: none; }
.trail-card__title a:hover { text-decoration: underline; }
.trail-card__excerpt { margin: 0 0 10px; font-size: 14px; color: #3e4c59; }
.trail-card__stats { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 0; font-size: 13px; color: #3e4c59; }
.trail-card__stats div { display: flex; gap: 4px; }
.trail-card__stats dt { font-weight: 600; color: #1f2933; }
.trail-card__stats dd { margin: 0; }
.trail-card__badge { display: none; }

/* 3. Medium container: image beside the text. */
@container trail (width >= 360px) {
  .trail-card { grid-template-columns: 40% 1fr; align-items: start; }
  .trail-card__media { aspect-ratio: 4 / 5; height: auto; }
}

/* 4. Wide container: featured layout with a badge and stat tiles. */
@container trail (width >= 560px) {
  .trail-card { grid-template-columns: 1fr 1fr; gap: 20px; padding: 16px; }
  .trail-card__media { aspect-ratio: auto; height: 100%; min-height: 200px; }
  .trail-card__title { font-size: 1.5rem; }
  .trail-card__badge { display: inline-block; margin-bottom: 8px; padding: 2px 10px; border-radius: 999px; background: #1f5135; color: #fff; font-size: 12px; font-weight: 600; }
  .trail-card__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
  .trail-card__stats div { flex-direction: column-reverse; gap: 0; padding: 8px; border-radius: 8px; background: #f3efe7; }
  .trail-card__stats dt { font-weight: 400; font-size: 12px; color: #3e4c59; }
  .trail-card__stats dd { font-size: 15px; font-weight: 700; color: #1f2933; }
}

One widget for the sidebar and the main column

This is the job container queries were designed for. The page layout still uses a media query, because whether the sidebar exists really does depend on the viewport. The events widget uses a container query, because its layout depends on the column it lands in.

Both copies use identical markup and classes. In the 210px sidebar each event is a compact block with a text link. In the main column the same list becomes rows with a booking button on the right. Move the widget to a footer or a modal and it adapts again with no extra CSS.

The booking link stays in the HTML at every size; only its presentation changes. Hiding actions at small sizes is tempting, but it also hides them from anyone whose browser falls back to the compact layout.

Open the demo in a new tab
Show the code
HTML
<section class="events-widget" aria-labelledby="ev-main">
  <div class="events">
    <div class="events__head">
      <h2 class="events__title" id="ev-main">Upcoming workshops</h2>
      <a class="events__all" href="#calendar">Full calendar</a>
    </div>
    <ul class="events__list">
      <li class="event">
        <span class="event__date">Nov <b>14</b></span>
        <p class="event__name">Intro to wheel throwing</p>
        <p class="event__meta">Sat 10:00, Clay room <span class="event__seats">3 seats left</span></p>
        <a class="event__book" href="#book-wheel">Book<span class="visually-hidden"> wheel throwing</span></a>
      </li>
      <li class="event">
        <span class="event__date">Nov <b>17</b></span>
        <p class="event__name">Laser-cut lamp shades</p>
        <p class="event__meta">Tue 18:30, Laser bay <span class="event__seats">Waiting list</span></p>
        <a class="event__book" href="#book-lamp">Join list<span class="visually-hidden"> for lamp shades</span></a>
      </li>
      <li class="event">
        <span class="event__date">Nov <b>19</b></span>
        <p class="event__name">Repair café: small electronics</p>
        <p class="event__meta">Thu 17:00, Front bench <span class="event__seats">Drop-in</span></p>
        <a class="event__book" href="#book-repair">Details<span class="visually-hidden"> for repair café</span></a>
      </li>
    </ul>
  </div>
</section>
CSS
/* Wherever the widget is dropped, its wrapper becomes the container. */
.events-widget { container: events / inline-size; }

.events { padding: 14px; border-radius: 14px; background: #fff; box-shadow: 0 1px 2px rgb(31 41 51 / .08); }
.events__head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 12px; margin-bottom: 10px; }
.events__title { margin: 0; font-size: 1rem; }
.events__all { font-size: 14px; color: #1d4ed8; }
.events__list { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.event { display: grid; grid-template-columns: 46px 1fr; grid-template-areas: "date name" "date meta" "date book"; gap: 2px 10px; align-items: start; }
.event__date { grid-area: date; display: grid; place-items: center; padding: 4px 0; border-radius: 8px; background: #e0e8ff; color: #1e3a8a; font-size: 12px; font-weight: 700; line-height: 1.2; text-transform: uppercase; }
.event__date b { font-size: 18px; }
.event__name { grid-area: name; margin: 0; font-size: 15px; font-weight: 600; line-height: 1.3; }
.event__meta { grid-area: meta; margin: 0; font-size: 13px; color: #3e4c59; }
.event__seats { display: block; }
.event__book { grid-area: book; justify-self: start; font-size: 13px; font-weight: 600; color: #1d4ed8; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* In a roomy container: one row per event, booking link becomes a button. */
@container events (width >= 420px) {
  .events { padding: 18px; }
  .events__list { gap: 0; }
  .event { grid-template-columns: 56px 1fr auto; grid-template-areas: "date name book" "date meta book"; align-items: center; gap: 0 14px; padding: 10px 0; border-top: 1px solid #e4e7eb; }
  .event__book { justify-self: end; padding: 6px 14px; border-radius: 999px; background: #1d4ed8; color: #fff; font-size: 14px; text-decoration: none; white-space: nowrap; }
  .event__book:hover { background: #1e3a8a; }
  .event__seats { display: inline; }
  .event__seats::before { content: " · "; }
}

Fluid typography with cqi, cqb and cqmin units

Container query length units size things relative to the nearest query container: 1cqi is 1% of its inline size (usually width), 1cqb is 1% of its block size, and cqmin and cqmax pick the smaller or larger of the two. cqw and cqh also exist for the physical axes. The headline here uses font-size: clamp(1.25rem, 0.75rem + 4.5cqi, 3rem), so each promo scales its own heading to its own width.

cqb and cqmin only measure a real height when the container has container-type: size, which needs a height from somewhere. The round stamp uses container-type: size with aspect-ratio: 1, so 30cqmin always fits the circle. With only an inline-size container the block axis has no size container, and the unit falls back to the small viewport size for that axis.

Keep a rem term in the calculation. Text sized purely in cqi ignores the reader's default font size and barely changes when they zoom, because the container shrinks in CSS pixels as the page is zoomed. The clamp() floor and the rem offset keep text resizable, which WCAG 1.4.4 asks for.

For older browsers, declare a plain value first and the cq version second, as in font-size: 1.6rem; font-size: clamp(...). A browser that can't parse the second declaration drops it and keeps the first.

Open the demo in a new tab
Show the code
HTML
<section class="promo" aria-labelledby="p1">
  <div class="promo__inner">
    <p class="promo__eyebrow">Saturday class · 9:00–13:00</p>
    <h2 class="promo__title" id="p1">Bake a country sourdough loaf</h2>
    <p class="promo__text">Mix, shape and score your own dough, then take home a jar of our 11-year-old starter.</p>
  </div>
</section>
CSS
/* An inline-size container: cqi = 1% of its width. */
.promo { container-type: inline-size; }

.promo__inner {
  /* Fallback values first, then the cqi versions for browsers that support them. */
  padding: 1.25rem;
  padding: clamp(1rem, 5cqi, 2.25rem);
  border-radius: 18px;
  background: #fff;
  border: 1px solid #ecdcc8;
}
.promo__eyebrow { margin: 0 0 .4em; font-size: .8125rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #9a3412; }
.promo__title {
  margin: 0 0 .35em;
  line-height: 1.1;
  font-size: 1.6rem;
  /* rem keeps the text responsive to the user's font size; cqi scales it with the box. */
  font-size: clamp(1.25rem, 0.75rem + 4.5cqi, 3rem);
}
.promo__text { margin: 0; max-width: 46ch; font-size: 1rem; font-size: clamp(.9375rem, .85rem + .6cqi, 1.0625rem); }

/* A size container (width AND height known) unlocks cqb and cqmin. */
.stamp { container-type: size; aspect-ratio: 1; }
.stamp__inner { margin: 0; display: grid; place-content: center; height: 100%; padding: 8%; border-radius: 50%; background: #9a3412; color: #fff; text-align: center; }
.stamp__big { display: block; font-weight: 800; line-height: 1; font-size: 2.5rem; font-size: 30cqmin; }
.stamp__small { display: block; margin-top: .3em; font-size: .875rem; font-size: clamp(.75rem, 8cqmin, 1.25rem); line-height: 1.2; }

A navigation bar that collapses by its container

Both navigation bars sit in the same viewport, so a media query would give them the same layout. The container query looks at each .nav-shell instead: below 560px the links fold into a Menu button, above it they sit in a row. The full-width bar collapses when you narrow the window; the copy in the 300px panel is always collapsed.

The query is written desktop first, as @container site-nav (width < 560px), on purpose. The base styles are a wrapping row of links, which is perfectly usable at any width. Only browsers that support container queries hide the links behind the button, so nobody ends up with a Menu button that can't work.

The script is a few lines that flip aria-expanded on the button and close the menu on Escape. CSS does the rest with .site-nav__toggle[aria-expanded="true"] + .site-nav__links. Hidden links use display: none, so they leave the tab order and the accessibility tree while the menu is closed.

Open the demo in a new tab
Show the code
HTML
<div class="nav-shell">
  <nav class="site-nav" aria-label="Shop">
    <a class="site-nav__brand" href="#home">Northwind Books</a>
    <button class="site-nav__toggle" type="button" aria-expanded="false" aria-controls="nav-links-1">
      <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
      Menu
    </button>
    <ul class="site-nav__links" id="nav-links-1">
      <li><a href="#fiction" aria-current="page">Fiction</a></li>
      <li><a href="#non-fiction">Non-fiction</a></li>
      <li><a href="#kids">Kids</a></li>
      <li><a href="#events">Events</a></li>
      <li><a href="#gift-cards">Gift cards</a></li>
    </ul>
  </nav>
</div>
CSS
/* The wrapper is measured; the nav inside responds. */
.nav-shell { container: site-nav / inline-size; }

.site-nav { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; padding: 10px 14px; border-radius: 12px; background: #12372a; color: #fff; }
.site-nav__brand { margin-right: auto; color: #fff; font-weight: 700; text-decoration: none; }
.site-nav__toggle { display: none; align-items: center; gap: 8px; padding: 6px 12px; border: 1px solid #a7d7c5; border-radius: 8px; background: transparent; color: #fff; font: inherit; font-size: 14px; cursor: pointer; }
.site-nav__toggle svg { width: 16px; height: 16px; }
.site-nav__links { display: flex; flex-wrap: wrap; gap: 4px; margin: 0; padding: 0; list-style: none; }
.site-nav__links a { display: block; padding: 6px 10px; border-radius: 8px; color: #e6f4ee; font-size: 15px; text-decoration: none; }
.site-nav__links a:hover { background: rgb(255 255 255 / .12); }
.site-nav__links a[aria-current="page"] { background: #a7d7c5; color: #12372a; font-weight: 600; }
.site-nav :focus-visible { outline-color: #fde68a; }

/* Narrow container: show the Menu button, collapse the links into a column. */
@container site-nav (width < 560px) {
  .site-nav__toggle { display: inline-flex; }
  .site-nav__links { display: none; flex-basis: 100%; flex-direction: column; padding-top: 6px; border-top: 1px solid rgb(255 255 255 / .2); }
  .site-nav__toggle[aria-expanded="true"] + .site-nav__links { display: flex; }
}
JavaScript
document.querySelectorAll('.site-nav__toggle').forEach((button) => {
  button.addEventListener('click', () => {
    const open = button.getAttribute('aria-expanded') === 'true';
    button.setAttribute('aria-expanded', String(!open));
  });
  // Escape closes an open menu and returns focus to its button.
  button.parentElement.addEventListener('keydown', (event) => {
    if (event.key === 'Escape' && button.getAttribute('aria-expanded') === 'true') {
      button.setAttribute('aria-expanded', 'false');
      button.focus();
    }
  });
});

A dashboard grid where every widget adapts

Containers can nest. The dashboard wrapper is a container named dash, and its grid goes from one column to two to four based on the dashboard's width, not the screen's. Each widget is also a container named widget, so a KPI tile puts its sparkline beside the number only when the tile itself is at least 260px wide.

That is why the revenue tile, which spans two columns, looks different from the trials tile next to it. The invoices table drops its date column under 380px and the channel chart turns its rows into stacked bars under 300px, each deciding on its own.

Grid columns use minmax(0, 1fr) rather than 1fr. A plain 1fr track has an automatic minimum, and long content inside it can push the track wider than intended.

Without container queries the grid would be a single column, so the demo adds a fallback inside @supports not (container-type: inline-size): an auto-fit grid with minmax(min(100%, 260px), 1fr) tracks that still gives several columns on wide screens.

Open the demo in a new tab
Show the code
HTML
<div class="dash-shell">
  <div class="dash">
    <section class="widget w-wide" aria-labelledby="k1">
      <h2 class="widget__title" id="k1">Monthly recurring revenue</h2>
      <div class="kpi">
        <p class="kpi__value">$48,920</p>
        <p class="kpi__delta is-up">+6.4% vs Sep</p>
        <svg class="kpi__spark" viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true"><polyline points="0,24 12,22 24,23 36,18 48,19 60,14 72,15 84,9 100,5"/></svg>
      </div>
    </section>
    <section class="widget" aria-labelledby="k2">
      <h2 class="widget__title" id="k2">Active trials</h2>
      <div class="kpi">
        <p class="kpi__value">312</p>
        <p class="kpi__delta is-down">−2.1% vs Sep</p>
        <svg class="kpi__spark" viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true"><polyline points="0,10 14,8 28,12 42,11 56,15 70,13 84,17 100,16"/></svg>
      </div>
    </section>
    <section class="widget" aria-labelledby="k3">
      <h2 class="widget__title" id="k3">Monthly churn</h2>
      <div class="kpi">
        <p class="kpi__value">1.8%</p>
        <p class="kpi__delta is-up">−0.3 pts, improving</p>
        <svg class="kpi__spark" viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true"><polyline points="0,8 14,10 28,9 42,13 56,14 70,17 84,19 100,21"/></svg>
      </div>
    </section>
    <section class="widget w-wide w-3" aria-labelledby="t1">
      <h2 class="widget__title" id="t1">Latest invoices</h2>
      <table class="invoices" aria-labelledby="t1">
        <thead><tr><th scope="col">Customer</th><th scope="col" class="col-date">Issued</th><th scope="col" class="num">Amount</th><th scope="col">Status</th></tr></thead>
        <tbody>
          <tr><td>Fernhill Dental</td><td class="col-date">14 Oct</td><td class="num">$1,240</td><td><span class="status paid">Paid</span></td></tr>
          <tr><td>Orbit Coffee Co.</td><td class="col-date">12 Oct</td><td class="num">$389</td><td><span class="status due">Due</span></td></tr>
          <tr><td>Haskell & Rowe</td><td class="col-date">2 Oct</td><td class="num">$2,075</td><td><span class="status late">Overdue</span></td></tr>
        </tbody>
      </table>
    </section>
    <section class="widget" aria-labelledby="b1">
      <h2 class="widget__title" id="b1">Signups by channel</h2>
      <ul class="bars">
        <li class="bar"><span>Organic</span><span class="bar__value">42%</span><span class="bar__track" aria-hidden="true"><span class="bar__fill" style="--v: 42%"></span></span></li>
        <li class="bar"><span>Referral</span><span class="bar__value">27%</span><span class="bar__track" aria-hidden="true"><span class="bar__fill" style="--v: 27%"></span></span></li>
        <li class="bar"><span>Paid</span><span class="bar__value">18%</span><span class="bar__track" aria-hidden="true"><span class="bar__fill" style="--v: 18%"></span></span></li>
        <li class="bar"><span>Partners</span><span class="bar__value">13%</span><span class="bar__track" aria-hidden="true"><span class="bar__fill" style="--v: 13%"></span></span></li>
      </ul>
    </section>
  </div>
</div>
CSS
/* Level 1: the dashboard is a container, so its grid responds to the space it gets. */
.dash-shell { container: dash / inline-size; }
.dash { display: grid; gap: 12px; }
@container dash (width >= 420px) { .dash { grid-template-columns: repeat(2, minmax(0, 1fr)); } .w-wide { grid-column: span 2; } }
@container dash (width >= 620px) { .dash { grid-template-columns: repeat(4, minmax(0, 1fr)); } .w-3 { grid-column: span 3; } }
/* Fallback: without container queries, an auto-fit grid still gives several columns. */
@supports not (container-type: inline-size) {
  .dash { grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); }
}

/* Level 2: every widget is a container too. */
.widget { container: widget / inline-size; padding: 14px; border-radius: 14px; background: #fff; box-shadow: 0 1px 2px rgb(31 41 51 / .08); }
.widget__title { margin: 0 0 6px; font-size: 13px; font-weight: 600; color: #3e4c59; }

/* KPI tile: sparkline under the number when narrow, beside it when wide. */
.kpi { display: grid; gap: 6px; }
.kpi__value { margin: 0; font-size: 1.5rem; font-weight: 700; line-height: 1.1; }
.kpi__delta { margin: 0; font-size: 13px; font-weight: 600; }
.kpi__delta.is-up { color: #166534; }
.kpi__delta.is-down { color: #b42318; }
.kpi__spark { width: 100%; height: 36px; }
.kpi__spark polyline { fill: none; stroke: #2563eb; stroke-width: 2; vector-effect: non-scaling-stroke; }
@container widget (width >= 260px) {
  .kpi { grid-template-columns: auto 1fr; grid-template-rows: auto auto; align-items: end; column-gap: 16px; }
  .kpi__value { font-size: 2rem; }
  .kpi__spark { grid-column: 2; grid-row: 1 / span 2; height: 56px; }
}

/* Bar list: label above bar when narrow, a three-column row when wide. */
.bars { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; font-size: 13px; }
.bar { display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; }
.bar__track { grid-column: 1 / -1; height: 8px; border-radius: 4px; background: #e4e7eb; }
.bar__fill { display: block; height: 100%; width: var(--v); border-radius: inherit; background: #2563eb; }
.bar__value { font-weight: 600; }
@container widget (width >= 300px) {
  .bar { grid-template-columns: 90px 1fr 40px; align-items: center; }
  .bar__track { grid-column: 2; grid-row: 1; }
  .bar__value { grid-column: 3; grid-row: 1; text-align: right; }
}

/* Table: drop the date column and shrink the status when the cell is narrow. */
.invoices { width: 100%; border-collapse: collapse; font-size: 13px; }
.invoices th { text-align: left; font-weight: 600; color: #3e4c59; padding: 0 6px 6px 0; }
.invoices td { padding: 6px 6px 6px 0; border-top: 1px solid #e4e7eb; }
.invoices :is(th, td) + :is(th, td) { padding-left: 10px; }
.invoices .num { text-align: right; font-variant-numeric: tabular-nums; }
.invoices .col-date { display: none; }
.status { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.status.paid { background: #dcfce7; color: #14532d; }
.status.due { background: #fef3c7; color: #713f12; }
.status.late { background: #fee2e2; color: #7f1d1d; }
@container widget (width >= 380px) {
  .invoices .col-date { display: table-cell; }
  .invoices { font-size: 14px; }
}

Named containers and nesting

An unnamed @container rule matches against the nearest ancestor that is a query container. Once containers nest, that is often not the one you mean. Naming fixes it: container: layout / inline-size on the outer wrapper and container: product / inline-size on each card slot, then @container layout (...) or @container product (...) in your rules.

Use the slider to change the outer layout's width. The layout query decides whether there are two columns, the product query decides each card's layout, and a third rule inside the card asks the outer layout container by name to make the button full width on narrow layouts. The labels on each card show which query is currently true.

Names are plain identifiers, and an element can carry several, as in container-name: product card. Pick names for the role (layout, product, sidebar) rather than for a size, since the same container will be measured at many sizes.

Recent browsers (Chrome 148, Firefox 149 and Safari 26.4) also accept a name with no condition, such as @container sidebar { ... }, which matches whenever an ancestor container has that name. Treat it as an enhancement for now.

Open the demo in a new tab
Show the code
HTML
<div class="layout" id="layout">
  <div class="layout__grid">
    <div class="product-slot">
      <article class="product">
        <svg class="product__art" viewBox="0 0 120 90" role="img" aria-label="Cream stoneware carafe with a dripper on top"><rect width="120" height="90" fill="#efe6dc"/><path d="M46 22h28l-6 14H52z" fill="#7c2d12"/><path d="M48 38h24c6 10 8 22 6 36H42c-2-14 0-26 6-36z" fill="#f8f4ee" stroke="#b9a99a" stroke-width="2"/><rect x="40" y="74" width="40" height="4" rx="2" fill="#b9a99a"/></svg>
        <div>
          <div class="readout" aria-hidden="true"><span class="readout__layout"></span><span class="readout__product"></span></div>
          <h2 class="product__name">Stoneware pour-over set</h2>
          <p class="product__meta">Hand-thrown in Stoke-on-Trent · 600 ml carafe</p>
          <p class="product__price">£58</p>
          <button class="product__cta" type="button">Add to basket</button>
        </div>
      </article>
    </div>
    <aside class="product-slot" aria-label="Goes well with">
      <article class="product">
        <svg class="product__art" viewBox="0 0 120 90" role="img" aria-label="Folded striped linen tea towel"><rect width="120" height="90" fill="#e7ece4"/><rect x="30" y="24" width="60" height="44" rx="3" fill="#f8f6f0" stroke="#8a9a80" stroke-width="2"/><path d="M30 34h60M30 40h60M30 56h60" stroke="#4d6b45" stroke-width="2"/></svg>
        <div>
          <div class="readout" aria-hidden="true"><span class="readout__layout"></span><span class="readout__product"></span></div>
          <h2 class="product__name">Linen tea towel</h2>
          <p class="product__meta">Stonewashed, 50 × 70 cm</p>
          <p class="product__price">£14</p>
          <button class="product__cta" type="button">Add to basket</button>
        </div>
      </article>
    </aside>
  </div>
</div>
CSS
/* Two named containers, one inside the other. */
.layout { container: layout / inline-size; }
.product-slot { container: product / inline-size; }

.layout__grid { display: grid; gap: 14px; }
.product { display: grid; gap: 10px; padding: 12px; border-radius: 14px; background: #fff; box-shadow: 0 1px 2px rgb(36 32 28 / .1); }
.product__art { display: block; width: 100%; height: auto; aspect-ratio: 16 / 9; border-radius: 10px; background: #efe6dc; }
.product__name { margin: 0; font-size: 1rem; line-height: 1.3; }
.product__meta { margin: 2px 0 8px; font-size: 13px; color: #554a40; }
.product__price { margin: 0 0 8px; font-weight: 700; }
.product__cta { padding: 7px 14px; border: 0; border-radius: 999px; background: #7c2d12; color: #fff; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; }
.product__cta:hover { background: #5b210d; }

/* The page-level container decides the column layout... */
@container layout (width >= 560px) {
  .layout__grid { grid-template-columns: minmax(0, 1fr) 190px; align-items: start; }
}
/* ...the nearest named container decides the card layout... */
@container product (width >= 300px) {
  .product { grid-template-columns: 110px 1fr; align-items: start; }
  .product__art { aspect-ratio: 4 / 3; }
}
/* ...and a rule deep inside the card can still ask the OUTER container by name. */
@container layout (width < 560px) {
  .product__cta { width: 100%; }
}
JavaScript
// Demo control only: lets you change the outer container's width with the keyboard or a pointer.
const range = document.getElementById('layout-width');
const out = document.getElementById('layout-width-out');
const layout = document.getElementById('layout');
range.addEventListener('input', () => {
  layout.style.width = range.value + '%';
  out.textContent = range.value + '%';
});

Style queries for theming, with a fallback

Style queries test the computed value of a custom property on a container, as in @container style(--theme: dusk) { .panel { ... } }. Every element is a style container by default, so no container-type is needed, and an unnamed query reads the property from the element's parent.

In this demo a radio group sets --theme on a wrapper through :has(), and the panel swaps its colour tokens in response. Each callout sets --status in an inline style, and its inner element picks colours from that value. The component never needs to know which class, attribute or script set the property.

Support is newer than size queries: Chrome and Edge 111, Safari 18 and Firefox 151, which shipped on 19 May 2026. Only custom properties can be queried; style(display: flex) and other regular properties do not work in any browser. An element cannot respond to a custom property it sets on itself, so put the property on a parent. Safari also cannot use the root element as a container, so set theme properties on a wrapper rather than relying on :root.

@supports can't detect style queries, so the demo detects them in CSS: body sets --sq-probe: yes, the note is visible by default, and @container style(--sq-probe: yes) hides it. The theme picker uses the same trick in reverse and stays hidden where it would do nothing, while callouts keep their New, Fixed and Heads up labels in text.

Open the demo in a new tab
Show the code
HTML
<div class="theme-scope">
  <fieldset class="theme-picker">
    <legend>Theme</legend>
    <label><input type="radio" name="theme" value="paper" checked> Paper</label>
    <label><input type="radio" name="theme" value="dusk"> Dusk</label>
    <label><input type="radio" name="theme" value="forest"> Forest</label>
  </fieldset>
  <section class="panel" aria-labelledby="rn-title">
    <div class="panel__head">
      <h2 id="rn-title">Ledgerly 4.2 release notes</h2>
      <p class="panel__date">Shipped 14 October</p>
    </div>
    <p class="panel__lead">Three things worth knowing before you update your team.</p>
    <div class="callout" style="--status: info">
      <div class="callout__inner">
        <span class="callout__icon" aria-hidden="true"><svg viewBox="0 0 16 16"><path d="M7 7h2v6H7zM7 3h2v2H7z"/></svg></span>
        <p><strong>New:</strong> invoices can now be drafted offline and sync when you reconnect.</p>
      </div>
    </div>
    <div class="callout" style="--status: success">
      <div class="callout__inner">
        <span class="callout__icon" aria-hidden="true"><svg viewBox="0 0 16 16"><path d="M6.5 11.5 3 8l1.4-1.4 2.1 2.1 5-5L13 5.1z"/></svg></span>
        <p><strong>Fixed:</strong> CSV exports no longer drop the currency column for multi-currency accounts.</p>
      </div>
    </div>
    <div class="callout" style="--status: warning">
      <div class="callout__inner">
        <span class="callout__icon" aria-hidden="true"><svg viewBox="0 0 16 16"><path d="M7 3h2v6H7zM7 11h2v2H7z"/></svg></span>
        <p><strong>Heads up:</strong> legacy API keys stop working on 1 December. Rotate them in Settings › Developers.</p>
      </div>
    </div>
  </section>
</div>
CSS
/* 1. Something sets a custom property on an ancestor: a class, an inline style, a CMS field,
      or here a radio button via :has(). Do not rely on :root — Safari can't query the root element. */
.theme-scope { --theme: paper; }
.theme-scope:has(input[value="dusk"]:checked) { --theme: dusk; }
.theme-scope:has(input[value="forest"]:checked) { --theme: forest; }

/* 2. Components read only tokens. Defaults = the "paper" theme and the fallback. */
.panel {
  --bg: #fff; --fg: #1f2933; --muted: #3e4c59; --border: #d9dee5;
  --info-bg: #e0ecff; --info-fg: #1e3a8a; --ok-bg: #dcfce7; --ok-fg: #14532d; --warn-bg: #fef3c7; --warn-fg: #713f12;
  padding: 16px; border: 1px solid var(--border); border-radius: 16px; background: var(--bg); color: var(--fg);
}

/* 3. Style queries swap the tokens. They test the custom property on the parent element. */
@container style(--theme: dusk) {
  .panel {
    --bg: #1e1b3a; --fg: #eceaff; --muted: #c9c4f2; --border: #3a3566;
    --info-bg: #2e2a5a; --info-fg: #dcd6ff; --ok-bg: #173b2d; --ok-fg: #bbf7d0; --warn-bg: #45320f; --warn-fg: #fde68a;
  }
}
@container style(--theme: forest) {
  .panel {
    --bg: #eef4ea; --fg: #1c2b1e; --muted: #3b4d3d; --border: #c4d4bd;
    --info-bg: #dbe8f2; --info-fg: #173a55; --ok-bg: #cfe8c6; --ok-fg: #1f4d22; --warn-bg: #f3e3c0; --warn-fg: #5b3a0b;
  }
}

/* 4. Per-item variants: each callout sets --status inline; its child queries it. */
.callout { margin-top: 10px; }
.callout__inner { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border-radius: 10px; border-left: 4px solid currentColor; background: color-mix(in srgb, var(--fg) 6%, var(--bg)); }
.callout__inner p { margin: 0; font-size: 14px; }
.callout__icon { flex: none; display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; background: currentColor; }
.callout__icon svg { width: 14px; height: 14px; fill: var(--bg); }
@container style(--status: info)    { .callout__inner { background: var(--info-bg); color: var(--info-fg); } }
@container style(--status: success) { .callout__inner { background: var(--ok-bg);   color: var(--ok-fg); } }
@container style(--status: warning) { .callout__inner { background: var(--warn-bg); color: var(--warn-fg); } }

/* The picker is only useful where style queries work, so it starts hidden. */
.theme-picker { display: none; }
@container style(--theme: paper) or style(--theme: dusk) or style(--theme: forest) {
  .theme-picker { display: flex; }
}

@media (prefers-reduced-motion: no-preference) {
  .panel, .callout__inner { transition: background-color .25s, color .25s; }
}

Container query vs media query, side by side

Panel A uses @container (width >= 440px); panel B uses @media (width >= 600px) with the same rules. Drag the corner of either panel, or use the slider. Panel A switches when its own box gets wide enough. Panel B switches when the browser window does, so in a narrow panel on a wide screen it tries the wide layout anyway and the text gets squeezed.

The panels use resize: horizontal with overflow: auto. Resize handles can only be dragged with a pointer, so the demo adds a range input that sets the same width. Any interface that relies on resize should offer a keyboard path like this.

Media queries are still the right tool for page-level decisions such as the number of top-level columns, and for user preferences such as prefers-reduced-motion, prefers-color-scheme and print. Container queries are for components that get placed in more than one context.

Open the demo in a new tab
Show the code
HTML
<div class="bio-scope">
  <article class="bio bio--cq">
    <span class="bio__avatar" aria-hidden="true">AO</span>
    <div>
      <h2 class="bio__name">Amara Okafor</h2>
      <p class="bio__role">Staff photographer, Lagos</p>
    </div>
    <p class="bio__text">Covers West African architecture and the people who restore it. 214 stories since 2019.</p>
    <button class="bio__follow" type="button">Follow<span class="visually-hidden"> Amara Okafor</span></button>
  </article>
</div>
CSS
/* Same component, same breakpoint idea, two different questions. */
.bio { display: grid; grid-template-columns: 48px 1fr; gap: 4px 12px; align-items: center; padding: 12px; border-radius: 12px; background: #fff; box-shadow: 0 1px 2px rgb(31 41 51 / .1); }
.bio__avatar { display: grid; place-items: center; width: 48px; height: 48px; border-radius: 50%; background: #0f766e; color: #fff; font-weight: 700; }
.bio__name { margin: 0; font-size: 1rem; line-height: 1.25; }
.bio__role { margin: 0; font-size: 13px; color: #3e4c59; }
.bio__text { grid-column: 1 / -1; margin: 4px 0 6px; font-size: 14px; }
.bio__follow { grid-column: 1 / -1; justify-self: start; padding: 6px 16px; border: 2px solid #0f766e; border-radius: 999px; background: #fff; color: #0f5e57; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; }

.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* A: asks "how wide is my container?" */
.bio-scope { container-type: inline-size; }
@container (width >= 440px) {
  .bio--cq { grid-template-columns: 56px 1fr auto; }
  .bio--cq .bio__avatar { grid-row: span 2; width: 56px; height: 56px; }
  .bio--cq .bio__text { grid-column: 2 / 3; grid-row: 2; margin: 0; }
  .bio--cq .bio__follow { grid-column: 3; grid-row: 1 / span 2; }
}

/* B: asks "how wide is the viewport?" and ignores the box it sits in. */
@media (width >= 600px) {
  .bio--mq { grid-template-columns: 56px 1fr auto; }
  .bio--mq .bio__avatar { grid-row: span 2; width: 56px; height: 56px; }
  .bio--mq .bio__text { grid-column: 2 / 3; grid-row: 2; margin: 0; }
  .bio--mq .bio__follow { grid-column: 3; grid-row: 1 / span 2; }
}
JavaScript
// Demo controls: a range input mirrors the drag handle, and each panel shows its width.
const range = document.getElementById('pane-width');
const out = document.getElementById('pane-width-out');
const panes = document.querySelectorAll('.pane');
range.addEventListener('input', () => {
  panes.forEach((pane) => { pane.style.width = range.value + '%'; });
  out.textContent = range.value + '%';
});
if ('ResizeObserver' in window) {
  // Write in the next frame so the label update can't re-trigger the observer in the same frame.
  const ro = new ResizeObserver((entries) => {
    requestAnimationFrame(() => {
      for (const entry of entries) {
        entry.target.querySelector('.pane__size').textContent = Math.round(entry.target.getBoundingClientRect().width) + 'px';
      }
    });
  });
  panes.forEach((pane) => ro.observe(pane));
}

Side effects of container-type: inline-size

container-type: inline-size applies style containment and inline-size containment to the element. Inline-size containment means the element's width is worked out without looking at its contents, which is what stops a query from changing the layout that the query depends on.

The practical effect: a container must get its width from outside. Block-level elements in normal flow are fine because they stretch to their parent. In shrink-to-fit situations the width collapses to zero. We checked this in Chromium, Firefox and WebKit: an inline-block, an auto-width flex item and an auto grid column with container-type: inline-size all measured 0px wide. Give such containers an explicit width, a flex-basis, or a fixed or fr grid track.

Height is not affected by inline-size: it still comes from the content. container-type: size contains both axes, so the element needs a height from somewhere, such as height, aspect-ratio or a stretching grid row, or it collapses vertically.

Style containment scopes counters: a counter-increment inside the container does not change the counter outside it. In our test, an outer counter read 1 instead of 3 when two of its increments sat inside a container. Positioned descendants are not affected, because a container does not become the containing block for position: absolute children unless you also give it position: relative.

The simplest habit is to put container-type on a plain wrapper element whose only job is to be measured, and leave the component's own box free.

Fallbacks and progressive enhancement

Size queries are Baseline Widely available, so the main audience for fallbacks is older devices that no longer get browser updates: Safari before 16, Chrome before 105 and Firefox before 110. A browser that does not understand @container ignores the whole block, so the pattern is to write a complete base layout first and enhance it inside @container.

For cq units, declare a plain value before the cq value so unsupporting browsers keep the first one. To change other styles when container queries are missing, use @supports not (container-type: inline-size), as the dashboard demo does with an auto-fit grid. @supports (container-type: inline-size) detects size queries and cq units; it does not tell you anything about style queries.

Style queries need their own plan, because Firefox only added them in version 151 and Safari in 18. Use them for enhancements such as themes and variants, keep the meaning in text, and detect them with a probe custom property and a style query that hides a fallback note, as in demo seven.

Accessibility notes

Container queries change presentation, not the document. Keep the source order logical, because a screen reader and the Tab key follow the HTML whatever the grid does. Reordering with grid areas is fine for small adjustments like moving an image beside the text.

Be careful about removing content at small sizes. Use display: none only for content that has an equivalent elsewhere, such as links behind a Menu button, and give any visual-only labels, like the layout readouts in these demos, aria-hidden="true".

Components that adapt to their container help with WCAG 1.4.10 Reflow, because a card in a narrow column at 400% zoom gets the same layout as one on a phone. Pair cq units with rem so text still follows the user's font size, and offer a keyboard alternative wherever the interface depends on dragging, such as resize handles.

Frequently asked questions

What is the difference between container queries and media queries?

A media query tests the viewport or device, such as the window width or the user's colour scheme. A container query tests an element's ancestor container, usually its width. Use media queries for page-level layout and user preferences, and container queries for components that appear in more than one context.

Do CSS container queries work in all browsers?

Size queries and the cqi, cqb, cqw, cqh, cqmin and cqmax units work in Chrome and Edge 105, Firefox 110 and Safari 16 and later, and have been Baseline Widely available since August 2025. Style queries for custom properties need Chrome or Edge 111, Safari 18 or Firefox 151.

Why does my container collapse to zero width?

container-type: inline-size makes the element ignore its contents when working out its width. In contexts that size elements to their content, such as inline-block, floats, auto-width flex items and auto grid columns, there is nothing left to measure, so the width becomes 0. Give the container an explicit width, flex-basis or grid track, or put container-type on a block-level wrapper.

Can an element query its own size?

No. @container rules match against an ancestor container, so they can only style the container's descendants. Put container-type on a wrapper and style the component inside it. The same applies to style queries: an element cannot react to a custom property it sets on itself.

What can container style queries test?

In current browsers, only custom properties, as in @container style(--theme: dark). Queries on regular properties such as style(display: grid) are in the specification but not implemented anywhere. Because Firefox added style queries in version 151, use them for enhancements and keep a sensible default.

More from the Modern CSS lab