Lesson 08 / 23
Media Queries
A media query's components, where its rules sit in the cascade, the overlap and gap at the edge of two ranges, the relation between query units and the user's font size, and feature queries.
Contents
The previous lesson gave the derived threshold: 812 units. What remains is how this number gets written in the stylesheet.
The tool of conditional style is the media query. A query asks a question; if the answer is true, the rules inside it are taken into account. This lesson takes up what a query can look at, where its rules sit in the cascade, and what happens at the point where two ranges meet.
The Query’s Components
A media query is built from three parts.
Media type says which medium the output is going to: screen for a display, print for
printing or a print preview, all for both. If not written, all is assumed.
Media feature tests a measurable property of the medium and is written in parentheses. The
most common are width and height; alongside these are orientation for the screen’s
orientation, aspect-ratio for the ratio of width to height, resolution for pixel density. A
separate group tests not screen size but input capability: pointer reports the precision of
the pointing device, hover reports whether a hover behavior exists.
Logical operators. and requires two conditions together, a comma means “or” — if one of
the comma-separated branches is true, the query is true. not inverts a query.
Feature testing has two notations. The older one uses min- and max- prefixes and produces a
closed range: (min-width: 812px) means “width equal to or greater than 812.” The newer
one writes comparison operators directly: (width >= 812px). This second notation can also
build an open range and combine two bounds in a single condition.
Evaluation and the Cascade
A query’s result and a rule’s application are two separate steps. If the query is true, the rules inside it join the cascade; which declaration wins is decided by the cascade.
A commonly made assumption is wrong here: a media query adds no specificity. The
.station-layout selector inside the query and the .station-layout selector outside it have
the same specificity; when the two conflict, whichever is written later wins.
// query.mjs — media query evaluator and cascade result // Supported syntaxes: (min-width: Npx), (max-width: Npx), (width >= Npx), // (Npx <= width < Mpx), (orientation: portrait|landscape), and, comma, not. function featureValue(name, env) { if (name === "width") return env.width; if (name === "height") return env.height; if (name === "orientation") return env.width >= env.height ? "landscape" : "portrait"; throw new Error(`unknown feature: ${name}`); } function feature(text, env) { const s = text.trim().replace(/^\(/, "").replace(/\)$/, "").trim(); let m = s.match(/^(\d+(?:\.\d+)?)px\s*(<=|<)\s*([a-z-]+)\s*(<=|<)\s*(\d+(?:\.\d+)?)px$/); if (m) { const v = featureValue(m[3], env); const lowOk = m[2] === "<=" ? Number(m[1]) <= v : Number(m[1]) < v; const highOk = m[4] === "<=" ? v <= Number(m[5]) : v < Number(m[5]); return lowOk && highOk; } m = s.match(/^([a-z-]+)\s*(>=|<=|>|<)\s*(\d+(?:\.\d+)?)px$/); if (m) { const v = featureValue(m[1], env); const n = Number(m[3]); return m[2] === ">=" ? v >= n : m[2] === "<=" ? v <= n : m[2] === ">" ? v > n : v < n; } m = s.match(/^(min-|max-)?([a-z-]+)\s*:\s*(.+)$/); if (m) { const v = featureValue(m[2], env); const target = m[3].endsWith("px") ? Number(m[3].slice(0, -2)) : m[3]; if (m[1] === "min-") return v >= target; if (m[1] === "max-") return v <= target; return v === target; } throw new Error(`unresolved condition: ${s}`); } function queryMatches(query, env) { return query.split(",").some((branch) => { const parts = branch.trim().split(/\s+and\s+/); const negated = parts[0].startsWith("not "); parts[0] = parts[0].replace(/^not\s+/, ""); const result = parts.every((p) => feature(p, env)); return negated ? !result : result; }); } const environments = [ { name: "narrow portrait", width: 380, height: 780 }, { name: "medium portrait", width: 720, height: 1024 }, { name: "at threshold", width: 812, height: 1000 }, { name: "wide landscape", width: 1280, height: 800 }, { name: "short landscape", width: 900, height: 420 }, ]; const rules = [ { query: null, value: "single column" }, { query: "(min-width: 812px)", value: "two columns" }, { query: "(min-width: 1200px)", value: "two columns + wide margin" }, { query: "(orientation: landscape) and (max-height: 480px)", value: "compact header" }, ]; console.log("--- rules in source order, the last match wins ---"); rules.forEach((r, i) => console.log(`${i + 1}. ${r.query ?? "(base, no query)"} -> ${r.value}`)); console.log("\nenvironment width height matched rules applied"); for (const e of environments) { const matched = rules .map((r, i) => (r.query === null || queryMatches(r.query, e) ? i + 1 : null)) .filter((x) => x !== null); const winner = rules[matched[matched.length - 1] - 1].value; console.log( `${e.name.padEnd(16)} ${String(e.width).padStart(8)} ${String(e.height).padStart(9)} ` + `${matched.join(",").padStart(16)} ${winner}`, ); } console.log("\n--- the width at the edge of two ranges ---"); const pairs = [ ["(max-width: 812px)", "(min-width: 812px)"], ["(max-width: 811.98px)", "(min-width: 812px)"], ["(width < 812px)", "(width >= 812px)"], ]; for (const [a, b] of pairs) { console.log(`\n${a} | ${b}`); console.log("width first second state"); for (const w of [811, 811.99, 812, 813]) { const e = { width: w, height: 900 }; const x = queryMatches(a, e); const y = queryMatches(b, e); const state = x && y ? "BOTH matched" : x || y ? "one rule" : "NEITHER matched"; console.log( `${String(w).padStart(8)} ${String(x).padStart(5)} ${String(y).padStart(6)} ${state}`, ); } }
--- rules in source order, the last match wins ---
1. (base, no query) -> single column
2. (min-width: 812px) -> two columns
3. (min-width: 1200px) -> two columns + wide margin
4. (orientation: landscape) and (max-height: 480px) -> compact header
environment width height matched rules applied
narrow portrait 380 780 1 single column
medium portrait 720 1024 1 single column
at threshold 812 1000 1,2 two columns
wide landscape 1280 800 1,2,3 two columns + wide margin
short landscape 900 420 1,2,4 compact header
--- the width at the edge of two ranges ---
(max-width: 812px) | (min-width: 812px)
width first second state
811 true false one rule
811.99 true false one rule
812 true true BOTH matched
813 false true one rule
(max-width: 811.98px) | (min-width: 812px)
width first second state
811 true false one rule
811.99 false false NEITHER matched
812 false true one rule
813 false true one rule
(width < 812px) | (width >= 812px)
width first second state
811 true false one rule
811.99 true false one rule
812 false true one rule
813 false true one rule
The first table shows matches accumulating. At a 1280-unit environment, three rules are true at once; the value applied is the last of the true ones. This means the order rules are written in carries meaning: if queries targeting the same declaration are not ordered from the narrow range to the wide one, the wide range’s rule gets crushed by the narrow range’s.
The fourth rule works on a different axis. At a 900-unit-wide, short environment, both the width condition and the orientation-and-height condition are true; the last one wins. Width is not the only measure: in an environment held horizontally with limited vertical space, dropping the header to a compact size is a decision that has nothing to do with width.
The Edge of Ranges
The second section tests the point where two neighboring ranges meet, with three notations.
The first pair is the classic mistake. Both max-width: 812px and min-width: 812px are true
at 812 units, because both prefixes produce a closed bound. At that exact width, two rules
apply at once, and the result is left to source order. The mistake can be invisible — the
result may already be the wanted rule — but if the declarations differ, a mixture of the two
applies.
The second pair tries the commonly suggested fix: pulling the upper bound down slightly. The overlap goes away, but a gap takes its place. At a width of 811.99 units, no rule matches at all. Fractional widths are not a theoretical possibility; zooming and scaling produce fractional CSS pixel values.
The third pair genuinely splits the range with comparison notation. (width < 812px) and
(width >= 812px) exclude each other and together cover the entire number line. Neither
overlap nor gap remains.
The most solid approach is to never write the second range at all. If the base style describes
the narrow layout, only (min-width: 812px) is written; no rule is needed for the lower range,
and the edge-point problem never arises.
The Unit in the Query
Wherever a length is written, unit choice is a decision. In a media query, this decision has an
unusual consequence: em in a query resolves not against the font size written on the root
element, but against the user’s default font size.
// unit.mjs — the relation between the media query length unit and the user's font size // em in a query resolves not against the root element's font-size, but against the // user's default font size. // content requirements measured at a 16px default const MAIN = 480, ASIDE = 260, GAP = 24, PADDING = 48; const BASE = 16; const NEEDED_16 = MAIN + ASIDE + GAP + PADDING; const emThreshold = NEEDED_16 / BASE; // the same threshold's em counterpart const pxThreshold = NEEDED_16; console.log(`width needed for two columns at a 16px default: ${NEEDED_16}px`); console.log(`if written in px: (min-width: ${pxThreshold}px)`); console.log(`if written in em: (min-width: ${emThreshold}em)\n`); console.log("user needed px query em query px deviation em deviation"); console.log("default width triggers at triggers at"); for (const d of [12, 16, 20, 24, 32]) { const needed = (NEEDED_16 * d) / BASE; // scales because every measure is rem-based const pxTrigger = pxThreshold; const emTrigger = emThreshold * d; console.log( `${String(d + "px").padStart(10)} ${needed.toFixed(0).padStart(8)} ` + `${pxTrigger.toFixed(0).padStart(10)} ${emTrigger.toFixed(0).padStart(10)} ` + `${(pxTrigger - needed).toFixed(0).padStart(12)} ${(emTrigger - needed).toFixed(0).padStart(12)}`, ); } console.log("\n--- what happens at a 20px default, at 812px width ---"); const d = 20; const scale = d / BASE; const area = 812 - PADDING * scale - GAP * scale; const main = (area * 2) / 3, aside = area / 3; console.log(`main section: ${main.toFixed(1)}px (needs ${(MAIN * scale).toFixed(1)}px)`); console.log(`aside: ${aside.toFixed(1)}px (needs ${(ASIDE * scale).toFixed(1)}px)`); console.log(`the px query opens two columns at this width; the em query would have waited until ${(emThreshold * d).toFixed(0)}px`);
width needed for two columns at a 16px default: 812px
if written in px: (min-width: 812px)
if written in em: (min-width: 50.75em)
user needed px query em query px deviation em deviation
default width triggers at triggers at
12px 609 812 609 203 0
16px 812 812 812 0 0
20px 1015 812 1015 -203 0
24px 1218 812 1218 -406 0
32px 1624 812 1624 -812 0
--- what happens at a 20px default, at 812px width ---
main section: 481.3px (needs 600.0px)
aside: 240.7px (needs 325.0px)
the px query opens two columns at this width; the em query would have waited until 1015px
The right two columns make the comparison on their own. When a user changes their default font size, every measure that is tied to the base unit grows together, and so does the width the two columns need. A query written in pixels cannot see this change and leaves the threshold in place: at a 20-unit default, the layout switches to two columns 203 units short of what is needed. A query written in the base unit shifts along with the need, and the deviation stays zero in every row.
The last block gives the deviation’s concrete counterpart: when the threshold triggers early, the main section drops to 481, the aside to 241 units — while the same content at the enlarged text size needs 600 and 325. Enlarging text is an adjustment made to keep a page readable; if layout does not scale along with that adjustment, the adjustment itself breaks the result.
The rule is this: length in a media query is written in the base unit. The same reasoning applies to height and aspect-ratio tests too.
Media Types and Feature Queries
Two tests other than width show that media queries are not only about screen size.
@media print rules apply to print output. On a printed measurement page, there is no place
for a navigation bar, a filter field, or link decorations; in exchange, link addresses need to
be written into the text and page breaks need to be controlled. The break-inside declaration
introduced in the multi-column layout lesson works here too.
A second structure, separate from media queries but written the same way, is the feature
query. @supports tests whether a declaration is recognized — not the medium, but whether the
style language resolves it. The question left open in the Grid and Flexbox Choice lesson is
answered from here: where the subgrid value is recognized, the inner grid inherits the
container’s lines; where it is not, it defines its own tracks.
/* station.css — step 8: derived threshold and printing */ @media (min-width: 50.75em) { .station-layout { grid-template-columns: minmax(480px, 2fr) minmax(260px, 1fr); grid-template-areas: "masthead masthead" "measurements aside" "location aside"; } } @supports (grid-template-columns: subgrid) { .measurement-card { display: grid; grid-template-rows: subgrid; grid-row: span 3; } } @media print { .nav, .measurement-filter { display: none; } .station-layout { grid-template-columns: minmax(0, 1fr); grid-template-areas: "masthead" "measurements" "location" "aside"; } .measurement-table tr { break-inside: avoid; } }
Three blocks, three separate questions. The first tests the environment’s width and changes only structure; its measures come from the intrinsic sizing in the base style. The second tests whether a declaration is recognized, and where it is, binds the card rows to the outer grid’s lines. The third tests where the output is going.
What the three share is that none of them undoes what the base style wrote. Each block adds a new declaration within its own condition.
Summary
- A media query is built from a media type, media features written in parentheses, and the
and, comma, andnotoperators; an unwritten type is taken asall. - A query adds no specificity; its rules compete at the same specificity as those outside it, and source order resolves conflicts.
- The
min-andmax-prefixes produce a closed bound: two queries written at the same number both match at that point; nudging the bound with a fraction turns the overlap into a gap. - Comparison notation splits a range with neither overlap nor gap; writing a single one-directional query removes the problem entirely.
- The base unit in a query resolves against the user’s default font size and shifts the threshold together with content; a threshold written in pixels cannot see this adjustment.
@media printtests the output’s medium,@supportstests whether the style language recognizes a declaration; both add declarations within their condition without undoing the base style.
Next Step
Every query in this lesson looked at one thing: the viewport itself. But what determines how a measurement card should look is not the screen’s width, it is the width of the place the card is placed inside. The same card can sit in a narrow side column on a wide screen; because the viewport is wide, the card opens its wide layout and gets squeezed. The next lesson changes the subject of the question: how does a component query the size of the container it is in?
To keep your progress and take notes, Log in
My notes
Log in to take notes.