The dark art of CSS media queries

No area of web development has left me more bemused than the parsing logic of CSS media queries.

It’s been 2 years since I first published the npm package media-query-parser - a package whose only aim is to parse a media query according to the spec.

In the process, I’ve learnt plenty of weird quirks that even the most seasoned of web developers might find surprising; so here they are!


Media queries aren’t what you think…

You may think of the part between the @ and the { as a media query; but it is actually a “media query list”.

That’s more than just semantics; they have different behaviour. A media query list containing an invalid media query is still a valid media query list.

For instance: @media invalid-query, screen { /* … */ } contains a syntactically invalid query (invalid-query); yet screen still be truthy, and so the inner CSS block can still be active.

So far - so sensible. But what happens when we have the block:

@media { /* ... */ }

Well - intuitively, a media query block is active if any individual query matches; as there are none it will never match.

Alas, our attempted to apply intuition is in vain. An empty media query list is considered to be always true; equivalent to @media all {.

Comma behaviour

So given that - with the exception of an empty media query list - it checks whether any comma-separated list is true, you might expect that this:

@media [, all, ] { /* ... */ }

…would ignore the [ and ]. Alas… that isn’t the case.

Only commas that are not nested inside (parentheses,), [square brackets,] or a function(expression,) are considered media query separators. Technically also braces though the left brace is a media query list terminator and as such can’t appear inside.

No valid media query contains a %

Perhaps not a huge shock - after all (min-width: 100%) wouldn’t be much use - but this isn’t just true because no feature uses them yet; but media query values cannot be percentages; only numbers, units, ratios and idents (i.e. landscape in (orientation: landscape)).

…or calc()

The above also means that functions, like calc() or var() are also not valid media queries.

Initially that might sound like a shame; but supporting them would create a potential ambiguity:

:root {
  background: blue;
  --x: 1000px;
}

/* when resizing the window from 999px to 1000px
   what background should the page have? */
@media (min-width: var(--x)) {
  :root {
    background: red;
    --x: 2000px;
  }
}

For this reason (I assume) the spec does not allow functions like calc() or var() in media queries.

Some browsers do support it, but they probably shouldn’t.

It is still possible to nuke a media query list

@media all, all' {
  /* ... */
}

The above code will never match, as the unterminated single quote starts an invalid CSS string token; it is never terminated.

Terminating it with @media all, all'' fixes the CSS lexer error and so - weirdly - the media block will be active as now only the second media query is considered an error.

not is not not, or is not not not?

These are all valid (though not necessarily matching) queries:

@media not all { /**/ }
@media not all and (max-width: 0px) { /**/ }
@media not all and not (max-width: 0px) { /**/ }
@media not all and not (not (max-width: 0px)) {
  /**/
}

not can be highly unintuitive. So much so that I’d trial

Most notably, the second and fourth conditions should match on all media larger than 0px wide.

This is because when not starts* a (valid) media query, it negates the whole media query.

*ignoring whitespace and comments but not ignoring parentheses

If that sounded confusing, it’s because it is confusing.

There’s so much more…

So far I’ve only touched on the actual parsing logic for media queries; but the spec also includes which features (like width, aspect-ratio, hover etc) should be supported by implementations, and that gets even messier.

But I shall save that for another time.