Skip to content
academia.sh

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, and not operators; an unwritten type is taken as all.
  • A query adds no specificity; its rules compete at the same specificity as those outside it, and source order resolves conflicts.
  • The min- and max- 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 print tests the output’s medium, @supports tests 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.

Start typing to search.

↑↓ Esc navigate · open · close