Container Queries vs Media Queries for Components: Choosing the Right Reference Box
Both rules answer a question about size, and the temptation is to treat them as interchangeable with different syntax. They are not. A media query asks about the environment — the viewport, the pointing device, the user’s motion preference, the print target. A container query asks about the layout position an element has been given. A design system that mixes them up ships components that look right on the designer’s screen and wrong in every real application, because the viewport width and the component width stopped correlating the moment someone put the component in a sidebar.
The rule of thumb is short: if the answer would change when you move the component to a different column, it is a container query; if the answer would change when the user rotates their phone or turns on high contrast, it is a media query. This deep dive belongs to Container Queries in Components inside Styling, Theming & CSS Encapsulation.
Problem Statement: The Card That Breaks in a Sidebar
class ProductCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: block; }
.layout { display: grid; gap: 0.75rem; }
.actions { display: flex; flex-direction: column; }
/* BUG: asks the viewport a question only the layout can answer. */
@media (min-width: 900px) {
.layout { grid-template-columns: 160px 1fr; }
.actions { flex-direction: row; }
}
</style>
<div class="layout">
<slot name="media"></slot>
<div><slot name="title"></slot><div class="actions"><slot name="action"></slot></div></div>
</div>`;
}
}
customElements.define('product-card', ProductCard);
<!-- 1440px desktop viewport -->
<div style="display: grid; grid-template-columns: 260px 1fr">
<aside><product-card>…</product-card></aside> <!-- 260px wide: goes two-column anyway -->
<main><product-card>…</product-card></main> <!-- 1160px wide: correct -->
</div>
At a 1440px viewport the media query matches for both cards, so the sidebar card tries to fit a 160px image column plus text plus a row of buttons into 260px. The image overflows, the buttons wrap into an unreadable stack, and the only way the component author can fix it is to add an attribute the consumer must remember to set — which is a coordination cost that grows with every new layout in the application.
Root-Cause Analysis
Media Queries Level 4 defines a media query as a test against the media features of the output device or the viewport. min-width in a media query means the viewport’s width — not the element’s, not its parent’s. There is no element-relative media feature and there never was one; that gap is precisely what container queries were specified to fill.
For a component author the consequence is structural. A media query is a global condition: it evaluates once per document, and every rule guarded by it flips at the same instant for every element on the page. That is exactly right for decisions the page makes as a whole — a navigation bar collapsing, a print stylesheet, honouring prefers-reduced-motion — and exactly wrong for a decision that depends on where an element landed.
Container queries invert the relationship. CSS Containment Module Level 3 evaluates each @container condition per element, against that element’s nearest matching query container. Two instances of the same component in different columns get different answers from the same rule, with no attribute, no observer, and no coordination between the component and the application’s layout.
There is also a correctness argument that outlives responsive design. A media-query breakpoint encodes an assumption about the application’s layout inside the component. The day someone adds a three-column view, or embeds the component in a modal, or renders it inside an email preview pane, the assumption is silently wrong. A container query encodes no assumption about the page at all — which is what makes a component genuinely portable across applications the design system never sees.
@media (min-width: 900px) tests perfectly when the only place it appears is the main column of a full-width page, because viewport width and component width happen to move together. The correlation is not causation, and it breaks the first time someone uses the component somewhere else. Test every component at several component widths inside a fixed viewport — that is the test that would have caught it.
Neither rule replaces the other. Media queries remain the only way to ask about the things container queries cannot see, and there are more of those than width: prefers-reduced-motion, prefers-color-scheme, prefers-contrast, pointer and hover, orientation, print, forced-colors, and resolution. A well-built component uses both, for different questions.
Production-Safe Pattern
class ProductCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: block; container: wfc-product / inline-size; }
.layout { display: grid; gap: 0.75rem; }
.media { width: 100%; border-radius: 10px; }
.actions { display: flex; flex-direction: column; gap: 0.5rem; }
.action { padding: 0.5rem 0.9rem; border-radius: 8px; transition: transform 160ms ease; }
/* LAYOUT question → container query. Answered per instance. */
@container wfc-product (inline-size >= 420px) {
.layout { grid-template-columns: 160px 1fr; align-items: start; }
.actions { flex-direction: row; }
}
@container wfc-product (inline-size >= 680px) {
.layout { grid-template-columns: 240px 1fr; gap: 1.25rem; }
}
/* ENVIRONMENT questions → media queries. Answered once for the page. */
@media (prefers-reduced-motion: reduce) {
.action { transition: none; }
}
@media (pointer: coarse) {
/* Touch targets, regardless of how wide the card is. */
.action { min-block-size: 44px; min-inline-size: 44px; }
}
@media print {
.actions { display: none; }
.layout { grid-template-columns: 1fr; }
}
</style>
<div class="layout">
<div><slot name="media"></slot></div>
<div><slot name="title"></slot><slot></slot><div class="actions"><slot name="action"></slot></div></div>
</div>`;
}
}
customElements.define('product-card', ProductCard);
Read the two groups as answering different questions, because they are. The container block decides the arrangement; it would give a different answer for the sidebar copy and the main-column copy of the very same element. The media block decides accessibility and output behaviour; a 44px touch target is correct on a touch device whether the card is 260px or 1160px wide, and hiding the action buttons is correct for print at every width.
Note that the two compose cleanly. A touch device viewing a wide card gets the two-column arrangement and the enlarged targets, because the rules are independent conditions rather than competing breakpoints.
Verification
const card = document.querySelector('product-card');
const layout = card.shadowRoot.querySelector('.layout');
// Layout follows the component, not the window.
card.style.width = '260px';
console.assert(getComputedStyle(layout).gridTemplateColumns.split(' ').length === 1, 'stacked when narrow');
card.style.width = '700px';
console.assert(getComputedStyle(layout).gridTemplateColumns.split(' ').length === 2, 'two-column when wide');
// Two instances at different widths, one viewport: different answers.
const [narrow, wide] = document.querySelectorAll('product-card');
narrow.style.width = '260px';
wide.style.width = '900px';
console.assert(
getComputedStyle(narrow.shadowRoot.querySelector('.layout')).gridTemplateColumns !==
getComputedStyle(wide.shadowRoot.querySelector('.layout')).gridTemplateColumns,
'same rule, two answers'
);
The manual test that matters is the one the media-query version would fail: put two copies of the component in one page, at different widths, and confirm they render differently. In DevTools, the device-emulation width slider is the wrong tool here — resizing the viewport should change nothing for a correctly built component. Resize the containing element instead, by editing its inline width in the Elements panel.
When to Use vs When to Avoid
| Question the rule answers | Use |
|---|---|
| How much inline space does this instance have? | @container |
| Should the arrangement be stacked or side by side? | @container |
| How large should fluid type inside this component be? | @container with cqi |
| Should the page’s navigation collapse? | @media — a page decision, not a component one |
| Does the user prefer reduced motion or dark colours? | @media |
| Is the primary pointer coarse? | @media |
| Is this a print rendering? | @media |
| Is the display high-resolution? | @media |
| Does a consumer token ask for compact density? | @container style(…) |
Two entries deserve a note. Page-level chrome — the application shell, the top navigation, the page grid itself — is legitimately a media-query concern, because it is the environment for everything else. And the last row is the third option people forget: a style query tests a custom property on the container, so “compact or comfortable” can be a token the consumer sets rather than a size the component infers.
Frequently Asked Questions
Can a media query ever ask about an element's width?
No. Media features describe the output device and the viewport; there is no element-relative media feature. That gap is exactly what container queries were specified to close.
Should I replace every media query in my components?
Only the ones asking about layout space. Environment questions — motion, colour scheme, contrast, pointer type, print, resolution — remain media queries, and a good component uses both kinds for different decisions.
My component works fine with media queries. Why change it?
Because it works by coincidence: viewport width and component width correlate only while the component lives in one kind of column. The first sidebar, modal, or split view breaks it, and the fix at that point is an attribute the consumer must set correctly everywhere.
Do container queries cost more than media queries?
Marginally more style-matching work, offset by the layout containment they require, which scopes invalidation to the component’s subtree. In practice the containment usually makes pages with many components faster, not slower.
Related
- Container Queries in Components — the parent topic on containment and query evaluation.
- Styling, Theming & CSS Encapsulation — the section this deep dive belongs to.
- Making a Host a Query Container — the declaration that makes per-instance answers possible.
- Container Query Units Inside Shadow DOM — the unit half of the same feature.
- Inheriting Global Themes in Isolated Components — the other way environment reaches a component.