Obsessing Over Smooth radial-gradient() Disc Edges
Obsessing Over Smooth radial-gradient() Disc Edges êŽë š
(⊠and how that lead me to a very underused CSS feature, resolution media queries.)
You may have come across this situation: you want to create a disc (oval) shape contained within your elementâs boundaries, and you want it to have smooth edges. Not jagged; not blurry.
If you want to avoid using a pseudo-element or, even worse, children just for decorative purposes, then radial-gradient()
seems to be the best solution. Especially in the case where you might need a bunch of such discs, more than the two pseudos available on an element.
The jaggies problem
However, if we do something like this:
radial-gradient(var(--r), var(--c) 100%, #0000)
Where r
is the gradient disc radius, then we get jaggies, a step-like effect along the radial-gradient()
disc, whereas one created with a pseudo-element has smooth-looking edges!
Note that we arenât setting a stop position explicitly for the final stop because the stop position of the final stop defaults to 100%
(of the radial-gradient()
radius, which is r
here), which is what we want in this case anyway. If you need a refresher on radial-gradient()
, check out this detailed explainer by Patrick Brosset.
You can see the difference between a pseudo-element disc (smooth edges) and a radial-gradient()
one (jaggies) in this live demo:
The smooth-looking edges of the pseudo-element version are a result of anti-aliasing, as it can be seen from the screen recording below:

recording of zooming in at the disc edges for the two cases
A popular, yet too imperfect fix
A solution I often see used to try to fix radial-gradient()
discs is introducing a 1%
distance between the positions of the two stops, something like this.
radial-gradient(var(--r), var(--c) 99%, #0000)
As I mentioned before, unless another value is explicitly specified, the final stop position defaults to 100%
, so thereâs never any need to explicitly set it to that value since itâs the default.
However, a 1%
distance means blurry edges for big discsâŠ

a big disc with a 1% distance between the red and transparent stop positions has blurry edges
⊠while we still get jaggies for small discs!

a small disc with a 1% distance between the red and transparent stop positions has jagged edges
A solution I thought was bulletproof
So my solution, which, up until recently, I thought would never fail, was to have a 1px
distance between the positions of our two stops:
radial-gradient(var(--r), var(--c) calc(100% - 1px), #0000)
This works well regardless of disc size⊠until it doesnât!
A pixel is not always a pixel
So there are situations when my âbulletproofâ solution fails. For example, in two cases Iâve never really considered before, since my main laptop is almost two decades old: with a hi-DPI display or with âthose pesky users doing their nasty zoomsâ (credit for this gem (myfonj
)).
In this case, when we zoom in up to a zoom level of 500%
, we get again blurry edgesâŠ

a zoomed in page with a fully contained disc with a 1px
distance between the red and transparent stop positions - this disc has blurry edges due to the zoom
⊠and when we zoom out up to a zoom level of 25%
, we get jagged edges!

a zoomed out page with a fully contained disc with a 1px
distance between the red and transparent stop positions - this disc has jagged edges due to the zoom
Boo!
So what can we do in this case?
Underrated CSS feature: resolution!
Up until this summer, when I got fixated on this zoom problem, I had no idea that CSS provides resolution media queries! These allow us to style things differently based on the device pixel density or zoom level.
I donât think I have access to any device with a higher pixel ratio display, but I can certainly test zoom. For zoom, this thing really works! For example, if weâre zoomed in to 500%
, weâre in the 5x
case:
@media (resolution: 5x) {}
This means we can divide that 1px
difference by a factor f
which we set in the media query.
div {
background:
radial-gradient(var(--r),
var(--c) calc(100% - 1px/var(--f, 1)), #0000)
}
@media (resolution: 5x) { div { --f: 5 } }
Note that the x
unit is an alias for the dppx
unit, an alias that was only added in Level 4 of the CSS Values and Units Module (Level 3 did not include x). However, at this point, Iâd say itâs safe to use since all major current desktop and mobile browsers have been supporting it for over half a decade.
I prefer using x
as itâs shorter and it feels more intuitive and consistent with picture
sources.
We can do the same for all other zoom levels Chromium browsers provide using Sass looping:
$f: .25 .33 .5 .67 .75 .8 .9 1.1 1.25 1.33 1.4 1.5 1.75 2 2.5 3 4 5;
$n: length($f);
@for $i from 0 to $n {
@media (resolution: nth($f, $i + 1)*1x) {
div { --f: #{nth($f, $i + 1)} }
}
}
This gives us a nice pure CSS way of ensuring we have smooth disc edges, not jagged, not blurry, regardless of display resolution or zoom level.

zooming doesnât mess up the edges of our radial-gradient()
disc anymore
Side note
for anyone wondering why the disc starts getting smaller once weâve increased the zoom above a certain level, this is due to the way weâve defined the disc radius:
--r: min(50vmin - 2em, 9em);
For large screens/ low zoom levels, the second value in the min()
(9em
) is the one thatâs used, as itâs smaller. Since the default font-size
and, consequently, any em
value always increases with zoom, the second min()
value becomes bigger than the first after a certain level of zoom, so then itâs the first value that gets used. For 50vmin - 2em
, 50vmin
is always constant, doesnât depend on the zoom level, but 2em
increases with zoom. This means our difference 50vmin - 2em
decreases with zoom.
Cool, but thatâs quite a lot of media queries and what do we do when other browsers have other zoom levels available instead of the ones in our list above, which is Chromium specific?
For example, Firefox goes from a 50%
zoom level to a 30%
one, which is the smallest value. It also uses 120%
, 170%
and 240%
zoom values instead of 125%
, 175%
and 250%
respectively in Chrome.

zoom levels in Firefox are different
This means that since we have no match for a zoom level of 30%
, --f
remains 1
there, just like in the default case, which means the zoomed out 1px
difference is seen as less than a third of that, resulting in jaggies at this smallest Firefox zoom level.
When zooming in, the blur problem is pretty much undetectable for the 120%
zoom level (which again has no match among our resolution media queries), but it starts being noticeable for the bigger no match zoom levels at 170%
and 240%
.
We could add those Firefox zoom levels to the list⊠or we could do something better! That is, use max and min resolution depending on whether weâre in the subunitary case or not, and also reverse the order of the subunitary zooms. The second part is because if we were to have the same order, with .9
being after .8
, then the (max-resolution: .9x)
case would override the (max-resolution: .8x)
one.
$f: .9 .8 .75 .67 .5 .33 .25
1.1 1.2 1.33 1.4 1.5 1.7 2 2.4 3 4 5;
$n: length($f);
@for $i from 0 to $n {
$c: nth($f, $i + 1);
@media (#{if($c < 1, 'max', 'min')}-resolution: $c*1x) {
div { --f: #{$c} }
}
}
A more subtle change from before is that, when the zoom levels are above 1
, we are using the slightly smaller of two zoom values that are close enough in Chrome and Firefox, but not quite the same. For example, between 1.25
in Chrome and 1.2
in Firefox we use 1.2
, between 2.5
in Chrome and 2.4
in Firefox, we use 2.4
. This is because the (min-resolution: 1.2x)
case also catches the entire (min-resolution: 1.25x)
case, but not the other way around. And the same thing goes for the other close, but not quite the same zoom level pairs from the two browsers.
Much better! But what if we really hate having so many media queries?
The less code and more flexible JS solution
In this case, weâd set f
from the JS as follows:
function zoom() {
document.body.style.setProperty('--f', window.devicePixelRatio);
matchMedia(`(resolution: ${window.devicePixelRatio}x)`)
.addEventListener('change', zoom, { once: true });
}
zoom();`
This works for any place where we may want to have radial-gradient()
created discs - not just for background
values, but also for mask
or border-image
values.
Conclusion
Is this overkill? Something only a psycho would do? It depends.
In some cases, having smooth edges may be worth obsessing about. For example, if we use a mask
as a fallback for shape()
in the case of a component (like a header
) with both convex and concave roundings.

thebabydino
))While newer Chrome and Safari versions have supported shape()
for a few months now, Firefox support isnât there yet. We could set the layout.css.basic-shape-shape.enabled
flag to true in about:config
to play with it there too, but remember, most people wonât have it enabled, and there is a reason why itâs still behind the flag in Firefox: not all commands work. We can use the lines and arcs we need for this particular shape, but BĂ©zier curves donât work yet. Furthermore, some people may be stuck on older hardware/ operating systems and may be unable to update Chrome or Safari to the latest version. So having a fallback for shape()
is very much necessary.
Without the zoom/device pixel ratio factor, we get ugly blurry edges for the concave rounding (the convex one is created via border-radius
, so it doesnât have this problem) at a zoom level of 500%
when shape()
isnât supported and the mask
fallback is used (for example, in Firefox without the flag enabled).

There are, however, other cases where we could embrace (and maybe even enhance) the blurry edges instead of doing anything about them. For example, when the discs are a part of a faded background.