Making sense of CSS Media Queries (in the year 2019)


Status: first draft, corrections immensely appreciated! 🙏

Media queries have been published in 2012 as a W3C Recommendation and benefit from near-universal support across browsers. Yet here I am, in the year 2019, wondering how they work. Like, I knew how to use them (mostly) successfully, but some parts still eluded me. In this article I set out to clarify some of them.

What are media queries?

Media queries are a mechanism in CSS that lets us test certain aspects of the browser or device that displays our web page. These aspects are external to the page, and not (usually) influenced by the styles applied to it.

We're going to look at a tiny slice of the whole media query pie: the width and height media features, with their associated min- and max- queries. They tell us things about the space allocated for the web page inside the browser.

A media query looks like this:

/* Makes the page red whenever there are at least 400px available for it in the browser. */
@media (min-width: 400px) { html { background: red; }
}

Are dimensional media queries useful?

Knowing what size the browser makes available for our page is useful in case we need to make adjustments to the layout based on the constraints imposed by this size. They have been a staple of Responsive Web Design techniques for ages.

With new ways of expressing layout (Flex and Grid), CSS gains alternatives to media queries for layouts that adapt well to wildly different sizes. Every Layout by Heydon Pickering and Andy Bell is an excellent resource to get acquainted with techniques using flex and grid properties to solve situations where we'd normally reach out to our trusty queries. Even then, we may need media queries to customize the layout further.

Dimensional media queries also show up in the sizes attribute on <img> and <source> elements to enable responsive images, appropriate to the medium they're displayed in.

All in all, they matter, and it's useful to know how they work.

Units for media queries

Screens have pixels. The browser takes part of those device pixels to display a web page in. The narrower the browser, the fewer pixels we get. CSS has the px unit, short for pixel. Let's skip, for a little while, over how CSS pixels are not the same thing as device pixels. In most cases, they sure feel like they're the same.

Writing media queries in pixels is pretty straightforward, feels intuitive, and produces the result we expect. On the other hand, they're stiff, and px in general goes against the grain of the web, where content should flow like water regardless of the vessel holding it, not be immobilized by the straitjacket of pixel perfection.

What about font-relative CSS units — em, rem, and their friends — which allows us to define media queries in harmony to the content on the page? What do they relate to exactly?

According to the spec, they relate to the initial value of font properties. For em and rem, the relevant property is font-size, which most browsers initially set to 16px.

As such, changing the font size of the html element:

html { font-size: 1.25rem;
}

...disconnects the meaning of rems in your styles from the meaning of rems in media queries: 1rem in styles is now equivalent to 20px, while in media queries 1rem, and 1em for that matter, is still 16px.

Why would the spec mandate this in the first place?

The CSS Working Group explain in a FAQ entry that selectors can't depend on layout. If rem / em media queries depended on the font-size of the html element, you could create an infinite loop:

html { font-size: 1rem;
} @media (min-width: 60rem) { html { /* Setting this invalidates the media query selector that triggered this style. */ font-size: 10rem; }
}

It's a bit weird, but it makes sense. You take a mental note of this peculiarity, and make sure you always think of media queries in terms of initial sizes. Suddenly,

The trouble with Safari

Current browsers generally adhere to the spec in regards to font-relative units in media queries.

The big outlier here is Safari, on both desktop and mobile. It follows the spec for ems — 1em in media queries is 16px regardless of the font size on the html element. But it scales rems in accordance to the html element (WebKit Bug 156684). Since this breaks the "no layout-dependent selectors" CSS rule, we can actually witness the infinite loop described above.

Got me there, Safari! But since we can use em and rem interchangeably, if we stick to em, we bring Safari's behavior in line with the other browsers.

Zooming in

Remember the whole pixels are not pixels thing? When you zoom into a web page, the distinction becomes relevant. On screens, relative units resolve to px. These are CSS pixels, not physical pixels on the device. But at the 100% zoom level, they're the same thing.

When you change the zoom level, modern browsers will adjust the ratio between CSS pixels and device pixels. 1px in CSS ends up meaning two device pixels, or four, or half a pixel. This is a brilliant way to keep the layout mostly intact, regardless of the choice of CSS units in stylesheets.

1rem is still 16px when you zoom in, but now there are fewer (CSS) pixels available to your page. This reflects in the media queries: min-width has a lower threshold — fewer pixels, fewer rems, fewer ems. Suddenly,

The trouble with Safari (again)

Safari on macOS has a bug where em and rem units in media queries factor in the browser's zoom ratio (WebKit Bug 156687).

As you zoom in, 1rem becomes 20px, and then 28px in media queries. This is concerning because the zoom factor is now doubly-represented: once by the fact that we get fewer CSS pixels for the page, and again by it inflating our em and rems.

With iOS 13, and the new iPadOS, Safari also introduced zoom controls for mobile users. Thankfully, these work correctly, in accordance to the rest of the browsers. But a quick glance at desktop Safari 13 (currently in the Technology Preview stage) reveals it still has the problem.

Changing the font settings

Zooming in and out is only one of the many ways users can customize their experience of a web page. Browsers also allows them to change font settings, in roughly two distinct ways:

Changing the default size

When the user changes the default size to something other than 16px, it becomes the new normal. It's now the basis of media queries, and the initial value for the html element's font size.

It's worth noting that absolute units in the html font size in stylesheets overwrite the user preference in regards to how text is shown on the page, but will not alter the basis of media queries. So, in addition to being insensitive to the user, you further disconnect the notion of 1rem in media queries from what's actually shown on the page.

Minimum font size

As a supplement to the default font size, browsers also offer a minimum font size. It does not normally affect the basis of em and rems in media queries.

Safari comes with a single setting, never use font sizes smaller than X. It gets factored into the initial font size, affecting media queries in ways I can't quite make heads and tails of, so it's left as an exercise to the reader. :-)

Text-only zoom

Firefox has a Zoom text only feature which alters the way zoom works. It disables the scaling of CSS pixels, and instead factors in the zoom level into the initial font size.

That means that media queries using font-relative units (em, rem) get a new basis, matching the initial font size of the <html> elements.

To really drive the feature home, and make it work as expected on pages which might use an absolute font size on the HTML element, it also factors in the zoom level into the computed value of its font size.

Everything works splendidly. The big losers here are px units. When you zoom in, the content gets bigger and bigger, and nothing changes in queryland, since there's no scaling of CSS pixels.

One less reason to ever use them!

Conclusion

Some takeaways from this foray into media queries are:

  • Your best bet for predictable behavior across the browser landscape right now is to use em units in media queries. rems get weird in desktop Safari, but once the bug is fixed, they're totally interchangeable
  • px-based media queries bear little relationship to your content, and are best avoided. Plus, they fail Firefox's text-only zoom.
  • Remember that when you change the font size on the html element — and when you do, do it with relative units — you're ever so slightly shifting the meaning of 1rem in styles vs. 1rem / 1em in media queries.

Appendix: Deprecated media queries

CSS Media Queries 4 deprecates the use of device-width, device-height, and device-aspect-ratio, which previously referred to physical pixels rather than CSS pixels. Firefox has already started reporting these in CSS pixels, and Edge seems to do so as well (I can't tell 100% in Browserstack), but Safari and Chrome continue to report physical pixels at the time of writing.

As CSS authors, these queries are not recommended.

Appendix: Methodology

I have tested with the following browsers so far:

  • Firefox macOS
  • Chrome macOS
  • Safari macOS
  • Safari iOS 13
  • Safari iPadOS
  • Microsoft Edge (via Browserstack)

I've made a diagonstics page to gather information the browsers exposes to CSS and JavaScript APIs.

Measuring Media Queries

JavaScript has access to CSS via the CSS Object Model API (CSSOM for short). We can match media queries from JavaScript using the Window.matchMedia() method:

let query = window.matchMedia('(min-width: 10rem)');
if (query.matches) { // ...
} else { // ...
}

This allows us to check min-width against a certain value to see if it matches or not, but not to find the actual breakpoint beyond which the query stops matching. The CSSOM View Module specification adds JS-accessible values for various measurements, but to rule out possible inconsistencies in how browsers report them, I prefered to obtain the numbers straight from the horse's mouth.

We can (ab)use matchMedia to learn the breakpoint of our current browser/device by asking repeatedly with different values. We're going to use the bisection method to avoid making a gazillion queries:

function find_min_width() { let start = 0; // 0 px let end = 1000000; // 1 million px let precision = 1; // whole pixels while (end - start >= precision) { let midpoint = start + (end - start) / 2; let query = matchMedia(`(min-width: ${midpoint}px)`); if (query.matches) { start = midpoint; } else { end = midpoint; } } return Math.round(start);
}

This function returns the breakpoint value, in pixels, of the min-width media query for our current environment. With some adjustments to the precision we can do the same for ems and rems.