Targeting smartphones with CSS
As of this writing, this site uses the following CSS media queries to target smartphones in portrait orientation:
The max-aspect-ratio
query ensures that we only target devices in portrait orientation. We use pointer
to check for devices that don't have precise pointers (i.e. mice). We also use min-resolution
to limit ourselves to high-resolution devices such as typical smartphones; this particular query is not fully precise or reliable, but for typical Android browsers, it would affect devices with a DPI of 320 or greater.
So why this particular set of queries?
Well, there's no exact way to detect smartphones using CSS media queries. Actually, let's be more specific: there's no exact way to detect high-resolution, small-size, touch-only devices using CSS media queries or similar features. Media queries related to input are broken due to mistakes made by specific hardware vendors, with browsers making no attempt to work around some notable cases; and media queries related to resolution are inexact and inconsistent across platforms, because they're subject to CSS abstractions that obscure the very traits we're trying to query.
Let's talk about that.
The @media
queries we can't use
CSS offers a wide variety of media queries, but many of the ones that seem like they'd work for smartphones are non-starters for one reason or another.
The most intuitive choice would be the handheld
device type, but this is deprecated, and current standards require that it match no device.
We can target touch devices, but that alone isn't enough, because not all touch-capable devices are smartphones: tablets and even many laptops have touch-capable screens. We would instead want to target devices that are touch-capable and hover-incapable despite having screens; this would cover devices with touchscreens and no mice. Unfortunately, as of this writing, the hover
feature query tests incorrectly in many (most?) Samsung devices manufactured since 2019, and neither Firefox for Android nor Chromium for Android make any attempt to work around this. Samsung has roughly 18% of the global market share for mobile devices, so this is a widespread issue. It's also, as of this writing, completely unmentioned on CanIUse, MDN, and similar sources, giving the false impression that hover
is reliable.
In lieu of targeting hover-incapable devices, we could target devices whose primary pointer is coarse. However, that will catch laptops running Windows 10, so it's a no-go as well.
We can't rely on these queries alone, but we can pair a pointer: coarse
query with a resolution
query in order to estimate what devices are smartphones.
The resolution
media query
The resolution
query is meant to allow us to test the pixel density of the device displaying the page. This sounds like a way to check the device's pixels per inch (more often called "dots per inch," or "DPI"), and indeed, the three resolution units that CSS adds for use in these queries include dpi
. Unfortunately, however, this is misleading. Per the spec, the resolution
query "does not refer to the number of device pixels per physical length unit, but the number of device pixels per CSS unit." In other words, the dpi
unit refers to the number of device pixels per CSS inch, and CSS pixels and inches aren't actually equivalent to real-world pixels and inches. This means that when we use resolution
queries, we're not actually querying the device's DPI; we're querying an abstracted value based on parameters that are chosen entirely at each browser vendor's discretion.
Browsers lie about DPI
The CSS standard's definition of a "pixel" is counterintuitive. Large amounts of web-based content were authored back when 96 DPI displays were the norm, and this content broke when rendered with higher-DPI displays, so the CSS spec was updated to require that CSS actively lie about the current DPI. The standard now mandates fixed relationships between all length units, physical and digital, and assumes a DPI of 96 in order to allow this; ergo 96px
is equivalent to 1in
regardless of the actual properties of the device displaying the page. Devices which support CSS are allowed to implement these fixed relationships in one of two ways, choosing at their discretion:
- Map
1px
to a single device pixel, thereby making all physical units (e.g.1in
) inaccurate for DPIs other than 96. This implies a need for the device to lie to pages and JavaScript about the DPI, screen size, and similar, in order to maintain the 96 DPI conversion factor. - Map
1px
to one ninety-sixth of a physical inch, thereby making the basic pixel unit inaccurate on devices with DPIs other than 96:1in
is accurate, but1px
no longer maps to a single hardware pixel.
The term for this is "anchoring:" you anchor either the physical units to the digital, or the digital to the physical. The standard encourages screen devices to make the former choice, and print media to make the latter choice.
The problem is that mobile web browsers only partially adhere to this part of the standard. They do treat 96px
and 1in
as equal, but they don't generally use either unit as an anchor: 1px
rarely corresponds to a single device pixel, and 1in
rarely corresponds to a physical inch. Instead, Android browsers will anchor both units to a separate abstraction; and because CSS resolution units are defined in terms of CSS length units, this means that on a typical Android browser, 96dpi
is not actually equivalent to 96 DPI. Rather, 96dpi
is equal to 1dppx
, where the dppx
unit indicates the number of device pixels per CSS pixel. In essence, when we query the resolution
in dppx
, we are directly testing against the scaling factor that a browser is applying to the page in order to fake 96 DPI, and this is the only tie we have to the device's real-world DPI.
(Zooming on desktop browsers has a similar effect: if you zoom to 200%, then a single CSS pixel encompasses two device pixels, and so the resolution
becomes 2dppx
. Zooming on mobile devices is implemented differently to avoid reflow, and so doesn't affect the resolution
.)
So what values do we use in a query?
If the resolution units don't directly measure the device's DPI — if 96dpi
isn't 96 DPI on the devices we need to check for — and if those devices don't fully follow the CSS spec with respect to unit anchoring, then how can we know what values to use in our queries?
As of this writing, MDN offers no guidance in its articles on resolution queries or resolution units, and web.dev, written by Chrome developers, likewise offers no guidance in its article on media queries. All three articles take the dpi
unit at face value, describing it as the "dots per inch" rather than the "dots per CSS inch;" you have to read the CSS spec yourself to get a more accurate description. However, the MDN article on resolution queries has a "See also" section which links to the MDN article for the window.devicePixelRatio
JavaScript property, and there, we can find a few tips.
MDN's definition of window.devicePixelRatio
roughly matches the definition of CSS's dppx
resolution unit, and their code samples use window.matchMedia
to dynamically construct a resolution
query based on window.devicePixelRatio
and measured in dppx
. If we double-check the spec, we can confirm what these code samples show: window.devicePixelRatio
is basically the same value against which a resolution
query measured in dppx
will be tested. MDN states that 96 DPI displays have a device pixel ratio of 1, Retina displays are expected to have a ratio of 2, and smartphones often have a ratio greater than 2, so we would expect the same to be true for dppx
values.
Modern smartphones, then, should match min-resolution: 2dppx
, and we can check the current resolution in dppx
by logging window.devicePixelRatio
. Case closed.
Except that something didn't feel right to me. Those claims about the device pixel ratio values felt very "Source: Trust me, bro." A Retina display is "expected" to have a value of 2, but "modern mobile device screens" have values above 2? How far above 2? Looking at the Retina display claim, specifically: these displays are "expected" to have a resolution of 2dppx
, which is equal to 192dpi
, but an iPhone 4 — one of the first devices to feature a Retina display — has a 326 DPI screen. We're off by a factor of 1.698 for a device released back in 2010. The iPhone 16 Pro, released this year, has a Retina display with a DPI of 460; for that, we're off by a factor of 2.4. Is that because MDN is wrong, or is it because Apple devices are anchoring CSS units to an abstraction that's off by factors of 1.7 or 2.4, such that the devices would test as 2dppx
despite being well above 192 DPI? Similarly, a Samsung Galaxy S24 tests as having a resolution of 2.625dppx
in Google Chrome, which is 252dpi
, but that phone's DPI is commonly estimated to be about 415, and the tests I've run in Chrome show that the browser is sizing things as if the screen were 420 DPI. Taking Chrome as correct, we're off by a factor of 1.667 for that phone.
So, I dug around Chromium's source code, and I found that the value of window.devicePixelRatio
(i.e. the resolution
we want to query) ultimately comes from the DisplayMetrics.density
value in Android's API. This is a scaling factor used to normalize physical screen resolutions to "density-independent" resolutions, which simulate 160 DPI similarly to how CSS simulates 96 DPI. Chromium uses the density value in other accommodations that it makes for mobile users, too: in Chromium documentation written for Android app developers, we see that some meta viewport
settings use the screen resolution as measured in Android's "density-independent pixel" unit, shearing the unit off and treating the numbers as CSS px
values. Sure enough, 420 ÷ 160 = 2.625, matching what we know to be the resolution
in dppx
.
What this means, then, is that on Android devices, 1dppx
and 96dpi
are equal to a real-world 160 DPI. Given a specific Android device, we need only divide its real-world DPI by 160 to get its resolution in dppx
. We therefore also know that following MDN's guidance regarding window.devicePixelRatio
, and treating 2dppx
or greater as a smartphone resolution, would work for Android smartphones with 320+ DPI. (It would also match desktop browsers zoomed to 200%, of course.)
Based on this, we know we can match modern smartphones using a media query that targets coarse-pointer or no-pointer devices with a resolution of 2dppx
or greater. The added pointer queries reduce the likelihood of us targeting a zoomed-in desktop device. We know what we're working with and we know how to use it. Case closed.
Case closed, right?
...
Hey, wouldn't it be funny if there were relatively recent versions of Chromium that applied an additional scaling factor to the Android screen density without telling anyone?
Older Chromium versions and OS-level font sizes
Older versions of Chromium on Android will check your OS-level font size setting, and multiply that into the screen density. They'll also take the layout viewport size requested by a meta viewport
tag (be it an integer constant or a named constant like device-width
), and divide that by the font scaling factor. This results in changes to the resolution as measured in dppx
, and it also results in changes to the layout viewport's size. These two changes almost cancel each other out, except that font sizing varies at the page level. Firefox doesn't appear to have ever done this, which has led to people occasionally being confused by inconsistent font sizing between the two browsers.
I don't know the exact range of Chromium versions that do this, but the latest version I've seen this behavior reproduced in is Chromium 124 (released six months ago), and I know it doesn't happen with version 131. Using our earlier example of a Samsung Galaxy S24, if you were to use one of these older Chromium versions with your OS-level font scale set to about 90%, then your resolution would be about 2.3625dppx
, such that 1dppx
is equal to a real-world DPI of 177.7̅. If you visit a page that uses a meta viewport
tag to request a layout viewport width of 1000, it'll end up with a layout viewport width of 1111.
Most people will be using up-to-date versions of stock Google Chrome, or at least Chromium forks that stay up to date, but there are some forks that tend to be several versions old. Bromite is one example: as of this writing, it's out of date and based on Chromium 108, though there exists a "Cromite" fork by a separate author which is keeping up with Chromium releases. People who browse with independently-made Chromium forks on Android, then, may still have their OS-level font size affect resolution
queries and CSS sizing behaviors in strange ways.
For these users, 1dppx
is equal to 160 DPI divided by the font size system setting, such that smaller font sizes result in the value matching higher DPIs.