Medium wave · 540–1600 kHz81 stn
1580kHz

Media Queries Ask the Wrong Question

A single ornate brass mechanical module resting inside several differently sized copper alcoves at once in a dark steampunk vault, each copy unfolding its gears and panels to fit the exact width of its own niche, neon teal light tracing the walls of every alcove while a giant clouded brass gauge for the whole room hangs ignored overhead

You build a post card. Image on the left, title and excerpt on the right, and below md it stacks. It looks great on the home page. Then someone drops the same card into the sidebar’s “Related posts” list, and on a 1440px monitor it tries to render side-by-side in a 300px column. The image is a postage stamp and the title wraps one word per line.

Nothing is broken. The media query is doing exactly what it was told: the viewport is wide, so use the wide layout. The problem is that the viewport was never the question. The card does not live in the viewport. It lives in a slot, and the slot is 300px.


The viewport is a proxy, and a bad one

Media queries made sense when a page was one column of content and the window width was a decent estimate of how much room everything had. That stopped being true the moment we started building pages out of reusable components.

A component on a content site routinely appears in three or four contexts:

  • A full-width feature on the home page
  • One cell of a three-column grid on a category page
  • A narrow sidebar list on a post page
  • A two-up row in a footer or newsletter block

At a 1400px viewport, those slots range from roughly 1200px down to 280px. A media query sees one number for all four. So you end up doing what everybody does: a variant prop, a compact flag, a size="sm" that the parent has to remember to pass. The component’s layout logic leaks into every caller, and every new placement is a new prop value.

That is the component asking the page a question the page should not have to answer.


Container queries ask the slot

A container query lets a component style itself based on the size of an ancestor element instead of the window. You mark the slot as a container, and descendants can query it:

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

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

@container (min-width: 32rem) {
  .card {
    grid-template-columns: 12rem 1fr;
  }
}

Now the same card stacks in the sidebar and goes side-by-side in the feature slot, at any viewport width, with no prop. The card’s layout is a property of the card again.

This has been in every major engine since early 2023: Chrome 105, Safari 16, Firefox 110. It is not a progressive-enhancement experiment anymore. It is just CSS.


Why this matters more on a static site

The pre-container-query workaround was ResizeObserver: measure the element in JavaScript, set a class, let CSS do the rest. Plenty of component libraries still ship exactly that.

On a static site that is a bad trade. To run a resize observer, the component has to hydrate. That means shipping a runtime for a component whose entire output was finished at build time, and the layout is wrong until the script runs. You get a first paint in the wrong layout followed by a jump, which is a layout shift you paid bytes for.

A container query is resolved by the browser during layout, before first paint, with no script at all. It is the same argument we made about accordions and disclosures: when the platform does the job, the component that used to do it becomes a liability. An Astro component with a container query renders to plain HTML and CSS and is correct on the first frame.


The rules that trip people up

Container queries are simple to write and easy to get subtly wrong. Four things account for almost every bug I have seen.

1. An element cannot query itself

A container query styles the descendants of a container, never the container. This does not work:

.card {
  container-type: inline-size;
}

/* Never matches the card itself */
@container (min-width: 32rem) {
  .card {
    grid-template-columns: 12rem 1fr;
  }
}

The fix is one wrapper. Either the slot becomes the container, or the component carries its own outer element:

<article class="card">
  <div class="card-body">
    <img src="/media/hero.webp" alt="" width="400" height="400" />
    <h3>Post title</h3>
  </div>
</article>
.card {
  container-type: inline-size;
}

@container (min-width: 32rem) {
  .card-body {
    display: grid;
    grid-template-columns: 12rem 1fr;
  }
}

Putting the container on the component’s own root is usually the better choice. The component then works in any slot without the parent having to opt in, which was the whole point.

2. Containment can collapse shrink-to-fit boxes

container-type: inline-size applies size containment on the inline axis. The browser computes the element’s width as if it had no content, so its children can no longer push it wider. That is what makes the query non-circular: the container’s width cannot depend on a layout that depends on the container’s width.

It is also why a container sometimes renders at zero width. Any context that sizes an element from its content will now size it from nothing:

  • width: fit-content or width: max-content
  • An absolutely positioned element with no explicit width
  • A float
  • A flex item with flex: none and no width
  • A grid track sized max-content or fit-content() whose only content is the container

Block-level elements in normal flow, and flex or grid items that get their width from the track, are fine. If a container mysteriously vanishes, check whether its width was ever coming from its children.

3. size is almost never what you want

There is also container-type: size, which contains both axes and lets you query height. It means the element’s height can no longer come from its content either, so unless you set an explicit height, it collapses vertically. Use inline-size unless you are building something with a fixed frame, like a dashboard tile.

4. Unnamed queries match the nearest container

An unnamed @container rule queries the nearest ancestor that is a container. Nest a card container inside a grid container and the card’s queries silently start reading the inner one. Name them when more than one is in play:

.layout-main {
  container: main / inline-size;
}

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

@container card (min-width: 32rem) {
  .card-body {
    grid-template-columns: 12rem 1fr;
  }
}

@container main (min-width: 60rem) {
  .card-grid {
    grid-template-columns: repeat(3, 1fr);
  }
}

The grid’s columns respond to the main column. The card’s internals respond to the card. Neither cares about the window.


Container units do fluid sizing properly

Container queries come with a set of units: cqw, cqh, cqi, cqb, cqmin, and cqmax. One cqi is 1% of the nearest container’s inline size.

This fixes fluid typography for components. The usual clamp(1rem, 2.5vw, 1.75rem) scales a card title with the window, which means a sidebar card’s heading gets larger as the monitor gets wider while its column stays the same width. Scale it with the container instead:

.card h3 {
  font-size: clamp(1rem, 0.75rem + 3cqi, 1.75rem);
}

Keep a rem term in the middle value, as above. A pure cqi or vw value ignores the user’s browser font-size setting, and that turns into an accessibility problem quickly.

If no ancestor is a container, cq* units fall back to the small viewport units (svw, svh, and friends), so a stray component degrades to viewport behavior rather than breaking.


Tailwind v4 has this built in

In Tailwind v3, container queries needed the @tailwindcss/container-queries plugin. In v4 they are core. Mark a container with @container, then prefix utilities with @-sized variants:

<article class="@container">
  <div class="grid gap-4 @md:grid-cols-[12rem_1fr]">
    <img src="/media/hero.webp" alt="" width="400" height="400" />
    <h3 class="text-lg @lg:text-2xl">Post title</h3>
  </div>
</article>

A few details worth knowing:

  • The container sizes are rem-based and much smaller than the viewport breakpoints, because they describe component widths, not screen widths. @md is not md. Do not translate one to the other by habit.
  • They come from the same --container-* theme scale that drives max-w-md and friends, so you can tune both in one @theme block.
  • @max-md: gives you the max-width form, and arbitrary values work too: @min-[30rem]:.
  • Named containers use a slash: @container/card on the parent, @md/card: on the child. Use names the moment containers nest, for the same reason as rule four above.

The pattern from our component styling system still applies: put the @container on the component’s own root so the component is self-contained, and keep viewport variants for the page shell.


Media queries are not dead

They are just for a narrower set of questions. Keep using them for:

  • Page-level layout. Whether the sidebar exists at all is a question about the window. The grid of the page shell belongs in media queries.
  • User preferences. prefers-reduced-motion, prefers-color-scheme, prefers-contrast. These describe the person, not any box.
  • Input capabilities. hover and pointer tell you whether to expect a mouse or a thumb. Container width says nothing about that.
  • Print. @media print is not going anywhere.

A useful rule of thumb: if the answer would be the same for every component on the page, it is a media query. If two components on the same page could reasonably want different answers, it is a container query.


The takeaway

Go find the props in your component library that exist only to tell a component how much room it has. compact, variant="sidebar", size="sm" on something that is not a button. Each of those is a media query that could not see the slot, patched with an argument the caller has to remember.

Put container-type: inline-size on the component’s root, move its responsive rules to @container, and delete the prop. The component gets its layout right in every placement, on the first paint, with no JavaScript. That is what a static component was supposed to be in the first place.