(… 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)
Code language: CSS (css)
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:

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)
Code language: CSS (css)
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…

… while we still get jaggies for small discs!

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)
Code language: CSS (css)
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).
In this case, when we zoom in up to a zoom level of 500%
, we get again blurry edges…

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!

1px
distance between the red and transparent stop positions – this disc has jagged edges due to the zoomBoo!
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) {}
Code language: CSS (css)
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 } }
Code language: CSS (css)
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)} }
}
}
Code language: SCSS (scss)
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.

radial-gradient()
disc anymoreSide 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);
Code language: CSS (css)
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.

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} }
}
}
Code language: SCSS (scss)
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();
Code language: JavaScript (javascript)
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.

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.