---
title: 'Media Queries'
source: 'https://academia.sh/en/courses/layout-and-responsive-design/media-queries'
course: 'Layout Systems and Responsive Design'
language: en
updated: '2026-08-17T18:09:28+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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**.

```js
// 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.

```css
/* 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?
