All WordPress HTML Templates Forms & Webhooks AI & Tools
HTML Templates

Container Queries Are Ready: Rebuilding Components That Adapt

Container queries in practice: real css container query syntax, the containment gotchas that break dropdowns, and how to make component responsive CSS

A client site we audited last year had a product card that appeared in four places: the main grid, a narrow sidebar, a full-width cross-sell row, and inside a modal. The CSS for it ran to 240 lines, of which roughly 90 were media query overrides keyed to page-level breakpoints. The sidebar version broke at 1024px because that’s where the sidebar got narrower, but the viewport got wider. Classic.

Container queries fix that, and they have been shipping in every major browser since Firefox 110 landed support in February 2023. Three years of availability. If you’re still writing component layout against viewport width, you’re solving a harder problem than you need to.

This is the part introductory posts skip: the migration is not free, containment has side effects that will break your dropdowns, and there are patterns where a media query is still the correct tool. Here’s how we actually do it.

Key Takeaways

  • container-type: inline-size applies contain: layout style inline-size, which creates a stacking context and a containing block for absolutely positioned children. That breaks escaping dropdowns and position: fixed descendants more often than people expect.
  • An element cannot query itself. Every container query component needs a wrapper, which means your markup changes, not just your CSS.
  • Container query units (cqi, cqb, cqmin) paired with clamp() give you typography that scales to the component rather than the window. This is the highest value, lowest risk place to start.
  • Name every container. Anonymous nearest-ancestor resolution works until someone adds a wrapper, then your card silently starts querying the wrong box.
  • Style queries for custom properties are usable now (Chrome 111, Firefox 128, Safari 18); scroll-state() queries are Chromium-only as of Chrome 133 and should be treated as enhancement.

Media queries answer the wrong question

A media query asks how wide the window is. Your component does not care. What a card needs to know is how much room it has been given, and on any layout more complex than a single column those two numbers diverge constantly.

The symptom is always the same. You build a component, it works in the context you built it for, then someone drops it in a sidebar or a 3-up grid and the image goes square, the heading wraps badly, or the button overflows. You add .sidebar .card { ... }. Six months later there are five such context selectors, each with its own breakpoint stack, and nobody can change the card without checking four pages.

Context-coupled CSS is the actual bug. Container queries let the component own its own responsive logic, which is what component architecture promised in 2015 and CSS could not deliver until 2022.

A real working moment that fits an article section about

The css container query syntax, minus the tutorial padding

You need two things: an element declared as a container, and a query that targets it. The query applies to descendants of the container, never the container itself.

/ The wrapper is the container. The card inside it is what responds. /
.card-shell {
  container: card / inline-size; / shorthand for container-name + container-type /
}

.card {
  display: grid;
  gap: 1rem;
}

@container card (width > 28rem) {
  .card {
    grid-template-columns: 12rem 1fr; / media object layout once there is room /
    align-items: start;
  }
  .card__excerpt { display: block; } / hidden on narrow by default /
}

/ Range syntax works and is clearer than two conditions /
@container card (28rem <= width <= 48rem) {
  .card__title { font-size: 1.25rem; }
}

Three choices here are deliberate. First, inline-size rather than size: size containment requires the element’s block size to be independent of its contents too, which for a normal flow element means it collapses to zero height. You almost never want container-type: size. Second, the named container, which I’ll argue for below. Third, mobile-first defaults with the query adding complexity, because when containment is unsupported or the container query never matches, you want the narrow layout, not a broken wide one.

On naming: anonymous containers resolve to the nearest ancestor with a container-type. Fine in a demo, fragile in a real codebase. The moment a colleague wraps your grid item in another container for an unrelated reason, your card starts measuring a different box and nothing errors. Name them. We use the component name, singular: card, media, pricing-tier. Two seconds of work that eliminates a genuinely hard-to-diagnose class of bug.

Container query units are the easiest win

If you only adopt one thing, make it cqi. It is one percent of the container’s inline size, so typography and spacing scale to the component instead of the viewport.

.card-shell { container: card / inline-size; }

.card__title {
  / floor, container-relative scale, ceiling /
  font-size: clamp(1.125rem, 0.9rem + 2.5cqi, 1.75rem);
  line-height: 1.2;
}

.card { padding: clamp(1rem, 4cqi, 2rem); }

The same card in a 320px sidebar and a 760px feature slot now reads correctly in both without a single breakpoint. Compare that to vw-based fluid type, which gets the relationship backwards: a wide window with a narrow column produces oversized text in a cramped box.

One caveat that bites people. cqi resolves against the nearest size container, and if there isn’t one it falls back to the small viewport. A component using cqi that is dropped outside its wrapper will silently behave like vi. Test that path, because it looks almost right, which is worse than looking wrong.

The four gotchas that will cost you an afternoon

1. Containment creates a stacking context

Layout containment makes the element a stacking context and a containing block for absolutely and fixed positioned descendants. In practice: a dropdown menu inside your card that used position: absolute with a high z-index to escape and overlay the page will now be trapped inside the card’s stacking order. A position: fixed modal rendered inside a container will position itself relative to the container, not the viewport.

The fix is architectural, not CSS. Render overlays in a portal at the document root, or use the top layer with <dialog> and popover, which are immune because they escape the containment entirely. We now default to popover for component-level menus for exactly this reason.

2. Inline-size containment removes intrinsic sizing on the inline axis

The container’s inline size no longer depends on its contents. Put container-type: inline-size on a grid item with grid-template-columns: auto, or on a flex item relying on max-content, and it will collapse or stretch unexpectedly. If you need shrink-to-fit behaviour, the container element is the wrong element.

3. You cannot style the container from its own query

Worth repeating because it’s the single most common first-day mistake. @container card (width > 30rem) { .card-shell { ... } } does nothing. This is also why container queries change your HTML. Every adaptive component needs an extra wrapper element, and if you’re generating markup from a CMS or a block theme, that wrapper has to come from somewhere. In block themes you usually add it via a group block or by filtering the block wrapper, which is a theme.json and template-parts conversation rather than a stylesheet one.

4. No ancestor queries, no quantity queries

There is no way to ask “is my container narrow?” from outside, and no built-in container query for “how many children do I have”. The old :nth-last-child quantity query hacks still apply. And display: contents elements cannot be containers, which trips up people flattening wrappers for grid.

Rebuilding a real component

Here’s a product card we ship in roughly this shape. Three layouts, one component, zero context selectors.

<div class="card-shell">
  <article class="card">
    <a class="card__media" href="/p/clay-mug">
      <img src="/img/mug.webp" width="600" height="600" alt="Stoneware mug, speckled glaze">
    </a>
    <div class="card__body">
      <h3 class="card__title"><a href="/p/clay-mug">Stoneware Mug</a></h3>
      <p class="card__price"><strong>24.00</strong></p>
      <p class="card__excerpt">Hand-thrown, dishwasher safe, 350ml.</p>
      <button class="card__cta" type="button">Add to basket</button>
    </div>
  </article>
</div>
.card-shell { container: card / inline-size; }

/ Compact default: stacked, no excerpt, icon-free button /
.card { display: grid; gap: 0.75rem; }
.card__excerpt { display: none; }
.card__title { font-size: clamp(1rem, 0.85rem + 1.8cqi, 1.5rem); }

/ Mid: excerpt earns its place /
@container card (width > 22rem) {
  .card__excerpt { display: block; }
}

/ Wide: horizontal media object, CTA pinned right /
@container card (width > 36rem) {
  .card {
    grid-template-columns: minmax(10rem, 35%) 1fr;
    align-items: center;
  }
  .card__body {
    display: grid;
    grid-template-areas: "title price" "excerpt cta";
    column-gap: 1.5rem;
  }
  .card__title   { grid-area: title; }
  .card__price   { grid-area: price; justify-self: end; }
  .card__excerpt { grid-area: excerpt; }
  .card__cta     { grid-area: cta; justify-self: end; }
}

Note what is not here: no @media, no .sidebar .card, no JavaScript ResizeObserver. The component is portable in the strict sense. Drop it in a 4-column grid, a 280px rail, or a full-bleed row and it does the right thing. The decisions about which elements appear at which width are product decisions, and they belong next to the component, not in a page stylesheet. If you’re working out what should survive at the narrow end, our breakdown of which product page elements actually drive add-to-cart is the right order of priority.

Migrating a breakpoint-era codebase without a rewrite

You do not convert everything. We’ve done this on Bootstrap 5 projects and on Canvas-based builds, and the sequence that works is boring and incremental.

  1. Leave the page grid on media queries. Columns, sidebar collapse, nav switching to a drawer: these genuinely depend on viewport and often on device characteristics. Converting your grid system to container queries is a lot of risk for no benefit.
  2. Convert leaf components first. Cards, media objects, stat blocks, testimonial tiles, form rows. These are the ones with context selectors. Each one is an isolated change you can ship and revert.
  3. Delete the context selectors as you go. If .sidebar .card still exists after the conversion, the conversion isn’t finished.
  4. Hold the line on page-level sections. Heroes are the interesting edge case. A hero is usually full-bleed, so its container is the viewport anyway, and media queries are simpler. Where container queries do help is when the hero’s inner content block has a max-width and you want type scaling to track that, which is a point we made in the piece on heroes that do not waste the fold.

Tooling has caught up. Tailwind CSS v4 ships container query support in core, so @container and variants like @md:flex-row work without the old plugin. If you’re on a component library with a sensible grid underneath, such as the Bootstrap 5 foundation in Canvas, container queries layer on top cleanly because the grid stays where it is and only the component internals change.

For the small number of sites still supporting browsers without containment, gate the enhancement rather than duplicating the stylesheet:

@supports (container-type: inline-size) {
  .card-shell { container: card / inline-size; }
}

The unsupported path gets the compact layout everywhere. Acceptable, not broken. That’s the whole idea behind progressive enhancement applied to layout.

Style queries and scroll-state: what’s actually usable

Size is only one axis. Style queries let a component respond to a custom property set by an ancestor, replacing a pile of modifier classes:

.panel { --tone: calm; }
.panel--urgent { --tone: urgent; }

@container style(--tone: urgent) {
  .card__cta { background: var(--color-danger); }
  .card__price { font-weight: 700; }
}

Style queries do not require a container-type: every element is a style container by default. Support is Chrome 111, Safari 18 and Firefox 128 for custom property queries. Queries against arbitrary CSS properties are still not shipped anywhere, so plan around custom properties only.

container-type: scroll-state with @container scroll-state(stuck: top) is genuinely useful for sticky headers that need to grow a shadow or shrink when pinned, and it removes an IntersectionObserver from your bundle. It landed in Chrome 133 in early 2025 and is still Chromium-only at the time of writing. Treat it as pure enhancement: the non-supporting path should look fine, just less polished.

Frequently Asked Questions

Do container queries hurt performance?

No, and in some cases they help. Containment gives the browser permission to skip layout work outside the container, which is why contain exists in the first place. We’ve not measured a regression on real builds; what we have measured is smaller stylesheets, since one component rule set replaces several context-specific ones.

Should I replace all my media queries with container queries?

No. Media queries remain correct for anything genuinely tied to the viewport or the device: page grid structure, navigation mode, prefers-reduced-motion, prefers-color-scheme, print stylesheets. Use container queries for component internals and media queries for page architecture, and the boundary between them becomes obvious.

Why does my component query not fire at all?

Ninety percent of the time the rule is targeting the container element itself rather than a descendant. The other common causes are a missing container-type, display: contents on the intended container, or a named query pointing at a container name that doesn’t exist, which fails silently. Inspect the element in DevTools: Chrome and Safari both badge container elements and show which container a query resolves against.

Can I use container queries inside WordPress block themes?

Yes, but you need a reliable wrapper element to act as the container. Core blocks give you group and row wrappers you can tag with a class, and theme.json can carry the custom properties your style queries read. The friction is editor parity: the block editor canvas has different ancestor widths than the front end, so always verify in both.

How do I test component responsive behaviour properly?

Build a test page that renders the component at a range of fixed container widths side by side, for example 240px, 320px, 480px, 640px and 900px. Resizing the browser only tests the widths your page layout happens to produce. A fixed-width harness catches the dead zones where a query is just about to flip and the layout looks cramped.

Where to start on Monday

Pick the single component in your codebase with the most context-specific overrides. Usually it’s a card. Wrap it, name the container, move the overrides into @container rules, delete the descendant selectors, then ship it behind nothing at all because support has been universal for three years.

If that goes well, the second pass is typography: swap vw-based clamp() for cqi wherever text sits inside a bounded column. Small change, immediately visible, and it makes the case for the rest of the migration far better than any argument you can make in a planning meeting.

component responsive css without media queries container queries container query units cqi clamp container-type inline-size stacking context css container query syntax css style queries custom properties migrating media queries to container queries scroll-state container query sticky header