Pure CSS Halftone Effect in 3 Declarations
Pure CSS Halftone Effect in 3 Declarations êŽë š
About half a decade ago, I got an idea about how to create a halftone effect with pure CSS. My original idea (which Michelle Barkerwrote abouta couple of years ago) was a bit inefficient, but in the years that followed, Iâve managed to polish it and reduce it to a single <div>
, no pseudos and just three CSS properties.
Whatâs a halftone effect?
If you donât know what ahalftoneeffect is, a very basic pattern looks like this:

This is what weâll be creating with a single <div>
(no pseudo-elements) and only three CSS declarations. Afterwards, weâll go through a bunch of variations and see some cooler-looking demos.
The 3 CSS Declarations
Thefirst declarationis abackground
and it consists of two layers. One is thepattern- the dots in our most basic case. The other is themap- this decides where the dots are bigger and where they are smaller. In the most simple case, itâs a linear gradient. So what we have so far in terms of code looks like this:
background:
radial-gradient(closest-side, #000, #fff) 0/ 1em 1em space,
linear-gradient(90deg, #000, #fff);
Weâve made sure we have an integer number of dots along both axes by using thespace
value forbackground-repeat
.
Taken separately, the two layers look like this:

Before we move any further, letâs take a closer look at these gradients. Each of the two layers goes fromblack
, which can also be written asrgb(0%, 0%, 0%)
orhsl(0, 0%, 0%)
towhite
, which can also be written asrgb(100%, 100%, 100%)
orhsl(0, 0%, 100%)
.
Dead in the middle we havegrey
, which isrgb(50%, 50%, 50%)
orhsl(0, 0%, 50%)
. This is the50%
lightness grey or, in short, as weâll be calling it from now on, the50%
grey.
Note that in the case ofanygrey, wherever it may be situated in between black and white, the saturation (the âSâ in HSL) is always0%
, while the hue (the âHâ in HSL) is irrelevant, so we just use0
. The only value that changes is the lightness (the âLâ in HSL), which goes from0%
forblack
to100%
forwhite
.
Basically, going from0%
to100%
along the gradient line means going from0%
to100%
along the lightness axis of theHSL bicone (thebabydino
).

(live demo (
thebabydino
))So in general, anyp%
grey can be written asrgb(p%, p%, p%)
orhsl(0, 0%, p%)
.
This can be seen in the interactive demo below where you can drag the bar along the entire lightness range.
Going back to ourbackground
with the pattern dots layer on top of thelinear-gradient()
map layer, we cannot see the map layer because itâs fully covered by the pattern layer. So the next step is to blend these twobackground
layers using themultiply
blend mode.
This means thesecond declarationis:
background-blend-mode: multiply
This works on a per pixel, per channel basis. We consider each layer to be a grid of pixels, we take every pair of corresponding pixels from the two layers and, for each of the three RGB channels, we multiply the corresponding channel values.

So for each pair of pixels, the result of this blending operation is an RGB value where each channel value is the result of multiplying the corresponding channel values from the two layers.
Note that what weâre multiplying is the decimal representation of percentage RGB values - that is, numbers in the[0, 1]
interval. And when multiplying values in this interval, the result is always smaller or equal to the smallest of the two values multiplied.
In our case, both gradients go from black to white, all we have in between are greys, which have all three RGB channels equal. So if at some point, both pixels in the pair of corresponding ones from the two layers havergb(50%, 50%, 50%)
, then the result of themultiply
blend mode is.25 = .5·.5
for each channel.
We can see that the result of themultiply
blend mode is alwaysat least as darkas the darker of the two pixels whose RGB values we multiply. This is because the two RGB values are in the[0, 1]
interval and, as mentioned before, multiplying such values always gives us a result thatâs at most as big as the smallest of the two numbers multiplied. The smaller the channel values are, the darker the grey they represent is.
After blending our pattern and map layers, we can see how overall, the pattern dots are now darker on the left where the map is closer toblack.

Below, you can see two scaled up dots from different points along the gradient line of the map. The second dot is further to the right (lighter) than the first one. The dark red circles mark the50%
grey limit for each.

For the darker dot, the50%
grey limit is a bigger circle than in the case of the lighter dot. Inside each dark red circle, we have greys darker than a50%
one. Outside, we have greys lighter than a50%
one. Keep this in mind for later.
The third andfinal declarationis afilter
using a largecontrast()
value.
For those not familiar with howcontrast()
works, it does one of two things, depending on whether its argument is subunitary or not.
If its argument is subunitary, then it pushes every channel value towards.5
, the middle of the[0, 1]
interval. A value of1
means no change, while a value of0
means the channel has been pushed all the way to.5
.
This means thatcontrast(0)
always gives us a50%
grey, regardless of thefilter
input.
You can see this in the interactive demo below - regardless of whether we apply ourfilter
on a plain solidbackground
box, opaque or semitransparent, a gradient or an image one, dragging the contrast down to0
always turns it into a50%
grey with the same alpha as the input.
Note thatcontrast(100%)
is the same ascontrast(1)
,contrast(50%)
is the same ascontrast(.5)
and so on.
If the argument of thecontrast()
function is greater than1
however, then each channel value gets pushed towards either0
or1
, whichever is closer. A contrast large enough can push the channel values all the way to0
or1
.
If we have a large enough contrast, all channel values are either zeroed (0%
) or maxed out (100%
) meaning we can only get one of eight possible results.

Coming back to our halftone pattern, we use:
filter: contrast(16)
Here, all greys darker than a50%
one (grey
orrgb(50%, 50%, 50%)
orhsl(0, 0%, 50%)
) get pushed toblack
and all the others towhite
.
Now remember how the50%
grey limit was a bigger circle if the dot was darker? Thatâs our limit for the contrast.
Inside that circle, we have greys darker than a50%
one, so they get pushed toblack
by large contrast vales. Outside it, the greys are lighter than a50%
one, so they get pushed towhite
by large contrast values.
Since the darker the dot, the bigger the50%
limit circle, this means the halftone dots in the darker area of the map are bigger.
So hereâs the result we get after the third and final declaration:

Weâre starting to get somewhere, but what we have so far is not ideal. And it makes sense we arenât there yet.
Since the left half of the map is darker than a50%
grey (the RGB channel values are below50%
or.5
in decimal representation of the percentage), blending any other layer with it using themultiply
blend mode gives us a result thatâs at least as dark.
This means the result of blending across the entire left half is a grey darker than a50%
one, so that large value contrast pushes everything in the left half toblack
.
The fix for this is pretty straightforward: we donât make our gradients go all the way from black to white, but rather from mid greys to white. Furthermore, for best results, the map at its darkest should be a little bit brighter than a50%
grey, while the pattern can be a bit darker.
background:
radial-gradient(closest-side, #777, #fff) 0/ 1em 1em space,
linear-gradient(90deg, #888, #fff);
Much better!
Now one thing to note here is that the contrast value needs to be enough to compensate for the blur radius of our dots. So if we increase the pattern size (thebackground-size
for the pattern layer), then we also need to increase the contrast value accordingly.
Letâs say we increase thebackground-size
from1em
to 9em
.

The dot edges are now blurry, so we also increase the contrast value from16
to letâs say80
.

Unfortunately, this results in ugly edges.
A fix for this would be to then chain a slight blur and a contrast thatâs enough to offset it. Generally, a contrast value thatâs 2-3 times the blur value in pixels works pretty well.
filter: contrast(80) blur(2px) contrast(5)

Aneven better fixwould involve using a custom SVGfilter
, but SVG filters are outside the scope of this article, so weâre not going there.
Variations
Now that weâve gone through the basics, we can start making things more interesting in order to get a lot of cool results by varying at least one of the pattern or map layers.
background:
var(--pattern, radial-gradient(closest-side, #777, #fff) 0/ 1em 1em space)),
var(--map, linear-gradient(90deg, #888, #fff));
background-blend-mode: multiply;
filter: contrast(16)
Pattern variations
In this part, weâre keeping the map gradient unchanged and keeping the same hex values for the pattern gradients, though the pattern gradients themselves change. Depending on the pattern, we might also adjust the contrast.
If you search for halftone patterns online, youâll see that most of them donât show a straight grid like we had above. So letâs fix that with a pattern made up of two layers.
--dot: radial-gradient(closest-side, #777, #fff calc(100%/sqrt(2)));
--pattern: var(--dot) 0 0/ 2em 2em, var(--dot) 1em 1em/ 2em 2em
In practice, Iâd probably use a variable instead of2em
and compute the offsets for the second layer of dots to be half of that.

Also, since weâve increased the size of the dots, weâve also bumped up the contrast value from16
to24
.
Another option would be to use arepeating-radial-gradient()
.
--pattern: repeating-radial-gradient(circle, #777, #fff, #777 1em)

Something like this can even be animated or made interactive. We can place these halftone ripplesat var(--x) var(--y)
and change these custom properties onmousemove
.
We donât have to limit ourselves to radial gradients. Linear ones work just as well. We can use arepeating-linear-gradient()
, for example:
--pattern: repeating-linear-gradient(#777, #fff, #777 1em)

We can also animate the gradient angle (like in the demo below on hover) or make it change as we move the cursor over the pattern.
We can also restrict thebackground-size
of alinear-gradient()
:
--pattern: linear-gradient(45deg, #fff, #777) 0 / 1em 1em

Just like for the first dots pattern variation, here weâve also bumped up the contrast.
We can also add one extra stop:
--pattern: linear-gradient(45deg, #fff, #777, #fff) 0 / 1em 1em

For both of the previous ones, the gradient angle can also be animated. This can be seen on hovering the panels in the demo below.
We can also play with conic gradients here. A simple repeating one produces rays that are thicker on the left than on the right.
--pattern: repeating-conic-gradient(#777, #fff, #777 2.5%)
Without anyfilter
adjustment however, the edges of these rays look bad, and so does the middle.

Using the tiny blur plus a contrast value thatâs 2-3 times the blur tactic fixes the ray edges:

⊠but the patternâs edges are now faded! We have two possible fixes here.
The first would be to remove thefilter
from the element itself and apply it on another element stacked on top of it as abackdrop-filter
.
The second would be to make the element extend outwards a bit using a negativemargin
and then clip its edges by the same amount usinginset()
.
Things get a lot more fun if we limit thebackground-size
of such aconic-gradient()
pattern and then play with the start angle--a
and the end percentage--p
.
--pattern:
repeating-conic-gradient(var(--a),
#fff, #777, #fff var(--p)) 0/ 3em 3em
Map variations
In this part, weâre keeping the pattern constant and trying out different maps.
Ourlinear-gradient()
map doesnât necessarily need to go along thexaxis - it can of course have a variable angle:
--map: linear-gradient(var(--a), #888, #fff)
The demo below shows this angle being animated on hover:
We can also add an extra stop:
--map: linear-gradient(var(--a), #fff, #888, #fff)
Again, hovering the demo below animates the map direction.
We can also make our gradient a repeating one:
--map:
repeating-linear-gradient(var(--a), #fff, #888, #fff var(--p))
Or we can switch to aradial-gradient()
:
--map:
radial-gradient(circle at var(--x) var(--y), #888, #fff)
In the demo below, the radial gradientâs position follows the cursor:
The radial gradient can be a repeating one too:
--map:
repeating-radial-gradient(circle at var(--x) var(--y),
#fff, #888, #fff var(--p))
Same thing goes for conic gradients.
--map:
conic-gradient(from var(--a) at var(--x) var(--y),
#fff, #888, #fff)
We can use a repeating one and control the number of repetitions as well.
--map:
repeating-conic-gradient(from var(--a) at var(--x) var(--y),
#fff, #888, #fff var(--p))
One thing that bugs me about some of the map variation demos, particularly about this last one, is the dot distortion. We can make it look less bad by sizing the element with the halftonebackground
such that both its dimensions are multiples of the dot size and change the position in increments of the same dot size.
--d: 1em;
--pattern:
radial-gradient(closest-side, #777, #fff)
0/ var(--d) var(--d);
--map:
repeating-conic-gradient(from var(--a)
at round(var(--x), var(--d)) round(var(--y), var(--d)),
#fff, #888, #fff var(--p));
width: round(down, 100vw, var(--d));
height: round(down, 100vh, var(--d));
But itâsnot enough (thebabydino
). In order for our dots to always be perfectly round, we need an SVGfilter
solution (thebabydino
). However, thatâs outside the scope of this article, so weâre not discussing it here.
Even more interestingly, our map can be an image too. Taking any random image as it is wonât work well.

We need to bring its saturation down to zero and, for this particular technique, we need to make sure the lightness of its pixels is pretty much in the[50%, 100%]
interval.
Thefilter()
(web-platform-tests/interop
)functioncould help (thebabydino
)here, but, sadly, foralmost a decade now, Safari has remained the only browser implementing it. We could make the pattern and the map layer each be a pseudo of an element, blend them together and apply the contrastfilter
on the pseudo-elementsâ parent. This way, the map pseudo could have afilter
applied on it too. However, here weâre looking for solutions that donât involve extra elements or pseudo-elements.
Something we can do is make the map be the result of multiple blended background layers. Making thebackground-color
anygrey and blending it with the map image using theluminosity
blend mode gives us a result that has the luminosity of the map image on top, the saturation of thebackground-color
below and, since this is a grey (its saturation is0%
), the hue becomes irrelevant.
Note that luminosity isnot the same as lightness (thebabydino
)(which is the âLâ in HSL), though in a lot of cases, theyâre close enough.
--pattern:
radial-gradient(closest-side, #777, #fff) 0/ 1em 1em space;
--map: url(my-image.jpg) 50%/ cover grey;
background: var(--pattern), var(--map);
background-blend-mode:
multiply /* between pattern & map */,
luminosity /* between map layers */;
filter: contrast(16)
We seem to be going in the right direction.

But itâs still not what we want, as this desaturated map is too dark, just like the firstblack
towhite
map gradient we tried.
We can brighten our map using thescreen
blend mode. Think of this blend mode as being the same asmultiply
, only with the ends of the lightness interval reversed.multiply
always produces a result thatâs at least as dark as the darkest of its two inputs,screen
always produces a result thatâs at least as bright as the brightest of its two inputs.
In our case, if we usescreen
to blend the desaturated image we got at the previous step with a midway grey like#888
, then the result is always at least as bright as#888
. And it is#888
only where we blend it with pure black pixels. Wherever we blend it with pixels brighter than pure black, the result is brighter than#888
. So basically, we get a map thatâs#888
at its darkest, just like our base map gradient.
--pattern:
radial-gradient(closest-side, #777, #fff) 0/ 1em 1em space;
--map:
conic-gradient(#888 0 0),
url(my-image.jpg) 50%/ cover
grey;
background: var(--pattern), var(--map);
background-blend-mode:
multiply /* between pattern & map */,
screen /* between map layers */,
luminosity /* between map layers */;
filter: contrast(16)
Much better!

(live demo (
thebabydino
))Again, some of the dots arenât fully round, but in order to get fully round dots, weâd need an SVGfilter
and thatâs a way too big of a topic to discuss here.
Palette variations
The simplest possible variation would be having white halftone dots on a black background. To do this, we can simply chaininvert(1)
to ourfilter
.
Or⊠we can do something else! We can use thescreen
blend mode weâve used before to brighten the image map. As mentioned, this works likemultiply
, but with the ends of the lightness interval reversed. So letâs reverse them for both the pattern and the map.
background:
var(--pattern,
radial-gradient(closest-side, #888, #000) 0/ 1em 1em space),
var(--map,
linear-gradient(90deg, #777, #000));
background-blend-mode: screen;
filter: contrast(16)

(live demo (
thebabydino
))But weâre not limited to just black and white.
Remember the part about how contrast works? Large contrast values push all pixels of thefilter
input to one of 8 possible RGB values. So far, ourfilter
input has been just greys, so they got pushed to either black or white. But we donât necessarily need to have just greys there. We could tweak those values to either zero or max out a channel or two everywhere.
For example, if we max out one of the channels, then our black dots get that channel added to them. Maxing out the red channel gives us red dots, maxing out the blue channel gives us blue dots, maxing out both the red and blue channels gives us magenta dots.
Going the other way, if we zero one of the channels, then it gets subtracted out of the white background. Zeroing the blue channel gives us a yellow background (the red and green channels are still maxed out for the background and combined, they give yellow). Zeroing the red channel gives us a cyan background. Zeroing both the blue and green channels gives us a red background.
You can play with various scenarios in the interactive demo below:
We can of course also have more interesting palettes and we can even have halftone dots on top of image backgrounds using the pure CSSblending technique (thebabydino
)I detailed ina talk on the topic (thebabydino
)I used to give in 2020 or by usingSVG (thebabydino
)filters (thebabydino
). Both of these approaches however require more than just one element with no pseudos and three CSS properties, so we wonât be going into details about them here.
Combining these variations (and more!)
Varying more than one of the above can help with interesting results.
By using top to bottom linear gradients for both the pattern and the map, with the pattern one having its size limited to10%
of the element, we can get the effect below without needing to use amask
gradient with many irregulrly placed stops. Blending with some extra layers helps us with a nicer palette for the final result.
We can also animate a mapâsbackground-position
to get a blinds effect like below:
In the demo above, weâve also blended the halftone pattern with an image. Hereâs another such example (note that this doesnât work in Firefox due tobug 1481498, which has everything to do with the text on the right side and nothing to do with the halftone part):

(live demo (
thebabydino
))Note that the code for all these demos so far is heavily commented, explaining the purpose of pretty much every CSS declaration in there.
The example below uses arepeating-radial-gradient()
pattern and aconic-gradient()
map, which funny enough, also creates a tiny heart in the middle.
For a bit of a different effect, hereâs a rhombic halftone one created by using two blended layers for the map - two otherwise identical linear gradients going in different directions:
The demo below is a combination of two halftone patterns stacked one on top of the other, the top one being masked using aconic-gradient()
checkerboardmask
.
Here are a few more halftone samples as card backgrounds:
Even more such halftone samples can be found in this gallery:
We arenât limited to 2D. We can also use such paterns in 3D and even animate them.
Finally, even more demos showcasing halftone patterns can be found inthis CodePen collection:
