CSS Module 12 – Responsive design: media and container queries
Container queries and container units
Make components respond to their container with container-type and @container, size text with cqi, and avoid self-queries, zero widths and stray containers.
What you will learn
- Build components that respond to their container with container-type and @container
- Size content with container query units such as cqi and cqb
- Avoid the common traps of querying yourself, missing sizes and unnamed nested containers
Before you start
What you’ll learn · Before you start
What you will learn
- Build components that respond to their container with container-type and @container
- Size content with container query units such as cqi and cqb
- Avoid the common traps of querying yourself, missing sizes and unnamed nested containers
Before you start
On this page
A container query styles an element by the size of a box around it instead of the size of the window. You mark
an ancestor as a container with container-type: inline-size, then write @container (width >= 30rem) { … }, and
the rules inside apply wherever that container is at least 30rem wide. The same card can then be stacked in a 280 px
sidebar and laid out side by side in a 640 px column on the same page, which no media query can do, because both
cards share one viewport. Container size queries and the container units such as cqi are Baseline widely
available. This lesson measures one card in three places in Chromium 153, Firefox 155 and WebKit 26.6, then the three
traps that catch most people: a container that queries itself, a container with no width of its own, and a query
that reaches the wrong container.
One card, three places
Container queries need two things: a container, and rules that ask about it. container: card / inline-size names
the slot card and makes its width queryable. The card’s rules ask about the slot through @container card (…):
/* The slot a card sits in is its query container: the card follows the slot's width, not the window's. */
.slot {
container: card / inline-size;
}
.card {
display: grid;
gap: 0.75rem;
padding: 1rem;
border: 1px solid #c3c9d1;
border-radius: 8px;
}
.card img {
inline-size: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
}
.card h3 {
margin: 0;
font-size: clamp(1.125rem, 0.75rem + 3cqi, 2rem); /* 3 % of the slot's width, between 18 and 32 px */
}
/* The photo beside the text once the slot is 30rem wide */
@container card (width >= 30rem) {
.card { grid-template-columns: 12rem 1fr; }
}
/* Photo, text and actions side by side from 60rem */
@container card (width >= 60rem) {
.card { grid-template-columns: 16rem 1fr auto; }
} The page places the same card in a 280 px sidebar, a 640 px main column and a 1100 px band, in one 1280 px window:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>One card in three places</title>
<link rel="stylesheet" href="card.css">
<style>
body { margin: 0; font: 16px/1.5 Arial, sans-serif; }
.sidebar { inline-size: 280px; }
.main { inline-size: 640px; }
.band { inline-size: 1100px; }
</style>
</head>
<body>
<template id="card">
<article class="card">
<img alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 3'%3E%3Crect width='4' height='3' fill='%23d9c7a7'/%3E%3C/svg%3E">
<div><h3>Handloom cotton saree</h3><p>Soft, breathable and woven by hand.</p></div>
<div><button type="button">Add to bag</button></div>
</article>
</template>
<aside class="slot sidebar"></aside>
<main class="slot main"></main>
<section class="slot band"></section>
<script>
for (const slot of document.querySelectorAll('.slot')) {
slot.append(document.getElementById('card').content.cloneNode(true));
const card = slot.querySelector('.card');
const columns = getComputedStyle(card).gridTemplateColumns.split(' ').length;
const title = getComputedStyle(card.querySelector('h3')).fontSize;
console.log(`${slot.classList[1]}: slot ${slot.getBoundingClientRect().width} px · ${columns} column(s) · title ${title}`);
}
</script>
</body>
</html> | Slot | Layout | Title size |
|---|---|---|
| sidebar, 280 px | stacked, one column | 20.4 px |
| main column, 640 px | photo beside the text, two columns | 31.2 px |
| band, 1100 px | photo, text and actions, three columns | 32 px |
All three engines chose the same layouts; Firefox gave the title sizes as 20.4063 and 31.1875 px, because it keeps font sizes in steps of 1/64 px. A media query would have given all three cards the layout for a 1280 px window. This program works out what the stylesheet gives any slot, including the boundary at 30rem:
// What card.css gives a card in a slot of a given width: the layout its two container queries choose, and the title
// size from clamp(1.125rem, 0.75rem + 3cqi, 2rem), where 1cqi is 1 % of the slot's inline size.
const REM = 16; // px: rem in the clamp() uses the page's root font size
function cardAt(slotWidth) {
const layout = slotWidth >= 60 * REM ? 'photo, text, actions' : slotWidth >= 30 * REM ? 'photo beside text' : 'stacked';
const preferred = 0.75 * REM + 3 * (slotWidth / 100);
const title = Math.min(Math.max(1.125 * REM, preferred), 2 * REM);
return { layout, title };
}
console.log('slot layout title');
for (const width of [280, 479, 480, 640, 960, 1100]) {
const { layout, title } = cardAt(width);
console.log(`${String(width).padStart(4)} px ${layout.padEnd(22)} ${Number(title.toFixed(2))} px`);
} Output
slot layout title 280 px stacked 20.4 px 479 px stacked 26.37 px 480 px photo beside text 26.4 px 640 px photo beside text 31.2 px 960 px photo, text, actions 32 px 1100 px photo, text, actions 32 px
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node card_at.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Use media queries for the page’s own layout, which depends on the window, and container queries for components that can be placed anywhere: cards, media objects, form rows, widgets in a dashboard.
What container-type does, and what it costs
For size queries, container-type takes one of three values. normal, the default, makes no size container. inline-size lets descendants
query the box’s width (its inline size, which is the width in horizontal text). size lets them query both width and
height. Each container applies containment: style containment, plus inline-size containment for inline-size or
size containment for size, and it starts an independent formatting context.
Inline-size containment means the box is sized as if it had nothing inside along the inline axis. That rule breaks a
loop, since the container’s width can’t depend on the content that asks about the width, but it also means a box
whose width normally comes from its content collapses. This page gives a filter bar container-type: inline-size in
three such places, then fixes each:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Containers that take their width from their content</title>
<style>
body { margin: 0; padding: 1rem; font: 16px/1.5 Arial, sans-serif; }
.case { margin-block-end: 1rem; }
.filters { container-type: inline-size; } /* a query container for the buttons inside it */
/* Three boxes whose width would come from their content */
.inline .filters { display: inline-block; }
.flex { display: flex; gap: 1rem; }
.flex .filters { flex: none; }
.grid { display: grid; grid-template-columns: auto 1fr; gap: 1rem; }
/* The fixes: a width that comes from outside the box */
.fixed.inline .filters { display: block; }
.fixed.flex .filters { flex: 0 0 16rem; }
.fixed.grid { grid-template-columns: 16rem 1fr; }
</style>
</head>
<body>
<template id="filters"><div class="filters"><button type="button">Size</button> <button type="button">Colour</button> <button type="button">Price</button></div><p>Results</p></template>
<div class="case inline"></div>
<div class="case flex"></div>
<div class="case grid"></div>
<div class="case inline fixed"></div>
<div class="case flex fixed"></div>
<div class="case grid fixed"></div>
<script>
for (const box of document.querySelectorAll('.case')) {
box.append(document.getElementById('filters').content.cloneNode(true));
const r = box.querySelector('.filters').getBoundingClientRect();
console.log(`${box.className.replace('case ', '')}: the container is ${r.width} px wide and ${Math.round(r.height)} px tall`);
}
</script>
</body>
</html> | The container is | Its width | The fix | Width after |
|---|---|---|---|
inline-block |
0 px | display: block |
768 px |
a flex: none item |
0 px | flex: 0 0 16rem |
256 px |
in an auto grid column |
0 px | a 16rem column |
256 px |
All three engines measured the same numbers. At 0 px wide, the three buttons inside each broken container sat one
above the other, 72 px tall, spilling out of a box with no width. A container must get its width from outside: a block that fills its
parent, a flex basis, or a grid track. A size container also needs a height from outside, because its content no
longer stretches it, so use size only for boxes with a known height, such as a full-screen panel. The independent
formatting context also stops margins from collapsing through the container’s edge, which the margins lesson of this
track measured.
Writing @container rules
A container query looks like a media query without the media type: conditions in parentheses, and, or and
not, and the range syntax. It can’t ask about hover or user preferences, which are media questions. Each condition
is answered by a container chosen this way: among the element’s ancestors, the nearest one that is a container
for the features asked about and, when the query names one, has that name. With no such ancestor, the query is
unknown and never applies.
A container can’t query itself
Because the answer comes from an ancestor, rules about the container’s own layout must wait for a container above it. This card is 600 px wide and is a container itself:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>A container can't query itself</title>
<style>
body { margin: 0; font: 16px/1.5 Arial, sans-serif; }
.card { container-type: inline-size; display: flex; flex-direction: column; inline-size: 600px; }
.card-body { display: flex; flex-direction: column; gap: 1rem; }
/* Looks for a container above .card: there is none, so it never applies */
@container (width >= 30rem) {
.card { flex-direction: row; }
}
/* Asks .card, the nearest container above .card-body */
@container (width >= 30rem) {
.card-body { flex-direction: row; }
}
</style>
</head>
<body>
<div class="card"><div class="card-body"><img alt="" width="160" height="120" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 3'/%3E"><p>Text beside the photo once the card is 30rem wide.</p></div></div>
<script>
const style = (s) => getComputedStyle(document.querySelector(s)).flexDirection;
console.log(`.card is 600 px wide · .card flex-direction: ${style('.card')} · .card-body flex-direction: ${style('.card-body')}`);
</script>
</body>
</html> In all three engines, .card stayed a column, since no container sits above it, while .card-body, whose container
is .card, became a row. Style the children, or wrap the component in a slot that is the container, as card.css
does.
Name the containers you mean
Containers nest, and an unnamed query takes the nearest. Here a sidebar is a container named sidebar, and later
someone makes its toolbar a container too, for the toolbar’s own buttons:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Which container a query reaches</title>
<style>
body { margin: 0; font: 16px/1.5 Arial, sans-serif; }
.sidebar { container: sidebar / inline-size; inline-size: 320px; }
.toolbar { container-type: inline-size; inline-size: 180px; } /* added later, for its own buttons */
/* Unnamed: the nearest container, which is now the 180 px toolbar */
@container (width >= 15rem) {
.promo { outline: 2px solid; }
}
/* Named: the nearest container called sidebar, 320 px wide */
@container sidebar (width >= 15rem) {
.promo { text-decoration: underline; }
}
</style>
</head>
<body>
<aside class="sidebar"><div class="toolbar"><p class="promo">Free returns for 30 days</p></div></aside>
<script>
const s = getComputedStyle(document.querySelector('.promo'));
console.log(`unnamed query (toolbar, 180 px): ${s.outlineStyle === 'solid' ? 'matches' : 'does not match'} · sidebar query (320 px): ${s.textDecorationLine === 'underline' ? 'matches' : 'does not match'}`);
</script>
</body>
</html> An unnamed query asks the nearest container; a named one asks the nearest with that name
Text description of the diagram
The top half shows three nested boxes. The outer box, drawn solid blue, is aside.sidebar, a container named sidebar (container: sidebar / inline-size), 320 px wide. Inside it, drawn dashed, is div.toolbar, an unnamed container (container-type: inline-size), 180 px wide. Inside the toolbar is p.promo, the element both queries style.
The bottom half shows the two queries:
- @container (width >= 15rem), drawn dashed like the toolbar. It has no name, so it asks the nearest container, the toolbar. 180 px is less than 240 px, so it does not match.
- @container sidebar (width >= 15rem), drawn solid blue like the sidebar. It asks the nearest container called sidebar. 320 px is at least 240 px, so it matches.
The unnamed query now asks the 180 px toolbar and stops matching, which no one meant; the query named sidebar still
asks the 320 px sidebar. All three engines agreed. Name every container a component depends on, with
container-name or the container shorthand, and name queries by what they mean (card, sidebar, page). A
query with a name and no condition, such as @container sidebar { … }, applies whenever such a container exists; it
is newly available in Chrome 148, Firefox 149 and Safari 26.4. Container style queries, which ask about a
container’s custom properties, are newly available too and belong to the conditional CSS module of this track.
em and rem mean something else here
In a media query, em and rem are the browser’s initial font size. In a container query, lengths are worked out
with the container’s own computed values: em is the container’s font size, and rem the root element’s font size
as the page sets it. This page sets the root to 20 px and fills a 280 px window with a 16 px container:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>em and rem in container and media queries</title>
<style>
html { font-size: 125%; } /* the page's 1rem is 20 px */
body { margin: 0; }
.box { container-type: inline-size; font-size: 16px; } /* fills the 280 px window */
/* rem: the browser's initial 16 px in a media query, the root's 20 px in a container query */
@media (width >= 15rem) { .box p { --media-rem: yes; } }
@container (width >= 15rem) { .box p { --container-rem: yes; } }
/* em: the container's own 16 px font size */
@container (width >= 15em) { .box p { --container-em: yes; } }
</style>
</head>
<body>
<div class="box"><p>Delivery in 2 days</p></div>
<script>
const p = getComputedStyle(document.querySelector('.box p'));
const width = document.querySelector('.box').getBoundingClientRect().width;
for (const [name, prop] of [['@media (width >= 15rem)', '--media-rem'], ['@container (width >= 15rem)', '--container-rem'], ['@container (width >= 15em)', '--container-em']]) {
console.log(`${width} px wide · ${name}: ${p.getPropertyValue(prop).trim() === 'yes' ? 'matches' : 'does not match'}`);
}
</script>
</body>
</html> | Query | Length | Matches at 280 px |
|---|---|---|
@media (width >= 15rem) |
15 × 16 = 240 px | yes |
@container (width >= 15rem) |
15 × 20 = 300 px | no |
@container (width >= 15em) |
15 × 16 = 240 px | yes |
The same 15rem meant 240 px in the media query and 300 px in the container query, in all three engines. When you
move a breakpoint from @media to @container, check the containers’ font sizes, or write it in rem with a root
size you know.
Container query units
Container query units are percentages of a container: cqw and cqh are 1 % of its width and height, cqi and
cqb 1 % of its inline and block size, and cqmin and cqmax the smaller and larger of cqi and cqb. Each unit
looks for the nearest container that can answer for its axis, and with none it falls back to the small viewport
size on that axis:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Container query units and their fallback</title>
<style>
body { margin: 0; }
.card { container-type: inline-size; inline-size: 400px; }
.wide { font-size: 5cqi; } /* 5 % of the nearest inline-size container */
.tall { font-size: 10cqb; } /* block axis: an inline-size container can't answer, so the small viewport does */
</style>
</head>
<body>
<div class="card"><p class="wide in">Inside a 400 px container</p><p class="tall in">Inside, using cqb</p></div>
<p class="wide out">Outside any container</p>
<script>
for (const el of document.querySelectorAll('p')) console.log(`${el.textContent}: ${getComputedStyle(el).fontSize}`);
</script>
</body>
</html> In an 800 by 600 px window, in all three engines, 5cqi was 20 px inside the 400 px container and 40 px outside any
container, 5 % of the 800 px viewport. 10cqb inside the same container was 60 px: an inline-size container can’t
answer for the block axis, so the unit used the 600 px viewport height. Use cqi for sizes that follow a
component’s width, like the card’s title, and avoid cqb unless a size container is there to answer it.
Media query or container query?
| Criterion | Media query | Container query |
|---|---|---|
| Asks about | The viewport, the input and the reader's settings | The size of an ancestor container |
| Needs | Nothing | container-type on an ancestor, which applies containment |
| em and rem | The browser's initial font size | The container's and the root's font sizes |
| Units | vw, vh, svw and the other viewport units | cqi, cqb, cqw, cqh, cqmin and cqmax |
| When to choose | Page layout, hover and pointer, preferences | Components placed in slots of different widths |
Practise: which container answers
Exercise · Medium · JavaScript
Find the container a query asks, and resolve its lengths
Two questions decide whether a container query matches: which container answers it, and how long its lengths are. Write two functions in container.mjs and export them.
queryContainer(ancestors, { name = '', axis = 'inline' }) takes the ancestors of the element being styled, from its parent outwards, each as { name, type }: name is the container's name ('' when it has none) and type is 'normal', 'inline-size' or 'size'. It returns the index of the container the query asks, or -1 when there is none:
- a query about the inline axis (width or inline-size) needs a container of type inline-size or size; - a query about the block axis (height or block-size) needs type size; - a query with a name only accepts a container with exactly that name; a query without one takes the nearest container that fits.
queryLength(value, { containerFont, rootFont }) turns a length of a container query, such as '15rem', '15em' or '240px', into px. In a container query, em is the container's own font size and rem the root element's, both in px.
The sample tests import both functions from container.mjs and run in your browser.
Starter code · container.mjs
/** The index of the ancestor a container query asks, or -1 when no ancestor can answer it. */
export function queryContainer(ancestors, { name = '', axis = 'inline' } = {}) {
// Replace this line with your code.
return -1;
}
/** A length of a container query in px: em is the container's font size, rem the root's. */
export function queryLength(value, { containerFont, rootFont }) {
// Replace this line with your code.
return 0;
} The sample tests · container.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { queryContainer, queryLength } from './container.mjs';
const sidebar = { name: 'sidebar', type: 'inline-size' };
const toolbar = { name: '', type: 'inline-size' };
const plain = { name: '', type: 'normal' };
const panel = { name: 'panel', type: 'size' };
test('an unnamed query asks the nearest container', () => {
assert.equal(queryContainer([toolbar, sidebar]), 0);
assert.equal(queryContainer([plain, plain, sidebar]), 2);
});
test('a named query skips to the nearest container with that name', () => {
assert.equal(queryContainer([toolbar, plain, sidebar], { name: 'sidebar' }), 2);
assert.equal(queryContainer([toolbar, sidebar], { name: 'panel' }), -1);
});
test('a block-axis query needs a size container', () => {
assert.equal(queryContainer([sidebar, toolbar, panel], { axis: 'block' }), 2);
assert.equal(queryContainer([sidebar, toolbar], { axis: 'block' }), -1);
assert.equal(queryContainer([panel, sidebar]), 0);
});
test('with no container at all, nothing answers', () => {
assert.equal(queryContainer([plain, plain]), -1);
assert.equal(queryContainer([]), -1);
});
test('px lengths stay as they are', () => {
assert.equal(queryLength('240px', { containerFont: 20, rootFont: 16 }), 240);
});
test('em is the container\'s font size, rem the root\'s', () => {
assert.equal(queryLength('15em', { containerFont: 16, rootFont: 20 }), 240);
assert.equal(queryLength('15rem', { containerFont: 16, rootFont: 20 }), 300);
assert.equal(queryLength('2.5rem', { containerFont: 14, rootFont: 16 }), 40);
assert.equal(queryLength('30em', { containerFont: 14, rootFont: 16 }), 420);
}); A hint
Walk the array from index 0, the parent. Skip an ancestor whose type can't answer the axis, or whose name differs when the query has one, and return the first index that is left. For lengths, read the number and the unit with a regular expression such as /^(\d*\.?\d+)(px|em|rem)$/.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What is the difference between a media query and a container query, and when do you use each?
A media query asks about the viewport, the input device and the reader’s settings, so every element on the page gets
the same answer. A container query asks about the size of an ancestor that was made a container with
container-type: inline-size or size, so the same component can get different layouts in a sidebar and in a main
column. Use media queries for the page’s own layout and for hover, pointer and preference questions, and container
queries for components that appear in slots of different widths. Containers have a cost: inline-size containment
means their width can’t come from their content, so they need a width from their parent, a flex basis or a grid
track.
Key takeaways
container: card / inline-sizenames a container;@container card (width >= 30rem)asks about it.- The answer comes from an ancestor: an element can’t query itself, so style its children or add a wrapper.
- An inline-size container is sized as if empty: inline-blocks,
flex: noneitems andautogrid columns collapse to 0 px. - Name the containers a component depends on; an unnamed query takes the nearest container, whatever it is.
- In container queries,
emis the container’s font size andremthe root’s, unlike in media queries. 1cqiis 1 % of the container’s inline size; with no container, container units use the small viewport.
References
- CSS Conditional Rules Module Level 5: container queries (W3C CSS Working Group)
- CSS Conditional Rules Module Level 5: container-type (W3C CSS Working Group)
- CSS Conditional Rules Module Level 5: container relative lengths (W3C CSS Working Group)
- CSS Containment Module Level 3: inline-size containment (W3C CSS Working Group)
- @container (MDN Web Docs) (Mozilla)
- Web features explorer: container queries (size) (W3C WebDX Community Group)
- Web features explorer: name-only container queries (W3C WebDX Community Group)
- Web features explorer: container style queries (W3C WebDX Community Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress