How to Use Container Queries in Production Without a Rewrite

The narrow problem

You are convinced by container queries and you have a codebase with four years of viewport breakpoints in it. The question is no longer whether the feature works — it does, everywhere you support — but how to introduce it into a live product without a stylesheet rewrite, a regression sweep, and a week of design QA. The failure mode here is not a query that never matches; that is a syntax issue covered elsewhere. It is a migration that half-lands: some components on container rules, some on viewport rules, a few on both fighting each other, and a @media block nobody dares delete because they cannot tell what still depends on it. This page is about sequencing that adoption safely, and it sits inside Container Query Syntax Basics, part of Mastering Container Queries & Responsive Layouts.

Approach rationale

The instinct is to migrate by replacement: find a @media rule, swap it for @container, move on. That is the one approach that reliably hurts, because the two rules are not equivalent and the swap changes behaviour at every breakpoint simultaneously across every page that uses the component. Whether a given rule should be viewport-driven or container-driven is a per-rule judgement, laid out in the guide to container versus media queries; the production question is what to do once you have made that judgement for a hundred rules you did not write.

The approach that works is additive and reversible. Ship the container rules on top of the existing viewport rules in a later cascade layer, so the old stylesheet remains the floor and the new rules are pure enhancement. Nothing regresses if a container rule is wrong — it is overriding a layout that already worked. Then remove the superseded @media block as a separate, separately reviewable change, once the component has been live long enough to trust. Two small diffs beat one large one, and the second is trivially revertable.

There is an accessibility dimension that argues for the same order. Container rules are frequently the ones that reflow content, and reflow interacts with WCAG 1.4.10, which requires content to remain usable at 320 CSS pixels of width and at 400% zoom. A container-driven component actually improves this, because zooming shrinks the container and the component adapts rather than clipping — but only if the unconditional baseline is itself usable. Keeping the baseline as a real layout rather than a stub is what makes the zoom case work, and it is the same thing that makes the migration safe.

Additive rollout with cascade layers Three stacked cascade layers where the existing viewport rules stay as the baseline and container query rules are added in a higher layer that can be removed without regression. Add a layer, do not replace a rule @layer reset untouched @layer components existing viewport rules stay this is the floor @layer enhancements new container rules, per component delete this layer to roll back later layers win, so no specificity fights

Complete working implementation

This is one component mid-migration, in the state you would actually merge. The components layer is the code that already shipped, including its viewport breakpoint. The enhancements layer is the new work: the slot wrapper becomes a query container and the card gains a container rule that supersedes the viewport one. Delete the second layer and the file still renders the old, correct design.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Container queries added as a layer</title>
<style>
  /* Declaring the order up front is what makes this safe: `enhancements`
     beats `components` no matter where the files end up in the bundle. */
  @layer reset, components, enhancements;

  @layer reset {
    *, *::before, *::after { box-sizing: border-box; }
    body { font: 16px/1.5 system-ui, sans-serif; margin: 0; padding: 1.5rem; }
  }

  @layer components {
    /* Shipped code, unchanged. The stacked layout is a real design, not a
       placeholder — it is what renders at 400% zoom and in the sidebar. */
    .media-card {
      display: grid;
      gap: 0.75rem;
      padding: 1rem;
      border: 1px solid #7aa2ff;
      border-radius: 12px;
    }
    .media-card__thumb {
      inline-size: 100%;
      /* Reserving the box BEFORE the image loads is what keeps the
         container's size stable, which is what prevents layout shift. */
      aspect-ratio: 16 / 9;
      background: #7aa2ff33;
      border-radius: 8px;
    }
    .media-card__body > * { margin: 0 0 0.4rem; }

    /* The legacy breakpoint. Still here, still the fallback for any slot
       that has not been given a container yet. Removed in a later PR. */
    @media (min-width: 48rem) {
      .media-card { grid-template-columns: 12rem 1fr; align-items: start; }
    }
  }

  @layer enhancements {
    /* Step 1: the layout that OWNS the slot opts in. Not the card itself —
       a component cannot query the box it is styling. */
    .slot { container: card-slot / inline-size; }

    /* Step 2: reset the viewport-driven layout back to the baseline so the
       two systems cannot both be half-applied at the same width. */
    .media-card { grid-template-columns: 1fr; }

    /* Step 3: the real rule. Named, so adding a container anywhere between
       .slot and .media-card later cannot silently steal the binding. */
    @container card-slot (min-width: 30rem) {
      .media-card { grid-template-columns: 12rem 1fr; align-items: start; }
    }
  }
</style>
</head>
<body>
  <div style="display:grid;grid-template-columns:16rem 1fr;gap:1.5rem">
    <aside class="slot">
      <article class="media-card">
        <div class="media-card__thumb"></div>
        <div class="media-card__body">
          <h2>Narrow slot</h2>
          <p>Below 30rem, so it keeps the stacked baseline.</p>
        </div>
      </article>
    </aside>
    <main class="slot">
      <article class="media-card">
        <div class="media-card__thumb"></div>
        <div class="media-card__body">
          <h2>Wide slot</h2>
          <p>Same component, same stylesheet, different container width.</p>
        </div>
      </article>
    </main>
  </div>
</body>
</html>

Key technique callout

Step 2 in that stylesheet is the one people leave out, and it is the one that causes the "works on my machine, broken on staging" bug. During a migration a component briefly has two systems telling it how to lay out: the old @media (min-width: 48rem) and the new @container card-slot (min-width: 30rem). Their thresholds refer to different boxes, so between roughly 48rem of viewport and 30rem of container the two disagree, and which one wins depends on layer order rather than on intent. Explicitly resetting the property back to its baseline value in the enhancement layer collapses that ambiguity: the viewport rule is neutralised for any slot that has become a container, and only the container rule can turn the two-column layout back on.

The other structural decision is where container-type lives. It goes on .slot — the layout element that decides how much room the component gets — never on .media-card. Putting it on the component root is the single most common rollout mistake, because the component then establishes a container it cannot itself query, every rule silently stops matching, and the change looks like a browser bug. Owning the wrapper in the layout also means a component can be dropped into a page that has not been migrated yet and will simply fall through to the viewport baseline.

Variation or extension

At design-system scale, hardcoding 30rem into every component recreates the maintenance problem that viewport breakpoints had. Thresholds are not custom-property-substitutable inside a query condition, but the layout consequences are, which gives you a workable token layer.

@layer enhancements {
  :root {
    --card-thumb-inline: 12rem;
    --card-gap: 0.75rem;
  }

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

  .media-card {
    grid-template-columns: 1fr;
    gap: var(--card-gap);
  }

  /* Threshold is literal; everything it sets comes from tokens, so a
     design change is one custom property, not a sweep through queries. */
  @container card-slot (min-width: 30rem) {
    .media-card {
      grid-template-columns: var(--card-thumb-inline) 1fr;
      --card-gap: 1.25rem;
    }
  }

  /* Density variants reuse the same query by re-pointing the tokens. */
  .slot--compact { --card-thumb-inline: 8rem; }
}

Keep the literal thresholds in one file with a comment explaining what each number means in content terms, and grep for them during review. Three or four container thresholds across a whole system is a healthy number; if a single component needs five, it is usually two components.

What to check before you ship

  1. Render the component in isolation at a handful of container widths — a resizable story or a resize: horizontal wrapper is enough. This is the test viewport-based QA cannot do.
  2. Zoom the browser to 200% and 400% and confirm nothing clips or requires horizontal scrolling.
  3. Disable the enhancements layer in DevTools and confirm the baseline is still a usable design.
  4. In Chromium DevTools, the Elements pane badges every query container and the Styles pane shows which @container rule matched and at what size — use it to confirm the rule bound to the container you intended.
  5. Watch Cumulative Layout Shift in a Lighthouse run before and after. Any regression is almost always a container whose size settles late, not the query itself; reserve the space with aspect-ratio or a min-block-size on media and skeletons.

Browser support note

Container queries have been available in every major engine since Chrome 105, Edge 105, Safari 16.0, and Firefox 110, so for a product on evergreen browsers this is a baseline feature and no polyfill is warranted — the JavaScript polyfills that existed during the 2022 transition cost more in main-thread time than the feature saves. Cascade layers are slightly older still, shipping in Chrome and Edge 99, Firefox 97, and Safari 15.4, which means the layered rollout pattern above is safe on the same floor. If your analytics still show meaningful traffic on engines below those versions, gate the enhancement layer with feature detection via @supports and keep the viewport breakpoint permanently, as covered in handling container query fallbacks for older browsers.

FAQ

Do I have to rewrite my media queries to adopt container queries? No. Leave the viewport rules in place as the baseline and add container rules in a later cascade layer, one component at a time. Delete a media query only after its component has shipped and been verified on container rules.

Where should the wrapper element that carries container-type go? On the element that owns the slot, not on the component root. A component that declares itself a container cannot query its own width, so the wrapper belongs in the layout that places the component.

How do I stop container queries from causing layout shift? Make sure the container has a stable size before content arrives, by reserving space with aspect-ratio or a min-height on media and skeletons. Shift comes from the container resizing late, not from the query itself.

What should QA actually test on a container-query component? Render each component at several container widths independently of the window, verify it still passes at 200 percent browser zoom, and confirm the unconditional baseline layout is usable on its own with the container rules disabled.

Related articles

More pages in the same section.