CSS Module 12 – Responsive design: media and container queries
Media queries: range syntax and user preferences
Write media queries with range syntax and, or and not, rem breakpoints, hover and pointer, and the motion, colour scheme, contrast and forced-colour queries.
What you will learn
- Write media queries with the range syntax and logical operators
- Detect input capabilities with the hover and pointer features
- Respond to user preferences for reduced motion, colour scheme, contrast and forced colours
Before you start
What you’ll learn · Before you start
What you will learn
- Write media queries with the range syntax and logical operators
- Detect input capabilities with the hover and pointer features
- Respond to user preferences for reduced motion, colour scheme, contrast and forced colours
Before you start
On this page
A media query asks the browser a question about where the page is shown, and applies its rules only when the
answer is yes. @media (width >= 40rem) { … } asks whether the viewport is at least 40rem wide;
@media (hover: hover) whether the main pointer can hover; @media (prefers-reduced-motion: reduce) whether the
reader asked their system for less motion. Media queries ask about the viewport, the input and the reader’s settings,
never about the box being styled: a component that must fit wherever it is placed needs container queries, the next
lesson. This lesson covers how a query is read, the range syntax and the gap it closes, how em and rem behave
inside queries, the hover and pointer features and the preference features, with every result measured in Chromium
153, Firefox 155 and WebKit 26.6.
How a media query is read
A query has an optional media type (screen, print or all) and conditions in parentheses. and needs every
condition, or needs one, and not turns a condition around; mixing and with or at one level needs parentheses
to say which comes first. A comma between whole queries means “any of these”. Queries ask three families of
questions:
What a media query can ask, and what the answer does
Text description of the diagram
At the top is a media query, written as @media followed by a condition and a block of rules. It asks one of three families of questions:
- The viewport: width, height, orientation and resolution, used for page layout.
- The input: hover, pointer, any-hover and any-pointer, used for target sizes and hover effects.
- The reader's settings: prefers-reduced-motion, prefers-color-scheme, prefers-contrast and forced-colors, used to honour what the reader asked for.
The answer is true, false or unknown. The rules apply only when it is true; a false or unknown answer, such as a misspelt feature, leaves them out.
The fastest way to learn the rules is to test queries with matchMedia(), which evaluates them as @media does.
This page tests fourteen in a window 800 px wide, at the default text size, so 40rem is 640 px and 50rem is 800 px:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Which media queries match</title>
<style>:root { --wide: 40rem; }</style>
</head>
<body>
<script>
// Each query is tested with matchMedia(), which evaluates it the way @media does. The lesson's table gives the
// answers in a window 800 px wide at the default text size, where 40rem is 640 px and 50rem is 800 px.
const QUERIES = [
'(width >= 40rem)',
'(min-width: 40rem)',
'(width < 50rem)',
'(width <= 50rem)',
'(30em <= width < 50em)',
'screen and (width >= 40rem)',
'print, (width >= 60rem)',
'not print',
'not (hover: hover)',
'(widht >= 1px)',
'not (widht >= 1px)',
'(width >= 40rem) or (widht >= 1px)',
'(width >= var(--wide))',
'(width >= calc(30rem + 10px))',
];
for (const q of QUERIES) {
const m = matchMedia(q);
console.log(`${q.padEnd(36)} ${m.matches}`);
}
</script>
</body>
</html> All three engines gave the same answers:
| Query | Matches at 800 px | Why |
|---|---|---|
(width >= 40rem), (min-width: 40rem) |
yes | the same question, written two ways |
(width < 50rem) |
no | 800 px is not less than 800 px |
(width <= 50rem) |
yes | <= includes the boundary |
(30em <= width < 50em) |
no | the range stops just before 800 px |
screen and (width >= 40rem) |
yes | the page is on a screen |
print, (width >= 60rem) |
no | neither query in the list is true |
not print |
yes | not turns the whole query around |
(widht >= 1px) |
no | a misspelt feature is unknown |
not (widht >= 1px) |
no | the opposite of unknown is still unknown |
(width >= 40rem) or (widht >= 1px) |
yes | or needs only one true condition |
(width >= var(--wide)) |
no | queries can’t read custom properties |
(width >= calc(30rem + 10px)) |
yes | calc() works in values |
The unknown rows matter most. A typo doesn’t break the stylesheet: Media Queries Level 4 makes the condition
unknown, and an unknown query never applies, even with not in front. The keyword only before a media type exists
only for browsers from before media queries, and changes nothing today.
The range syntax, and the gap it closes
The range syntax compares a feature with ordinary operators: (width >= 40rem) means exactly what
(min-width: 40rem) means, and (width <= 64rem) what (max-width: 64rem) means. It also says what the prefixes
can’t: a strict comparison, (width < 64rem), and a range in one condition, (40rem <= width < 64rem). It is
Baseline widely available: Chrome and Edge 104, Firefox 102 and Safari 16.4 support it.
The strict comparison fixes a real bug. A pair of breakpoints written with prefixes, (max-width: 959px) for the
narrow layout and (min-width: 960px) for the wide one, leaves out every width between 959 and 960 px, and such widths
exist. At 125 % display scaling, which Windows offers, one device pixel is 0.8 CSS px, so the layout width moves in
steps of 0.8 px:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>A gap between two breakpoints</title>
</head>
<body>
<script>
// The layout width can be a fraction of a CSS pixel: at 125 % display scaling it moves in steps of 0.8 px.
const width = Number(document.documentElement.getBoundingClientRect().width.toFixed(2));
const pairs = {
'min/max': ['(max-width: 959px)', '(min-width: 960px)'],
'range': ['(width < 960px)', '(width >= 960px)'],
};
for (const [syntax, [narrow, wide]] of Object.entries(pairs)) {
const n = matchMedia(narrow).matches;
const w = matchMedia(wide).matches;
console.log(`${width} px · ${syntax}: narrow ${n}, wide ${w}${!n && !w ? ' · neither layout applies' : ''}`);
}
</script>
</body>
</html> With Chromium and Firefox set to 125 % scaling and a window of 959 CSS px, the page measured 959.2 px wide. Both
prefixed queries were false, so neither layout applied and the page lost its styles at that width. The range pair,
(width < 960px) and (width >= 960px), always gives exactly one match. The same check, for the widths that 125 %,
150 % and 300 % scaling produce:
// Two layouts split at 960 px, written three ways. Screens scaled to 125 %, 150 % or 300 % give layout widths in
// steps of 0.8, 0.67 or 0.33 CSS px, so a width such as 959.2 px is real.
const SPLITS = {
'max-width: 959px / min-width: 960px': [(w) => w <= 959, (w) => w >= 960],
'max-width: 959.98px / min-width: 960px': [(w) => w <= 959.98, (w) => w >= 960],
'width < 960px / width >= 960px': [(w) => w < 960, (w) => w >= 960],
};
const widths = [959, 959.2, 1439 / 1.5, 2879 / 3, 960];
console.log(('width ' + Object.keys(SPLITS).map((k, i) => `split ${i + 1}`.padEnd(10)).join('')).trimEnd());
for (const w of widths) {
const cells = Object.values(SPLITS).map(([narrow, wide]) => {
const n = narrow(w);
const x = wide(w);
return (n && x ? 'both' : n ? 'narrow' : x ? 'wide' : 'NEITHER').padEnd(10);
});
console.log(`${w.toFixed(2).padEnd(12)}${cells.join('')}`.trimEnd());
}
Object.keys(SPLITS).forEach((k, i) => {
const [narrow, wide] = k.split(' / ');
console.log(`split ${i + 1}: narrow layout in (${narrow}), wide layout in (${wide})`);
}); Output
width split 1 split 2 split 3 959.00 narrow narrow narrow 959.20 NEITHER narrow narrow 959.33 NEITHER narrow narrow 959.67 NEITHER narrow narrow 960.00 wide wide wide split 1: narrow layout in (max-width: 959px), wide layout in (min-width: 960px) split 2: narrow layout in (max-width: 959.98px), wide layout in (min-width: 960px) split 3: narrow layout in (width < 960px), wide layout in (width >= 960px)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node gap.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
Bootstrap 5.3 subtracts 0.02 px from its max-width breakpoints for the same reason; a width would then have to fall
between 959.98 and 960 px to slip through, which no common scaling produces. With the range syntax you don’t need the
trick.
em and rem in media queries
Inside a media query, em and rem don’t come from the page’s styles. Media Queries Level 4 bases them on the
initial font size: the browser’s default of 16 px, or the size the reader chose in the browser’s settings. This
page sets html { font-size: 62.5% }, the old trick that makes 1rem 10 px in its CSS, and tests three queries:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>rem and em in media queries</title>
<style>
html { font-size: 62.5%; } /* the old trick that makes 1rem 10px inside the page */
</style>
</head>
<body>
<script>
// In a media query, rem and em use the browser's initial font size (16 px unless the reader changed it), never
// the font-size the page sets on html.
const root = getComputedStyle(document.documentElement).fontSize;
for (const q of ['(width >= 40rem)', '(width >= 40em)', '(width >= 640px)']) {
console.log(`html font-size ${root} · ${q} ${matchMedia(q).matches}`);
}
</script>
</body>
</html> | Window, default text | (width >= 40rem) |
(width >= 640px) |
|---|---|---|
| 600 px, 16 px | no | no |
| 700 px, 16 px | yes | yes |
| 700 px, 20 px | no | yes |
(width >= 40em) gave the same answer as 40rem in every row, and the page’s own root font size was 10 px with the
16 px default and 12.5 px with 20 px. The first row shows that the page’s 10 px root changed nothing: 40rem in the
query stayed 640 px. The last row shows
what a reader’s setting does: with 20 px text, 40rem became 800 px, and the narrow layout stayed until the window was
that wide. All three engines agreed at the default size; Chromium and Firefox, whose default text size the test could
change, agreed at 20 px. That is the reason to write breakpoints in rem or em: the layout switches when the text no longer fits,
whatever size the reader picked. A px breakpoint ignores that setting, so large text gets squeezed into the wide
layout. Browser zoom is different: it shrinks the width in CSS px, so px and rem breakpoints react to it in the same
way.
Hover and pointer: what the input can do
hover says whether the main pointing device can hover (hover or none), and pointer how precise it is
(fine, coarse or none). any-hover and any-pointer ask the same about every input, so a laptop with a
touchscreen typically has pointer: fine for its trackpad and also any-pointer: coarse for the screen. Neither feature can
detect a keyboard. This product card reveals its Save button on hover only where hover is reliable, and keeps it
visible everywhere else:
hover-reveal.css
/* The Save button is always visible, so every device can use it. It hides until the card is hovered, or holds
focus, only when the main pointer can hover precisely and no input is coarse: a laptop with a touchscreen keeps
it visible for the finger. */
.save { opacity: 1; min-block-size: 24px; min-inline-size: 24px; }
@media (hover: hover) and (pointer: fine) and (not (any-pointer: coarse)) {
.product .save { opacity: 0; transition: opacity 150ms; }
.product:hover .save,
.product:focus-within .save { opacity: 1; min-block-size: 24px; min-inline-size: 24px; }
}
/* Targets of 44 px wherever any input is coarse; 24 px is the minimum everywhere else. */
@media (any-pointer: coarse) {
.save { min-block-size: 44px; min-inline-size: 44px; }
} hover-reveal.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hover only where hover exists</title>
<link rel="stylesheet" href="hover-reveal.css">
</head>
<body>
<div class="product">
<h2>Cotton kurta</h2>
<button class="save" type="button">Save</button>
</div>
<script>
const features = ['(hover: hover)', '(pointer: fine)', '(pointer: coarse)', '(any-hover: hover)', '(any-pointer: coarse)'];
console.log(features.map((q) => `${q} ${matchMedia(q).matches}`).join(' · '));
const save = document.querySelector('.save');
const style = getComputedStyle(save);
console.log(`Save button: opacity ${style.opacity}, ${Math.round(save.getBoundingClientRect().height)} px tall`);
</script>
</body>
</html> | Emulated input | Features that matched | Save button |
|---|---|---|
| mouse, 1280 px | hover: hover, pointer: fine |
hidden until hover or focus, 24 px tall |
| touch, 390 px phone | hover: none, pointer: coarse, any-pointer: coarse |
visible, 44 px tall |
| touch, 1280 px | hover: none, pointer: coarse, any-pointer: coarse |
visible, 44 px tall |
The three engines matched in every row. The same features answered both touch rows, which is the point: layout follows the width, while hover and pointer describe the input, so a large touchscreen is treated as touch. Three rules come from this:
- Never let a task depend on hover. A finger can’t hover, and touch browsers apply hover styles only to what was tapped.
- Reveal with focus too.
:focus-withinshows the button to keyboard users who tab into the card. - Size targets for the coarsest input. WCAG 2.2 asks for targets of at least 24 by 24 CSS px (success criterion
2.5.8) and 44 by 44 at level AAA (2.5.5);
any-pointer: coarseapplies the larger size when any input is a finger.
User preferences
Four preference features are Baseline widely available, and each asks one question:
prefers-reduced-motion:reduceorno-preference. Someone with a vestibular disorder can be made dizzy by movement they didn’t start.prefers-color-scheme:lightordark, from the system or the browser.prefers-contrast:more,less,customorno-preference.forced-colors:activewhen the browser replaces the page’s colours with the reader’s own palette, as Windows contrast themes do.
The stylesheet below gives each one a small, separate job: motion is added only for people who haven’t asked for less, a dark palette swaps custom properties, more contrast strengthens edges, and forced colours keep a border:
preferences.css
/* Four user preferences. Each block changes only what the preference is about. */
:root {
color-scheme: light dark;
--surface: #ffffff;
--text: #1f2933;
--edge: #c3c9d1;
}
body { background: var(--surface); color: var(--text); }
.card { border: 1px solid var(--edge); border-radius: 8px; box-shadow: 0 2px 6px #0003; }
/* Motion is added for people who have not asked their system for less of it. */
@media (prefers-reduced-motion: no-preference) {
.card { transition: translate 250ms ease-out; }
.card:hover { translate: 0 -4px; }
}
/* A dark palette when the system or browser asks for one. */
@media (prefers-color-scheme: dark) {
:root { --surface: #14171c; --text: #e4e7ec; --edge: #48505c; }
}
/* A stronger edge when the reader asks for more contrast. */
@media (prefers-contrast: more) {
:root { --edge: currentColor; }
}
/* Forced colours replace the palette with the reader's own and drop shadows, so the border carries the edge. */
@media (forced-colors: active) {
.card { border-color: CanvasText; }
} preferences.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>User preference queries</title>
<link rel="stylesheet" href="preferences.css">
</head>
<body>
<div class="card">Order #40211 is out for delivery.</div>
<script>
const card = getComputedStyle(document.querySelector('.card'));
const body = getComputedStyle(document.body);
const queries = ['(prefers-reduced-motion: reduce)', '(prefers-color-scheme: dark)', '(prefers-contrast: more)', '(forced-colors: active)'];
console.log(queries.filter((q) => matchMedia(q).matches).join(' ') || 'no preference query matches');
console.log(`background ${body.backgroundColor} · border ${card.borderTopColor} · shadow ${card.boxShadow} · transition ${card.transitionDuration}`);
</script>
</body>
</html> Loaded with Playwright’s media emulation for each preference in turn:
| Emulated | What changed |
|---|---|
| nothing | white page, grey border, soft shadow, a 0.25 s transition |
| reduced motion | the transition became 0 s |
| dark scheme | a near-black page with a dark grey border |
| more contrast | the border took the text colour |
| forced colours | a black border; Chromium and Firefox removed the shadow |
In forced colours mode Chromium and Firefox removed the shadow, which is why the card’s edge has to be a border.
WebKit matched the query under emulation but kept the shadow and the page’s own colours. Two more features are not
ready: prefers-reduced-transparency works only in Chromium, and prefers-reduced-data in no browser, so use them
only to add something extra.
Keeping breakpoints organised
Media queries can’t read custom properties, as the var() row above showed, and @custom-media, the planned way to
name a query, hasn’t shipped in any browser. Until it does, keep the values in one documented list, and nest each
component’s queries inside the component’s rule, which CSS nesting allows in every current engine:
/* The site's breakpoints, listed in one place. Media queries can't read custom properties, and @custom-media has
not shipped in any browser yet, so each value is written out where it is used:
two columns 33rem (528 px at the default text size)
three columns 49rem (784 px)
Search the code base for "33rem" and "49rem" before changing one. */
.products {
display: grid;
gap: 1rem;
/* Nested queries keep a component's breakpoints next to the component. */
@media (width >= 33rem) {
grid-template-columns: repeat(2, 1fr);
}
@media (width >= 49rem) {
grid-template-columns: repeat(3, 1fr);
}
} All three engines applied the nested queries, giving one, two and three columns at 400, 600 and 900 px. In
JavaScript, matchMedia(query).matches gives the same answer as @media, and a change event fires when it flips,
which is how scripts follow a breakpoint or a preference without polling the window size.
Practise: evaluate width queries
Exercise · Medium · JavaScript
Evaluate width media queries the way a browser does
Write matchesWidth(query, { width, fontSize = 16 }) in match.mjs and export it. It answers whether a media query about the viewport's width matches a viewport width CSS px wide, in a browser whose initial font size is fontSize px. It returns true or false.
A query is one or more conditions joined by and , each in parentheses:
(min-width: 40rem)and(max-width: 59.99em), which include their value;- the range syntax with
widthon either side:(width >= 40rem),(width < 50em),(640px <= width); - a range with two values:
(30em <= width < 50em).
The operators are <, <=, > and >=, and values are a number with px, em or rem. In media queries em and rem are both the initial font size, never the font-size a page sets on html, so with fontSize: 20, 40rem is 800 px. The query matches only when every condition does.
Widths may be fractions of a pixel, as they are at 125 % display scaling: at 959.2 px, (max-width: 959px) and (min-width: 960px) are both false.
The sample tests import matchesWidth from match.mjs and run in your browser.
Starter code · match.mjs
/** Whether a width media query matches a viewport `width` px wide, with an initial font size of `fontSize` px. */
export function matchesWidth(query, { width, fontSize = 16 }) {
// Replace this line with your code.
return false;
} The sample tests · match.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { matchesWidth } from './match.mjs';
test('min-width and max-width include their value', () => {
assert.equal(matchesWidth('(min-width: 40rem)', { width: 640 }), true);
assert.equal(matchesWidth('(min-width: 40rem)', { width: 639.5 }), false);
assert.equal(matchesWidth('(max-width: 40em)', { width: 640 }), true);
assert.equal(matchesWidth('(max-width: 40em)', { width: 640.5 }), false);
});
test('the range operators, at their boundaries', () => {
assert.equal(matchesWidth('(width >= 40rem)', { width: 640 }), true);
assert.equal(matchesWidth('(width > 40rem)', { width: 640 }), false);
assert.equal(matchesWidth('(width < 50rem)', { width: 800 }), false);
assert.equal(matchesWidth('(width <= 50rem)', { width: 800 }), true);
});
test('the value can come first', () => {
assert.equal(matchesWidth('(40rem > width)', { width: 600 }), true);
assert.equal(matchesWidth('(640px <= width)', { width: 600 }), false);
});
test('a range with two values', () => {
assert.equal(matchesWidth('(30em <= width < 50em)', { width: 480 }), true);
assert.equal(matchesWidth('(30em <= width < 50em)', { width: 800 }), false);
assert.equal(matchesWidth('(30em <= width < 50em)', { width: 479 }), false);
});
test('em and rem follow the initial font size', () => {
assert.equal(matchesWidth('(width >= 40rem)', { width: 700, fontSize: 20 }), false);
assert.equal(matchesWidth('(width >= 640px)', { width: 700, fontSize: 20 }), true);
assert.equal(matchesWidth('(min-width: 35em)', { width: 700, fontSize: 20 }), true);
});
test('every condition joined by and must match', () => {
assert.equal(matchesWidth('(min-width: 30rem) and (max-width: 47.99rem)', { width: 767 }), true);
assert.equal(matchesWidth('(min-width: 30rem) and (max-width: 47.99rem)', { width: 768 }), false);
assert.equal(matchesWidth('(min-width: 30rem) and (max-width: 47.99rem)', { width: 400 }), false);
});
test('fractional widths fall between min- and max- breakpoints', () => {
assert.equal(matchesWidth('(max-width: 959px)', { width: 959.2 }), false);
assert.equal(matchesWidth('(min-width: 960px)', { width: 959.2 }), false);
assert.equal(matchesWidth('(width < 960px)', { width: 959.2 }), true);
}); A hint
Split the query on and , take the text inside each pair of parentheses, and turn every value into px first (em and rem multiply by fontSize). For a range, split on the operators with a regular expression that keeps them, such as /\s*(<=|>=|<|>)\s*/, put width in place of the word, and check each neighbouring pair.
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
6 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 (min-width: 48em) and (width >= 48em), and why write breakpoints in em or rem rather than px?
They match the same widths: Media Queries Level 4 defines the min- prefix as the >= comparison, so both mean that
the viewport is at least 48em wide. The range form also says what the prefixes can’t: a strict comparison such as
(width < 48em), and a range in one condition, (30em <= width < 48em), which leaves no gap at fractional widths. In
media queries, em and rem both use the browser’s initial font size, 16 px unless the reader changed it, never the
font-size a page sets on html, so 48em is 768 px by default and 960 px for someone who chose 20 px text. That is
the reason to prefer them: the layout switches when the text no longer fits, whatever size the reader picked.
Key takeaways
andneeds every condition,orone,notreverses one; a comma means “any of these queries”.- A misspelt or unsupported feature is unknown, and an unknown query never applies, even with
notin front. (width >= 40rem)equals(min-width: 40rem); use<for the upper end so fractional widths such as 959.2 px always get a layout.- In media queries,
emandremare the reader’s default text size, never the page’s root font size. hoverandpointerdescribe the main input,any-hoverandany-pointerevery input; never hide a task behind hover.- Add motion inside
(prefers-reduced-motion: no-preference), swap custom properties for the dark scheme, and draw edges with borders, which survive forced colours.
References
- Media Queries Level 4: syntax, evaluation and the range context (W3C CSS Working Group)
- Media Queries Level 4: units in media queries (W3C CSS Working Group)
- Media Queries Level 4: interaction media features (hover, pointer, any-hover, any-pointer) (W3C CSS Working Group)
- Media Queries Level 5: user preference media features (W3C CSS Working Group)
- Media Queries Level 5: custom media queries (W3C CSS Working Group)
- CSS Nesting Module (W3C CSS Working Group)
- Using media queries (MDN Web Docs) (Mozilla)
- Web Content Accessibility Guidelines (WCAG) 2.2: Target Size (Minimum) (W3C)
- Web Content Accessibility Guidelines (WCAG) 2.2: Target Size (Enhanced) (W3C)
- Bootstrap 5.3: breakpoints, max-width (Bootstrap)
- Web features explorer: media query range syntax (W3C WebDX Community Group)
- Web features explorer: interaction media queries (W3C WebDX Community Group)
- Web features explorer: prefers-reduced-transparency media query (W3C WebDX Community Group)
- Web features explorer: custom media queries (W3C WebDX Community Group)
- Understanding WCAG 2.2: Animation from Interactions (W3C)
- Page: emulateMedia (Playwright documentation) (Microsoft (Playwright))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress