Why is your 1px divider grey instead of black?
You wrote 1px solid black. On the phone it shows up as a soft grey smudge, and in
a zoomed screenshot it is clearly two rows of something that is not black. Your colour value
is fine. The line is being asked to cover a distance the pixel grid can’t express, and
the rasteriser is splitting the difference.
“1px” stopped meaning one pixel years ago
Every modern UI unit — the CSS pixel, the Android dp, the iOS point, the Flutter
logical pixel — is a density-independent unit. The device decides how many real pixels it
spends on one of them, and that multiplier is the device pixel ratio.
iOS keeps that number an integer: @2x or @3x, nothing in between. Android does not. Here is the phone on my desk, reported by the device itself:
$ adb shell wm size
Physical size: 1440x3120
$ adb shell wm density
Physical density: 600
600 / 160 = 3.75 physical pixels per dp
So a 1dp divider on this phone is 3.75 pixels tall. Not 3, not 4 — three and three quarters. There is no way to paint three-quarters of a pixel, so the renderer paints the whole ones at full strength and the leftovers at partial opacity. That is the blur. And because Android densities come from buckets rather than from arithmetic, the fraction differs per device, and a user who changes the Display size setting changes it again at runtime.
Even at a clean 2x, a stroke lands between pixels
Fractional density is only half the story. A stroke is centred on its coordinate and grows outwards by half its width in each direction, so a 1-unit line at a whole-number coordinate covers the back half of one pixel row and the front half of the next. Neither row is fully covered, so neither row is fully black.
That is measurable rather than theoretical. I stroked horizontal lines into a canvas in Chromium and read the pixel values straight back out — the number after each row index is how much ink actually landed in it:
lineWidth 1, y = 10 → row 9: 0.498 row 10: 0.498
lineWidth 1, y = 10.5 → row 10: 1.000
lineWidth 3.75, y = 10 → row 8: 0.875 rows 9–10: 1.000 row 11: 0.875
lineWidth 0.5, y = 10 → row 9: 0.251 row 10: 0.251
Line one is the grey divider, exactly as the title asks: one pixel of black requested, two pixels of 50% grey delivered. Line two is the same line moved half a unit, which lands inside a single row and comes out solid. That half-unit offset is the oldest trick in canvas drawing and it is still the right one.
The fourth row is the interesting failure. Asking for half a pixel does not give you a thinner
line — it gives you the same two rows at a quarter strength. Push further and it keeps fading:
a stroke of 1 / 3.75 landed on a half-pixel centre paints a single crisp row at
26.7% ink. Below one device pixel, the rasteriser stops paying in width and starts paying in
opacity. Every designer who has tried to get a more delicate rule by typing a smaller number
has met this: the line didn’t get finer, it got weaker.
CSS borders follow a different rule — they floor
Borders are not canvas strokes, and browsers quantise them in layout before anything is
painted. Same browser, device pixel ratio 2, measuring the computed value of a
border-top:
border-top: 1px → computed 1px (2 device pixels)
border-top: 0.5px → computed 0.5px (1 device pixel)
border-top: 0.33px → computed 0.5px
border-top: 0.1px → computed 0.5px
border-top: 0.04px → computed 0.5px
border-top: thin → computed 1px
Anything non-zero and smaller than one device pixel is rounded up to exactly one, so a
CSS border never fades out the way a canvas stroke does — and the keyword thin,
which sounds like the hairline you want, is a full logical pixel. The practical move is to
write the sub-pixel value and let the engine clamp it: 0.5px is one device pixel
on a 2x screen and still a sane fallback at 1x. Verify it in the engine you actually ship to,
with the same six lines I used — create a div, set the border, read
getComputedStyle().borderTopWidth. It takes a minute and it beats guessing.
Flutter gives you the hairline, then defaults away from it
Flutter has a real escape hatch, documented in the SDK source: a BorderSide with
width: 0.0 is special-cased to “the width of one physical pixel”, and
Divider with thickness: 0.0 is “always drawn as a line with a
height of exactly one device pixel”. That is the crispest line the hardware can show,
whatever the density.
But the Material 3 defaults don’t use it. _DividerDefaultsM3 ships
thickness: 1.0, so an unconfigured Divider on the phone above is
3.75 device pixels of outlineVariant — close to four times the hairline, which is
why Flutter list separators can look heavy next to the ones iOS draws between its own rows. If
you want the hairline you have to ask:
Divider(thickness: 0) // one device pixel
BorderSide(width: 1 / MediaQuery.devicePixelRatioOf(context))
Both are correct; the second is useful when you want two or three physical pixels and want to say so explicitly.
Position blurs a line as readily as width does
A perfectly sized stroke still smears if it starts at a fractional offset, and fractional offsets appear without you typing one. Centring a 1-unit line inside an odd-numbered height puts it on a half. A row height of 56.5 pushes every divider below it off the grid. An animation that interpolates a translation sits between pixels for most of its frames, which is why a card border can visibly soften mid-transition and sharpen when it settles.
The fix is to round the geometry, not the stroke: snap the offset to a whole device pixel
((y * dpr).round() / dpr), keep row heights even when a divider is involved, and
in canvas work add the half-unit offset so the stroke straddles nothing.
In an exported image, the whole unit changes
Everything above assumes a live screen. The moment you rasterise a design to a fixed pixel size, “1px” means one pixel of that canvas and nothing else. App Store screenshot slots are 1242×2688, 1290×2796 and 1320×2868, so the same template renders at three widths — and a hairline authored against a 390-wide preview becomes about 3.4 pixels at 1320 wide. It survives, but it is no longer a hairline.
Then the store shrinks it again. A screenshot in search results is displayed a few hundred points wide — call it a 5× downscale. A one-pixel rule contributes a fifth of one output pixel at that ratio, so the best it can come out as is a 20% tint of its own colour, and the resampler decides even that. So: express strokes in a design as a fraction of canvas width rather than an absolute pixel count, and never let a hairline be the only thing separating two regions that matter. If the boundary has to read at thumbnail size, carry it with a tonal step — a slightly different background — and let the line be the refinement rather than the structure.
1 / dpr rather than as a guessed decimal? Did
you check a divider’s computed value instead of trusting the value you typed? Are your
stroke offsets whole device pixels? And in anything you export: is any boundary carried by a
line that disappears at thumbnail size?
Where we sweat this so you don’t
ShotCanvas templates scale every rule, divider and device-frame stroke with the canvas, so a frame authored once exports at 1242, 1290 and 1320 wide without a hairline thickening into a bar or thinning into nothing. You pick the look; the maths comes out the same in every slot.