🍱 Lunchbox Hands

css

A CSS px Is Not a Device Pixel: PPI, devicePixelRatio, and rem

The CSS px is a viewing-angle unit, not a dot on your panel. How PPI, devicePixelRatio and min-resolution connect them, and why rem sits on top of all three.

Open DevTools on an iPhone 15 Pro and the viewport reports a width of a few hundred pixels. Apple’s spec sheet says the same screen is 1,179 pixels wide. Both numbers are correct, because they measure different things. A CSS px is not a dot on your screen. It is a unit defined by how big something looks at arm’s length, and the browser scales it onto however many physical pixels your panel happens to have. There are three layers here: physical pixel density (PPI), the ratio between device pixels and CSS pixels (devicePixelRatio), and font-relative units (rem) stacked on top. Most confusion about “why is my 16px text a different size on every device” comes from mixing those layers up. This post takes them one at a time, using real numbers from our screen PPI calculator and px to rem converter.

The CSS pixel is an angle

Section 6.2 of CSS Values and Units Level 4 fixes all the absolute units to each other: 1in = 2.54cm = 96px, 1pt = 1/72in, so 1px = 1/96in. That looks like a physical definition until you read the next part. The spec defines a reference pixel as “the visual angle of one pixel on a device with a device pixel density of 96dpi and a distance from the reader of an arm’s length. For a nominal arm’s length of 28 inches, the visual angle is therefore about 0.0213 degrees.”

We can check that number. One ninety-sixth of an inch viewed from 28 inches subtends atan((1/96) / 28) = 0.02132°, which matches the spec. Turned back into a length at that distance, 0.0213° is about 0.264 mm.

Then the spec says which unit is the anchor. For print, the physical units (inches, centimetres) are real. For screens, “it is recommended instead that the anchor unit be the pixel unit”, and “if the anchor unit is the pixel unit, the physical units might not match their physical measurements.” In other words, on a monitor 1in in CSS means 96 CSS pixels, and 96 CSS pixels means whatever the browser decided a CSS pixel is on that screen. It is not a promise about a ruler. That is why width: 1in almost never measures one inch.

Physical density: PPI is Pythagoras

The panel itself has a fixed grid of physical pixels. Its density is the number of pixels along the diagonal divided by the diagonal length:

PPI       = √(width² + height²) ÷ diagonal_in
dot pitch = 25.4 ÷ PPI            (mm per pixel)

That is exactly what our calculator’s computeScreen does: Math.hypot(widthPx, heightPx) / diagonalIn for PPI and 25.4 / ppi for dot pitch. Running it on some of its built-in presets:

ScreenResolutionPPIDot pitch
24” 1080p monitor1920×108091.790.2767 mm
27” 1440p monitor2560×1440108.790.2335 mm
27” 4K monitor3840×2160163.180.1557 mm
MacBook Air 13.6”2560×1664224.510.1131 mm
iPhone 15 Pro 6.1”1179×2556461.440.055 mm
55” 4K TV3840×216080.110.3171 mm

A 27” 1440p monitor has a diagonal of 2,937.2 pixels, and 2,937.2 ÷ 27 = 108.79. A 32” 1440p monitor comes out at 91.79 PPI, the same density as a 24” 1080p panel. That’s why the two look equally sharp even though one has 78% more pixels.

The spec-sheet figures differ a little from ours, and the reason is worth knowing. Apple’s iPhone 15 Pro tech specs list “2556‑by‑1179-pixel resolution at 460 ppi” on a “6.1‑inch” display, and a footnote says the screen “is 6.12 inches diagonally” when measured as a rectangle. Using 6.1 gives us 461.44. Using 6.12 gives 459.94, which rounds to Apple’s 460. The MacBook Air 13-inch (M3) specs list 2560×1664 “at 224 pixels per inch”, and we compute 224.51 from the rounded 13.6”. Marketed diagonals are rounded, so if you want the precise figure, type the footnote diagonal into the calculator. It accepts decimals like 6.12.

The Apple numbers also show where “Retina” sits. A Retina MacBook is about 224 PPI and a current iPhone is about 460. That’s roughly double, because you hold a phone much closer to your face.

devicePixelRatio connects the two

So we have a physical grid (1,179 pixels across the iPhone) and a CSS grid (a few hundred CSS px across the viewport). The number that relates them is window.devicePixelRatio. MDN defines it as “the ratio of the resolution in physical pixels to the resolution in CSS pixels”, or put another way, “how many of the screen’s actual pixels should be used to draw a single CSS pixel.” A value of 1 “indicates a classic 96 DPI display”, 2 “is expected for HiDPI/Retina displays”, and “modern mobile device screens … often yield a devicePixelRatio value greater than 2.”

The physical size of a CSS pixel on a given screen is simply PPI ÷ DPR CSS pixels per real inch:

ScreenPPIDPRCSS px per physical inchSize of one CSS px
27” 1440p at 100% scaling108.791108.790.233 mm
MacBook Air 13.6”224.52112.250.226 mm
iPhone 15 Pro4603153.330.166 mm

(The DPR values are assumptions for the worked example. Your OS scaling setting changes them.) None of these is exactly 96. The two desktop-distance screens land close to each other, around 0.23 mm, and the phone’s CSS pixel is physically smaller because you hold it closer. That’s the reference pixel idea in practice: roughly the same angle at your eye, not the same millimetres.

Two things change DPR that people don’t expect. MDN notes that “page zooming affects the value of devicePixelRatio”: zoom to 200% and each CSS pixel covers twice as many device pixels. But “pinch-zooming does not affect devicePixelRatio.” Dragging a window to a monitor with different density changes it too, which is why MDN’s example watches for changes with matchMedia.

Why no website can measure your screen in inches

With DPR and window.screen, a page can work out how many physical pixels the display has. Screen.width “returns the width of the screen in CSS pixels”, so multiplying by DPR gives device pixels. That’s what the Detect my screen button in our calculator does. Its detectPhysicalPixels function computes Math.round(cssWidth * dpr). A laptop reporting 1440×900 CSS px at a ratio of 2 comes back as 2880×1800.

Look at what’s missing, though. There’s no screen.widthInInches in those APIs, and none of the standard web APIs a page can call (Screen, devicePixelRatio, media queries) report physical size either. As the first section showed, even the CSS in unit is defined in CSS pixels. Pixel count is only half of the PPI formula, so our tool fills in the resolution, clears the diagonal field and asks you to type it. The detect message says “browsers never expose the physical size of a display, so no website can measure inches for you.” The page’s FAQ gives the likely reasons: it would be another fingerprinting signal, and operating systems often don’t know the physical size reliably anyway.

There are two honest caveats about the detector. It’s only as good as the DPR the browser reports, so reset browser zoom to 100% first (the tool tells you this). And with fractional scaling it can be off by one. If a browser reported a rounded 1707×960 CSS px at a ratio of 1.5, our function would return 2561×1440, not the panel’s real 2560×1440. When you know the true resolution from the spec sheet, type it in.

resolution media queries speak in dppx

CSS can ask about density directly with the resolution media feature, which “can be used to test the pixel density of the output device.” It’s a range feature, so you’ll mostly see min-resolution and max-resolution. It accepts dpi, dpcm, and dppx (dots per CSS pixel, also written x):

/* Serve the sharper background on screens with a DPR of 2 or more */
@media (min-resolution: 2dppx) {
  .hero { background-image: url([email protected]); }
}

The units line up with the CSS pixel, not with physical inches. CSS Values 4 defines 1dppx = 96dpi, so min-resolution: 192dpi and min-resolution: 2dppx are the same query. Neither one asks whether the panel is 192 PPI. A MacBook Air at 224 physical PPI matches 2dppx because its DPR is 2, and a 27” 1440p monitor at 108.79 PPI doesn’t match (min-resolution: 1.1dppx) at 100% scaling even though it has more than 96 physical PPI. Once again, the query measures CSS pixels, not the panel.

rem stacks on top: a unit of the user’s text size

Now for the layer web developers deal with every day. The spec defines em as “equal to the computed value of the font-size property of the element on which it is used” and rem as “equal to the computed value of the em unit on the root element.” If you set nothing, that root is the browser default. MDN’s font-size page says it “is usually 16px.”

That default is where the accessibility argument comes from. MDN warns that “defining font sizes in px is not accessible, because the user cannot change the font size in some browsers.” A reader who raises their browser’s default text size changes what 1rem means, and a hard-coded 16px ignores them. Browser zoom scales both units, since it changes DPR. The font-size preference only reaches rem and em.

Converting is just division by the root:

pxrem at 16px rootrem at 10px root (62.5%)rem at 18px root
10.06250.10.0556
120.751.20.6667
140.8751.40.7778
241.52.41.3333

Those figures come straight from our converter’s pxToRem and formatNumber at four decimal places, which is the default. At a 16px root every whole pixel converts exactly within four places, since 1px is 0.0625rem. An 18px root gives repeating decimals (14px is 0.777…rem), so the precision selector (0–6 places) decides where to cut them off. The formatter also removes floating-point noise, so 0.1 + 0.2 prints as 0.3, not 0.30000000000000004. Both boxes accept suffixed values like 1.5rem and a decimal comma (1,5).

em compounds, rem doesn’t

The spec has one clause that explains most em surprises: when a font-relative unit appears in font-size itself, it resolves “against the computed metrics of the parent element.” So a list at font-size: 1.2em nested inside another 1.2em list ends up 1.2 × 1.2 = 1.44 times the base size. rem always goes back to the root, so 1.5rem is the same size everywhere on the page. Our converter’s results equal em values only when the parent’s font size matches the root you entered, and the tool says so under the table.

The 62.5% trick, and the real objection to it

html { font-size: 62.5%; } makes the root 10px (62.5% of 16), so 24px is 2.4rem. The usual complaint is that it breaks the user’s font-size preference, but it doesn’t: a percentage is taken from the user’s default, so someone who set 20px gets a 12.5px root and everything still scales. Our converter handles this. Type 62.5% as the root and it resolves to 10px, and the snippet it generates includes the html rule:

html {
  font-size: 62.5%; /* = 10px */
}

.element {
  font-size: 2.4rem; /* 24px */
}

The real costs are elsewhere. First, anything left at 1rem, including body text you forgot to set, now renders at 10px, so every component has to declare its own size. Second, and harder to catch, code you didn’t write may assume the default. A third-party widget with padding: 1rem built to look like 16px will render at 10px on your page, because rem means one thing per document, not one thing per component. Plenty of teams find it simpler to keep the 16px root and let a converter handle the arithmetic. Our tool shows the same trade-off in a note whenever you pick a 10px root.

Putting the layers together

When a design comp says “24px heading”, here’s what happens when that heading reaches a real screen:

  1. You write 1.5rem. It resolves to 24 CSS px at the default root, or more if the reader raised their text size.
  2. The browser multiplies by DPR. At DPR 3 that’s 72 device pixels. At 200% zoom on a DPR-1 monitor it’s 48.
  3. The panel’s PPI turns device pixels into millimetres. 72 device pixels at 460 PPI is about 4 mm on the phone. 24 device pixels at 108.79 PPI is about 5.6 mm on the monitor, which you’re sitting further away from.

The spec deliberately leaves step 3 loose: same size to your eye, not on a ruler.

Where to go from here

  • To see how sharp a screen really is, or to compare a 27” 1440p with a 32” 4K before you buy, put the numbers into the screen PPI calculator. If you’re checking the screen you’re on, use Detect my screen and then type the diagonal from the spec sheet.
  • To turn a design file’s pixel values into rem, the px to rem converter does both directions with any root, and it can copy the whole table as CSS custom properties.
  • For headings that scale between breakpoints, the clamp() calculator builds the rem + vw expression. The math behind fluid clamp() typography explains why its intercept has to be in rem.
  • To simplify a resolution to its ratio or scale dimensions, use the aspect ratio calculator. When a new high-PPI monitor arrives, run the screen test and read dead pixels vs stuck pixels before deciding whether to return it.

All of these tools run in your browser. Nothing you type is sent anywhere.