Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Mon, 30 Jun 2025 17:30:03 +0000 en-US hourly 1 https://wordpress.org/?v=6.8.3 225069128 Step Gradients with a Given Number of Steps https://frontendmasters.com/blog/step-gradients-with-a-given-number-of-steps/ https://frontendmasters.com/blog/step-gradients-with-a-given-number-of-steps/#respond Mon, 30 Jun 2025 17:30:02 +0000 https://frontendmasters.com/blog/?p=6338 Let’s say we want some stepped gradients like the ones below, with a certain number of steps.

Desired_linear_result screenshot. Shows a bunch of linear gradients in 10 steps from left to right.
the desired result

Before reading further, try thinking about this. You are only given the start and end steps and the rest should be obtained via linear interpolation. How would you create them? Maybe put together a quick demo.

Note that this is a different problem from stepped gradients with a certain step size. The given step size problem is way more complex and it’s impossible to solve with CSS alone as long as we don’t have native looping. SVG filters provide a solution, though they limit us to using just pixel values for the step size; having em or calc() values there isn’t possible without JS.

The Classic CSS Approach

This means computing the intermediate step values and specifying a stop and positions for each step. Something like this:

.step {
  background: linear-gradient(
    90deg,
    #00272b 10%,
    #193f2f 0 20%,
    #325833 0 30%,
    #4b6f36 0 40%,
    #64873b 0 50%,
    #7c9f3f 0 60%,
    #95b743 0 70%,
    #aecf47 0 80%,
    #c7e74b 0 90%,
    #e0ff4f 0
  );
}

Tedious!

And this is the simplified version, the one that avoids repeating stops and stop positions, something that has been well-supported cross-browser for over half a decade, by the way. Because I often see the ancient, inefficient version that duplicates everything:

.step {
  background: linear-gradient(
    90deg,
    #00272b 0%,  #00272b 10%,
    #193f2f 10%, #193f2f 20%,
    #325833 20%, #325833 30%,
    #4b6f36 30%, #4b6f36 40%,
    #64873b 40%, #64873b 50%,
    #7c9f3f 50%, #7c9f3f 60%,
    #95b743 60%, #95b743 70%,
    #aecf47 70%, #aecf47 80%,
    #c7e74b 80%, #c7e74b 90%,
    #e0ff4f 90%, #e0ff4f 100%
  );
}

We could generate the stop list using Sass with looping and the mix() function.

// $c0: gradient start
// $c1: gradient end
// $n: number of steps
@function stop-list($c0, $c1, $n) {
  $l: (); // list of stops, initially empty

  @for $i from 0 to $n {
    $l: $l, mix($c1, $c0, $i * 100%/ ($n - 1)) 0 ($i + 1) * 100% / $n;
  }

  @return $l;
}

.step {
  background: linear-gradient(90deg, stop-list(#00272b, #e0ff4f, 10));
}

This produces the following compiled result:

.step {
  background: linear-gradient(
    90deg,
    #00272b 0 10%,
    #193f2f 0 20%,
    #325833 0 30%,
    #4b6f36 0 40%,
    #64873b 0 50%,
    #7c9f3f 0 60%,
    #95b743 0 70%,
    #aecf47 0 80%,
    #c7e74b 0 90%,
    #e0ff4f 0 100%
  );
}

Not bad — but we should probably tweak that generating function a bit so we get rid of the unnecessary 0 and 100% stop positions at the very start and at the very end and to add rounding in case 100 is not a multiple of $n.

// $c0: gradient start
// $c1: gradient end
// $n: number of steps
@function stop-list($c0, $c1, $n) {
  $l: (); // list of stops, initially empty

  @for $i from 0 to $n {
    $l: $l,
      mix($c1, $c0, $i * 100%/ ($n - 1))
      // 1st stop position for each stop
      // not set (empty '') for very first stop
      if($i > 0, 0, unquote(""))
      // 2nd stop position for each stop
      // not set (empty '') for very last stop
      if($i < $n, round(($i + 1) * 100% / $n), unquote(""));
  }

  @return $l;
}

Much better — but the Sass function doesn’t look pretty and it gets even more complex if we need to round those percentages to a certain precision $p, a certain number of decimals, not just to integer percentage values:

@use "sass:math";

// $c0: gradient start
// $c1: gradient end
// $n: number of steps
// $p: rounding precision, how many decimals to keep
@function stop-list($c0, $c1, $n, $p: 2) {
  $l: (); // list of stops, initially empty

  @for $i from 0 to $n {
    $l: $l,
      mix($c1, $c0, $i * 100%/ ($n - 1))
      // 1st stop position for each stop
      // not set (empty '') for very first stop
      if($i > 0, 0, unquote(""))
      // 2nd stop position for each stop
      // not set (empty '') for very last stop
      if(
        $i < $n - 1,
        round(($i + 1) * 100% / $n * math.pow(10, $p)) * math.pow(10, -$p),
        unquote("")
      );
  }

  @return $l;
}

We still have that long list of values in the compiled CSS and, if we have 7 elements like this with stepped gradients, each is going to get its own long list.

Another problem with this is we cannot tweak the stepped gradient from DevTools. Not in a non-tedious way that doesn’t involve changing almost every step manually. If we want to change one of the end steps from DevTools, we have to also change all the Sass-computed intermediate steps.

Screenshot showing the Sass code that generates the steps and the visual result. On the right, a DevTools panel is open and we're modifying the first step in the compiled code the browser can see, but this doesn't change the Sass-generated intermediate ones.
what happens when we change one end step from DevTools

Using CSS variables can get around both these problems, but we cannot use CSS variable values inside the Sass mix() function. In order to use CSS variables, we have to use CSS color-mix() function:

@use "sass:math";

// $n: number to round to a certain precision
// $p: rounding precision, how many decimals to keep
@function round-to($n, $p: 2) {
  @return round($n * math.pow(10, $p)) * math.pow(10, -$p);
}

// $c0: gradient start
// $c1: gradient end
// $n: number of steps
// $p: rounding precision, how many decimals to keep
@function stop-list($c0, $c1, $n, $p: 2) {
  $l: (); // list of stops, initially empty

  @for $i from 0 to $n {
    $l: $l,
      color-mix(in srgb, $c1 round-to($i * 100%/ ($n - 1), $p), $c0)
        // 1st stop position for each stop
      // not set (empty '') for very first stop
      if($i > 0, 0, unquote(""))
        // 2nd stop position for each stop
      // not set (empty '') for very last stop
      if($i < $n - 1, round-to(($i + 1) * 100% / $n, $p), unquote(""));
  }

  @return $l;
}

Which produces the following ugly compiled CSS:

.step {
  background: linear-gradient(
    90deg,
    color-mix(in srgb, var(--c1) 0%, var(--c0)) 10%,
    color-mix(in srgb, var(--c1) 11.11%, var(--c0)) 0 20%,
    color-mix(in srgb, var(--c1) 22.22%, var(--c0)) 0 30%,
    color-mix(in srgb, var(--c1) 33.33%, var(--c0)) 0 40%,
    color-mix(in srgb, var(--c1) 44.44%, var(--c0)) 0 50%,
    color-mix(in srgb, var(--c1) 55.56%, var(--c0)) 0 60%,
    color-mix(in srgb, var(--c1) 66.67%, var(--c0)) 0 70%,
    color-mix(in srgb, var(--c1) 77.78%, var(--c0)) 0 80%,
    color-mix(in srgb, var(--c1) 88.89%, var(--c0)) 0 90%,
    color-mix(in srgb, var(--c1) 100%, var(--c0)) 0
  );
}

We can tweak the Sass to get rid of the first and last color-mix() and use the given ends --c0 and --c1 instead (live demo):

@use "sass:math";

// $n: number to round to a certain precision
// $p: rounding precision, how many decimals to keep
@function round-to($n, $p: 2) {
  @return round($n * math.pow(10, $p)) * math.pow(10, -$p);
}

// $c0: gradient start
// $c1: gradient end
// $n: number of steps
// $p: rounding precision, how many decimals to keep
@function stop-list($c0, $c1, $n, $p: 2) {
  $l: (); // list of stops, initially empty

  @for $i from 0 to $n {
    $l: $l,
      if(
          $i > 0,
          if(
            $i < $n - 1,
            color-mix(in srgb, $c1 round-to($i * 100%/ ($n - 1), $p), $c0),
            $c1
          ),
          $c0
        )
      // 1st stop position for each stop
      // not set (empty '') for very first stop
      if($i > 0, 0, unquote(""))
      // 2nd stop position for each stop
      // not set (empty '') for very last stop
      if($i < $n - 1, round-to(($i + 1) * 100% / $n, $p), unquote(""));
  }

  @return $l;
}

But the generated CSS still looks ugly and difficult to read:

.step {
  background: linear-gradient(
    90deg,
    var(--c0) 10%,
    color-mix(in srgb, var(--c1) 11.11%, var(--c0)) 0 20%,
    color-mix(in srgb, var(--c1) 22.22%, var(--c0)) 0 30%,
    color-mix(in srgb, var(--c1) 33.33%, var(--c0)) 0 40%,
    color-mix(in srgb, var(--c1) 44.44%, var(--c0)) 0 50%,
    color-mix(in srgb, var(--c1) 55.56%, var(--c0)) 0 60%,
    color-mix(in srgb, var(--c1) 66.67%, var(--c0)) 0 70%,
    color-mix(in srgb, var(--c1) 77.78%, var(--c0)) 0 80%,
    color-mix(in srgb, var(--c1) 88.89%, var(--c0)) 0 90%,
    var(--c1) 0
  );
}

So… isn’t there another way?

The No-Support Ideas

The spec defines a stripes() image function and my first thought was it should allow us to do this, though it was not clear to me in which direction the stripes would go, if we have a way of specifying that:

.step {
  background: stripes(
    #00272b,
    #193f2f,
    #325833,
    #4b6f36,
    #64873b,
    #7c9f3f,
    #95b743,
    #aecf47,
    #c7e74b,
    #e0ff4f
  );
}

But the more I read the first paragraph in the spec definition, the more it sounds like this wasn’t meant for backgrounds, but for stripes going along the direction of things like borders (including roundings) and text strokes. In this case, the line direction would be the “missing” direction.

There’s also a proposal to add animation-like gradient easing, including a steps() function, though, just like in the case of the stripes() function, there’s a lot about this I don’t understand and the proposal doesn’t seem to have moved much lately.

Since neither of these can be used today, what other solutions that we can currently use do we have?

Enter the SVG filter Enhancement

Wait, don’t run away screaming! I promise that, leaving aside small browser issues, the technique is actually simple and even has better support than the CSS gradient double stop position syntax!

Let’s say we have a plain black to red gradient on an element that also gets an SVG filter:

.step {
  background: linear-gradient(90deg, #000, #f00);
  filter: url(#step)
}

We’ve picked this gradient in particular because it’s a gradient from 0% to 100% one one of the RGBA channels (in this case, the red channel R) while all other channel values stay constant. It could also be written as:

.step {
  background: linear-gradient(90deg, rgb(0% 0% 0% / 1), rgb(100% 0% 0% / 1));
  filter: url(#step)
}

Writing it like this makes it even more obvious that the green and blue channels (the second and third values in the rgb()) are zeroed everywhere all the time (before applying the filter and after too, as the SVG filter, which we’ll see in a second, doesn’t affect any channel other than the red one), while the alpha (the final value, the one after the slash in the rgb()) is maxed out everywhere all the time.

So basically, we go from a 0% red (which is equivalent to black) at the start on the left to a 100% red (which is the same as the red keyword value) at the end on the right.

a simple left to right black to red gradient band
our initial gradient

This SVG filter needs to live inside an svg element. Since this svg element only exists to contain the filter, we don’t have any graphics that are going to be visible on the screen within it, it is functionally the same as a style element. So we zero its dimensions (width and height attributes), hide it from screen readers (aria-hidden) and take it out of the document flow (from the CSS).

<svg width='0' height='0' aria-hidden='true'>
  <filter id='step' color-interpolation-filters='sRGB'>
  </filter>
</svg>
svg[height='0'][aria-hidden='true'] { position: fixed }

The filter element also gets another attribute other than the id. We aren’t going into it, just know we need to set it to sRGB for cross-browser compatibility if we mess with the RGB channels, as the spec default and the one used by all browsers nowadays is linearRGB, but sRGB is likely what we want in most cases, plus it used to be the only value that worked in Safari, though that has recently changed.

In our particular case, if we don’t set the color-interpolation-filters attribute to sRGB, we won’t get equally sized steps in any browser other than older Safari versions which use sRGB anyway.

Inside this filter, we have a feComponentTransfer primitive. This allows us to manipulate the RGBA channels individually (via the suggestively named feFuncR, feFuncG, feFuncB and feFuncA) in various ways. In this case, we have a gradient from black (0% red) to red (100% red) so we manipulate the red channel using feFuncR.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='step' color-interpolation-filters='sRGB'>
    <feComponentTransfer>
      <feFuncR type='discrete' tableValues='0 1'/>
    </feComponentTransfer>
  </filter>
</svg>

We’ve set the type of feFuncR to discrete, meaning the output red channel only has discrete values and these values are those specified by the tableValues attribute, so in our case here, 0 and 1 (1 being the decimal representation of 100%).

What does this mean? Where the input value for the red channel is below 50% (.5), so on the left half of the initial gradient, the filter output value for the red channel is 0 (zeroed). And where the input value for the red channel is at least 50% (.5), so on the right half of the initial gradient, the filter output for the red channel is 1 (maxed out). Since the green and blue channels are zero everyehere, this makes the left half of our gradient a black step and the right half a red step.

Screenshot of the two step gradient example. Shows the filter input (the initial left to right, black to red gradient) and output (the filtered gradient, the one in two steps: black and red). On top of these gradients, we also have the boundary lines for the two intervals the [0%, 100%] interval of the red channel progression (which coincides with the input gradient) is split into. At the bottom, the feFuncA primitive creating this result is also shown.
a black to red stepped gradient with 2 steps, before and after applying the step filter

Basically, when we have two values for tableValues, the [0, 1] interval of possible red input values gets split into two: [0, .5) and [.5, 1]. The first interval [0, .5) gets mapped to the first of the two tableValues and the second interval [.5 1] gets mapped to the second of the two tableValues.

Now let’s say we add a .5 value in between:

<feFuncR type='discrete' tableValues='0 .5 1'/>

This gives us three steps from left to right: a 0% red (black), a 50% red (maroon) and a 100% red (red).

Now that we have three values for tableValues, the [0, 1] interval of possible red input values gets split into three: [0, .(3)), [.(3),.(6)) and [.(6), 1]. The first one, in our case being the left third of the gradient, gets mapped to 0, so we have 0% red (black) there. The second one, in our case being the middle third of the gradient, gets mapped to .5, so we have 50% red (marron) there. The third one, in our case being the right third of the gradient, gets mapped to 1, so we have 100% red (red) there.

Screenshot of the three step gradient example. Shows the filter input (the initial left to right, black to red gradient) and output (the filtered gradient, the one in three steps: black, maroon and red). On top of these gradients, we also have the boundary lines for the three intervals the [0%, 100%] interval of the red channel progression (which coincides with the input gradient) is split into. At the bottom, the feFuncA primitive creating this result is also shown.
a black to red stepped gradient with 3 steps, before and after applying the step filter

We can also have four equally spaced values for tableValues:

<feFuncR type='discrete' tableValues='0 .333 .667 1'/>

This gives us 4 steps from left to right:

Screenshot of the four step gradient example. Shows the filter input (the initial left to right, black to red gradient) and output (the filtered gradient, the one in four steps). On top of these gradients, we also have the boundary lines for the four intervals the [0%, 100%] interval of the red channel progression (which coincides with the input gradient) is split into. At the bottom, the feFuncA primitive creating this result is also shown.
a black to red stepped gradient with 4 steps, before and after applying the step filter

In general, n equally spaced values for tableValues produce n equal steps for our black to red gradient:

adjusting the number of steps adjusts tableValues and the visual result (live demo – note that if you’re on a wide gamut display, you’re likely to see this broken in Chrome)

If we use Pug, we can easily generate these values within a loop:

- let a = new Array(n).fill(0).map((_, i) => +(i/(n - 1)).toFixed(2));

svg(width='0' height='0' aria-hidden='true')
filter#step(color-interpolation-filters='sRGB')
feComponentTransfer
feFuncR(type='discrete' tableValues=`${a.join(' ')}`)

Great, but this is a simple black to red gradient. How can we create a stepped orange to purple gradient, for example?

Extending the Technique: Different Palettes

This technique works for gradients where we vary any of the four RGBA channels from 0% to 100% along the gradient line while keeping all other three channels constant along the entire gradient line, though not necessarily zeroed or maxed out like in the black to red gradient example.

For example, we could make the green channel G go from 0% to 100% along the gradient line, while the red channel is fixed at 26% for the entire gradient, the blue channel is fixed at 91% for the entire gradient and the alpha channel is fixed at 83% for the entire gradient. This means we go from a slightly faded blue first step (rgb(26% 0% 91%/ 83%)) to a somewhat desaturated aqua (rgb(26% 100% 91%/ 83%)) for the final step.

Screenshot of a five step gradient example where we vary the green channel while keeping all others constant along the entire gradient line. Shows the filter input (the initial left to right, faded blue to desaturated aqua gradient) and output (the filtered gradientin five steps). On top of these gradients, we also have the boundary lines for the five intervals the [0%, 100%] interval of the green channel progression (which coincides with the input gradient progression) is split into.
our example gradient, initial and with a 5 step filter applied

Below you can see how you can play with an interactive demo that allows to create a custom 5 step gradient that has steps along one of the four channels while the others have a fixed value, custom set by us, but fixed for the entire gradient.

picking the gradient channel and adjusting values for the fixed ones (live demo – note that this demo may also suffer from the Chrome wide gamut bug)

Out of all these cases, the most interesting one is the one varying the alpha.

First off, using the alpha channel allows us to avoid both the wide gamut bug and another Chrome bug we hit when using one of the RGB channels, but not the alpha channel.

Chrome screenshot of four 5-step gradients obtained by varying each of the four RGBA channels while keeping all other constant. For those varying the RGB channels, the step edges are jagged, which doesn't happen when varying the alpha channel.
the step edges are jagged in Chrome for the steps created on the RGB channels, but not on the alpha channel (live test)

Secondly, what this allows us to do is to fade any RGB value in steps along the entire alpha interval. And if we place this stepped fade on top of another solid RGB background, we get our desired stepped gradient where we only need to know the start (the layer underneath, seen exactly as set for the first step where the alpha of the stepped gradient above is 0) and end step (used for the stepped gradient on top).

This is exactly how the gradients in the image at the start of the article were created. We have a .step element with a solid background set to the start step --c0 and a pseudo fully covering it with a gradient where we vary the alpha of the end step --c1.

.step {
  position: relative;
  background: var(--c0);

  &::before {
    position: absolute;
    inset: 0;
    background: linear-gradient(90deg, #0000, var(--c1));
    filter: url(#step);
    content: "";
  }
}

This pseudo has the alpha step filter applied.

//- change this to change number of steps
- let n = 10;
- let a = new Array(n).fill(0).map((_, i) => +(i/(n - 1)).toFixed(3));

svg(width='0' height='0' aria-hidden='true')
filter#step
feComponentTransfer
feFuncA(type='discrete' tableValues=a.join(' '))

Note that in this case when we’re creating the steps on the alpha channel and we’re not touching the RGB channels, we don’t even need the color-interpolation-filters attribute anymore.

You can check out the live demo for various --c0 and --c1 combinations below:

And yes, in case anyone is wondering, the pure CSS and the SVG filter results are identical – you can check it out in this demo.

Simplifying the Technique for the Future

It feels a bit inconvenient to use pseudo-elements for this instead of just having a background. The filter() function solves this problem. It takes an image (which can also be a CSS gradient) and a filter chain as inputs, then outputs the filtered image. This output can be used anywhere an image can be used in CSS — as a background-image, mask-image, border-image, even shape-outside!

This way, our CSS can become:

.step {
  background: 
    filter(linear-gradient(90deg, #0000, var(--c1)), url(#step)) 
    var(--c0)
}

Much simpler!

The catch? While Safari has supported this for a decade (I first learned about this function and the Safari implementation in the summer of 2015!), no other browser has followed since. Here are the Chrome and Firefox bugs for anyone who wants to show interest and add to the use cases.

Here is the filter() version of the stepped gradients demo, but keep in mind it only works in Safari.

Extending the Technique: Back and Forth Steps

Now let’s say we wanted to modify our gradient to go back to black from red in the middle (we’re using the red and black gradient example here because of the contrast):

.step {
  background: linear-gradient(90deg, #000, #f00, #000);
  filter: url(#step)
}

The filter is generated exactly the same as before:

- let n = 5;
- let a = new Array(n).fill(0).map((_, i) => +(i/(n - 1)).toFixed(3));

svg(width='0' height='0' aria-hidden='true')
filter#step(color-interpolation-filters='sRGB')
feComponentTransfer
feFuncR(type='discrete' tableValues=`${a.join(' ')}`)

This Pug is producing the following HTML:

<svg width='0' height='0' aria-hidden='true'>
  <filter id='step' color-interpolation-filters='sRGB'>
    <feComponentTransfer>
      <feFuncR id='func' type='discrete' tableValues='0 .25 .5 .75 1'/>
    </feComponentTransfer>
  </filter>
</svg>

In this case of a gradient going back, basically being reflected with respect to the middle, the middle red step is doubled when using the exact same red channel step gradient as before.

Screenshot of a 2·5 = 10 step gradient. Shows the filter input (the initial left to right, black to red and then back to black gradient) and output (the filtered gradient, in 5 + 5 steps). On top of these gradients, we also have the boundary lines for the intervals the [0%, 100%] interval of the red channel progression (which coincides with the first half of the input gradient) is split into. This simple linear gradient reflection results in a doubling of the middle red step.

This makes perfect sense. We’re reflecting the gradient and this repeats the 100% red step in the middle. We don’t have n steps across the gradient line anymore, we have 2·n of them, with the two in the middle having the same RGBA value (100% red).

What we need to do is make it look like we only have 2·n - 1 steps by making the two steps in the middle half the size of the other ones. This means moving the cutoff to 100% red ((n - 1)/n·100% red, which is 80% red in the n = 5 example case here) at half a 100%/(2·n - 1) interval from the middle, both before and after. So our CSS becomes:

.step {
  /* cutoff to 100% red */
  --r: rgb(calc((var(--n) - 1) / var(--n) * 100%), 0%, 0%);
  /* distance from the middle of gradient line */
  --d: 0.5 * 100%/ (2 * var(--n) - 1);
  background: linear-gradient(
    90deg,
    #000,
    var(--r) calc(50% - var(--d)),
    #f00,
    var(--r) calc(50% + var(--d)),
    #000
  );
}

This does the trick!

Screenshot of a 2·5 = 10 step gradient. Shows the filter input (the initial left to right, black to red and then back to black gradient) and output (the filtered gradient, in 5 + 5 steps). On top of these gradients, we also have the boundary lines for the intervals the [0%, 100%] interval of the red channel progression (which coincides with the first half of the input gradient) is split into. The trick here is that the two middle intervals aren't equal to all the others, but half of them. This way, the middle red step is still doubled, but it's also half the size of all other steps, so it doesn't look like it's repeated twice.

The stops we’re specifying in the CSS in the particular case of n = 5 are the 0% red (implicitly at the 0% point of the gradient line), the 80% red at the 44.(4)% point of the gradient line (set explicitly), the 100% red (implicitly at the 50% point of the gradient line), the 80% red at the 55.(5)% point of the gradient line (also set explicitly) and the 0% red (implicitly at the 100% point of the gradient line).

If we wanted to fine tune things, we could also simplify the middle offset computations:

50% - d = 
50% - .5·100%/(2·n - 1) =
50% - 50%/(2·n -1) =
50%·(1 - 1/(2·n - 1)) =
50%·(1 - f)

So our CSS would become:

.step {
  /* cutoff to 100% red */
  --r: rgb(calc((var(--n) - 1) / var(--n) * 100%), 0%, 0%);
  /* fraction of distance from middle of gradient line */
  --f: 1/ (2 * var(--n) - 1);
  background: linear-gradient(
    90deg,
    #000,
    var(--r) calc(50% * (1 - var(--f))),
    #f00,
    var(--r) calc(50% * (1 + var(--f))),
    #000
  );
}

Note that the SVG filter remains the exact same as before, we just pass the number of steps n to the CSS as a custom property:

- let n = 5;

// exact same SVG filter as before
.step(style=`--n: ${n}`)

If we want our gradient to repeat and we don’t want a doubled end/start step, we need to do something similar at the other end of the red scale (channel scale in general) and make it look as if the start/end step is 100%/(2·(n - 1)) of the gradient line (not 100%/(2·n - 1) like in the case of no gradient repetition reflection).

.step {
  /* cutoff to 0% red */
  --r0: rgb(calc(1 / var(--n) * 100%), 0%, 0%);
  /* cutoff to 100% red */
  --r1: rgb(calc((var(--n) - 1) / var(--n) * 100%), 0%, 0%);
  /* fraction of distance from middle/ end of gradient line */
  --f: 1/ (2 * (var(--n) - 1));
  background: linear-gradient(
      90deg,
      #000,
      var(--r0) calc(50% * var(--f)),
      var(--r1) calc(50% * (1 - var(--f))),
      #f00,
      var(--r1) calc(50% * (1 + var(--f))),
      var(--r0) calc(50% * (2 - var(--f))),
      #000
    )
    0/ 50%;
}

Note that we’ve used a background-size of 50%, which means 2 repetitions. For a generic number of repetitions q, our background-size is 100%/q.

For the alpha channel variation that allows us to get any gradient from any --c0 to any --c1, it’s very similar:

.step {
  /* cutoff to 0% alpha */
  --a0: rgb(from var(--c1) r g b/ calc(1 / var(--n)));
  /* cutoff to 100% alpha */
  --a1: rgb(from var(--c1) r g b/ calc((var(--n) - 1) / var(--n)));
  /* fraction of distance from middle/ end of gradient line */
  --f: 1/ (2 * (var(--n) - 1));
  position: relative;
  background: var(--c0);

  &::before {
    position: absolute;
    inset: 0;
    background: linear-gradient(
        90deg,
        #0000,
        var(--a0) calc(50% * var(--f)),
        var(--a1) calc(50% * (1 - var(--f))),
        var(--c1),
        var(--a1) calc(50% * (1 + var(--f))),
        var(--a0) calc(50% * (2 - var(--f))),
        #0000
      )
      0 / calc(100% / var(--q));
    filter: url(#step);
    content: "";
  }
}

You can play with the demo below by changing the number of repetitions q to see how the result changes without needing to modify anything else.

What if we wanted to have full steps at the start of the first repetition and at the end of last repetition? Well, in that case, given a number q of repetitions, we can compute the width of the lateral borders to be equal to half a step size on each side. A step size is 1/(2·q·(n - 1) + 1) of the pseudo parent’s content-box width, so the border-width on the pseudo needs to be half of that.

.step {
  /* cutoff to 0% alpha */
  --a0: rgb(from var(--c1) r g b/ calc(1 / var(--n)));
  /* cutoff to 100% alpha */
  --a1: rgb(from var(--c1) r g b/ calc((var(--n) - 1) / var(--n)));
  /* fraction of distance from middle/ end of gradient line */
  --f: 1/ (2 * (var(--n) - 1));
  container-type: inline-size;
  position: relative;
  background: var(--c0);

  &::before {
    position: absolute;
    inset: 0;
    border: solid 0 #0000;
    border-width: 0 calc(50cqw / (2 * var(--q) * (var(--n) - 1) + 1));
    background: linear-gradient(
        90deg,
        #0000,
        var(--a0) calc(50% * var(--f)),
        var(--a1) calc(50% * (1 - var(--f))),
        var(--c1),
        var(--a1) calc(50% * (1 + var(--f))),
        var(--a0) calc(50% * (2 - var(--f))),
        #0000
      )
      0 / calc(100% / var(--q));
    filter: url(#step);
    content: "";
  }
}

Modified interactive demo:

What makes this possible and easy is the fact that, by default, background-size and background-position are relative to the padding-box (their position is relative to the top left corner of the padding-box, so that 0 position is relative to the padding-box left edge and their size is relative to the padding-box dimensions, so that 100% in calc(100%/var(--q)) is relative to the padding-box width), but extra background repetitions are painted in all directions under the border too.

The box model and the default way backgrounds are painted. Their size and position is relative to the padding-box, but repetitions are also painted underneath the borders.
by default, backgrounds cover the entire border-box, but start from the top left corner of the padding-box

Note that the whole reflect and repeat could be very much simplified on the CSS side if CSS gradients also allowed reflecting repetition like SVG ones do.

Extending the Technique: Different Gradients

We’ve only used a left to right linear-gradient() so far, but the direction of the gradient may vary and we may well use a radial-gradient() or a conic-gradient() instead. Nothing changes about the filter in this case. The gradients below all use the exact same filter.

Regardless of the gradient type, the filter() function is going to simplify things if Chrome and Firefox implement it too. The relevant code for the demo above would become:

.step {
  --c0: #00272b;
  --c1: #e0ff4f;
  --s: #0000, var(--c1);
  background: filter(var(--img, conic-gradient(var(--s))), url(#step)) var(--c0);
}

.linear {
  --img: linear-gradient(to right bottom, var(--s));
}
.radial {
  --img: radial-gradient(circle, var(--s));
}

You can check out the live demo, but remember it only works in Safari.

Refining Things

The results aren’t perfect. When using radial or conic gradients or even linear ones at weird angles, we get jagged edges in between the steps. I guess it doesn’t look that bad in between steps that may be reasonably similar, but if we wanted to do something about it, what could we do?

When creating the steps from the CSS, we can always use the 1px difference trick (avoid using a 1% difference for this, it can be unreliable) to smoothen things for radial and linear ones (the conic gradient case is a lot more complicated though and I haven’t been able to find a pure CSS solution that doesn’t involve emulating the conic gradient with a linear one).

But what can we do about it in the case of steps created via an SVG filter?

Given the introduction of the 1px difference produces an effect similar to a tiny blur, the first instinct would be to try to blur the whole thing. However, the result looks terrible, even when we correct the edge alpha decrease, so the blur idea goes out the window!

We could also smoothen the edges of each step using a technique similar to the no matrix filter gooey effect. That mostly works, save for a bit of weird rounding at the edges for all gradients and in the middle of the conic one. But that’s a lot of filter primitives, a lot for such a tiny visual gain.

Another option would be to try to simplify this technique and smoothen the edges of even steps – this avoids increasing the number of primitives with the number of steps, but also comes with other technical challenges. So at the end of the day, it’s another path I’m not fully convinced it’s worth taking for such a small visual gain. Not to mention the weird edge rounding and the even more obvious clump in the middle of the conic-gradient().

Finally, we could make the gradients grainy. But the approach discussed in a previous article is likely not what we’re going for.

There may be cases where it is what we want, for example when it comes to such dithered band cards:

Most of the time, this is probably not the desired result. So maybe try another approach to grainy gradients, one that doesn’t use displacement maps and also doesn’t alter the gradient palette?

We could use the old approach of layering and blending with a desaturated noise layer whose alpha we also reduce to a certain extent before blending:

<svg width='0' height='0' aria-hidden='true'>
  <filter id='grain' x='0' y='0' width='1' height='1' 
          color-interpolation-filters='sRGB'>
    <feTurbulence type='fractalNoise' baseFrequency='.713' numOctaves='3'/>
    <feColorMatrix type='saturate' values='0'/>
    <feComponentTransfer>
      <feFuncA type='linear' slope='.6'/>
    </feComponentTransfer>
    <feBlend in2='SourceGraphic' mode='overlay'/>
  </filter>
</svg>

Here, we fully desaturate the noise produced by feTurbulence and then scale down its alpha (to .6 of what it would be otherwise).

This is the path taken by the sunburst demo below, which was created taking inspiration from the heading image of an earlier post here:

This comes with the disadvantage of altering the original palette, but if that’s not as much of an issue, it could work.

Finally, another option would be XOR-ing the alpha of the desaturated and reduced alpha noise layer and the alpha of the steps:

]]>
https://frontendmasters.com/blog/step-gradients-with-a-given-number-of-steps/feed/ 0 6338
Grainy Gradients https://frontendmasters.com/blog/grainy-gradients/ https://frontendmasters.com/blog/grainy-gradients/#comments Fri, 13 Jun 2025 14:37:36 +0000 https://frontendmasters.com/blog/?p=6066 You know when you set a background gradient or a gradient mask and you get an ugly banding effect? If you can’t picture what I mean, here’s an example:

A left to right pinkinsh orange to dark grey gradient exhibiting banding.
 example gradient with banding

Previous Solutions

Over time, I’ve seen a couple of approaches commonly recommended for solving this. The first is to simply introduce more stops (gradient “easing”), which I’m not really keen on doing, even if I can just generate them in a Sass loop and never need to know about them. The second one is to make the gradient noisy. Let’s do that.

The way I first went about making gradients grainy was to have a gradient layer and a noise layer (using pseudo-elements for the layers) and then blend them together. I first did this in response to a question asked on X. That video became one of my most watched ones ever, which isn’t something I’m happy about anymore because I’ve come to find that technique to be overly complicated, like scratching behind the right ear with the left foot.

A few months later, I saw an article that was doing something similar: placing a gradient layer and a noise layer one on top of the other. Unlike my approach, it wasn’t blending the two layers and instead was relying on one end of the gradient being transparent to allow the noise to show through. For the other end to be something other than transparent, it would layer an overlay and blend it. Just like my layered pseudos approach… too complicated! Not to mention that the contrast() and brightness() tampering (meant to highlight the grain) make this only work for certain gradient inputs and they greatly alter the saturation and luminosity of the original gradient palette.

In time, I would improve upon my initial idea and, almost half a decade later, I would make a second video on the topic, presenting a much simplified technique. Basically, the gradient would get fed into an SVG filter, which would generate a noise layer, desaturate it and then place it on top of the input gradient. No external files, no base64-ing anything, no separate (pseudo)element layers for the noise and the gradient.

Still, it didn’t take long before I wasn’t happy with this solution anymore, either.

The big problem with layering the noise and the gradient

The problem with all of these solutions so far is that they’re changing the gradient. Depending on the particular technique we use, we always end up with a gradient that’s either darker, brighter, or more saturated than our original one.

We can reduce the noise opacity, but in doing so, our gradient becomes less grainy and the efficiency of fixing banding this way decreases.

A better solution

How about not layering the the noise layer and instead using it as a displacement map?

What this does is use two of the four RGBA channels of the noise layer to determine how the individual pixels of the input gradient are shifted along the x and y axes.

Both the filter input (our gradient) and the noise layer can be taken to be 2D grids of pixels. Each pixel of our input gradient gets displaced based on the two selected channel values of its corresponding pixel in the noise layer (used as a displacement map).

A channel value below 50% means moving in the positive direction of the axis, a channel value above 50% means moving in the negative direction of the axis and a channel value of exactly 50% means not moving at all.

The displacement formula for a generic channel value of C and a displacement scale of S is the following:

(.5 - C)*S

If we use the red channel R for displacement along the x axis and the alpha channel A for displacement along the y axis, then we have:

dx = (.5 - R)*S
dy = (.5 - A)*S

Note that the values for both R and A are in the [0, 1] interval (meaning channel values are zeroed at 0 and maxed out at 1), so the difference between the parenthesis is in the [-.5, .5] interval.

The bigger the scale value S is, the more the gradient pixels mix along the gradient axis depending on the red R and alpha A channel values of the displacement map generated by feTurbulence.

Let’s see our code!

<svg width='0' height='0' aria-hidden='true'>
  <filter id='grain' color-interpolation-filters='sRGB'>
    <feTurbulence type='fractalNoise' baseFrequency='.9713' numOctaves='4'/>
    <feDisplacementMap in='SourceGraphic' scale='150' xChannelSelector='R'/>
  </filter>
</svg>

Since the <svg> element is only used to hold our filter (and the only thing a filter does is apply a graphical effect on an already existing element), it is functionally the same as a <style> element, so we zero its dimensions and hide it from screen readers using aria-hidden. And, in the CSS, we also take it out of the document flow (via absolute or fixed positioning) so it doesn’t affect our layout in any way (which could happen otherwise, even if its dimensions are zeroed).

svg[height='0'][aria-hidden='true'] { position: fixed }

The <filter> element also has a second attribute beside its id. We aren’t going into it here because I don’t really understand it myself. Just know that, in order to get our desired result cross-browser, we always need to set this attribute to sRGB whenever we’re doing anything with the RGB channels in the filter. The sRGB value isn’t the default one (linearRGB is), but it’s the one we likely want most of the time and the only one that works properly cross-browser.

The feTurbulence primitive creates a fine-grained noise layer. Again, we aren’t going into how this works in the back because I haven’t been able to really understand any of the explanations I’ve found or I’ve been recommended for the life of me.

Just know that the baseFrequency values (which you can think of as being the number of waves per pixel) need to be positive, that integer values produce just blank and that bigger values mean a finer grained noise. And that numOctaves values above the default 1 allow us to get a better-looking noise without having to layer the results of multiple feTurbulence primitives with different baseFrequency values. In practice, I pretty much never use numOctaves values bigger than 3 or at most 4 as I find above that, the visual gain really can’t justify the performance cost.

We also switch here from the default type of turbulence to fractalNoise, which is what’s suited for creating a noise layer.

This noise is then used as a displacement map (the second input, in2, which is by default the result of the previous primitive, feTurbulence here, so we don’t need to set it explicitly) for the filter input (SourceGraphic). We use a scale value of 150, which means that the maximum an input pixel can be displaced by in either direction of the x or y axis is half of that (75px) in the event the channel used for x or y axis displacement is either zeroed (0) or maxed out (1) there. The channel used for the y axis displacement is the default alpha A, so we don’t need to set it explicitly, we only set it for the x axis displacement.

We’re using absolute pixel displacement here, as relative displacement (which requires the primitiveUnits attribute to be set to objectBoundingBox on the <filter> element) is not explicitly defined in the spec, so Chrome, Firefox and Safari each implement it in a different way from the other two for non-square filter inputs. I wish that could be a joke, but it’s not. This is why nobody really uses SVG filters much — a lot about them just doesn’t work. Not consistently across browsers anyway.

At this point, our result looks like this:

Grainy gradient with dithered edges. A bright pink outline shows us the boundary of the filter input. Within this boundary, we have transparent pixels. Outside it, we have opaque pixels.

Not quite what we want. The dashed bright pink line shows us where the boundary of the filter input gradient box was. Along the edges, we have both transparent pixels inside the initial gradient box and opaque pixels outside the initial gradient box. Two different problems, each needing to get fixed in a different way.

To cover up the transparent pixels inside the initial gradient box, we layer the initial gradient underneath the one scrambled by feDisplacementMap. We do this using feBlend with the default mode of normal (so we don’t need to set it explicitly), which means no blending, just put one layer on top of the other. The bottom layer is specified by the second input (in2) and in our case, we want it to be the SourceGraphic. The top layer is specified by the first input (in) and we don’t need to set it explicitly because, by default, it’s the result of the previous primitive (feDisplacementMap here), which is exactly what we need in this case.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='grain' color-interpolation-filters='sRGB'>
    <feTurbulence type='fractalNoise' baseFrequency='.9713' numOctaves='4'/>
    <feDisplacementMap in='SourceGraphic' scale='150' xChannelSelector='R'/>
    <feBlend in2='SourceGraphic'/>
  </filter>
</svg>

I’ve seen a lot of tutorials using feComposite with the default operator of over or feMerge to place layers one on top of another, but feBlend with the default mode of normal produces the exact same result, I find it to be simpler than feMerge in the case of just two layers and it’s fewer characters than feComposite.

To get rid of the opaque pixels outside the initial gradient box, we restrict the filter region to its exact input box — starting from the 0,0 point of this input and covering 100% of it along both the x and y axis (by default, the filter region starts from -10%,-10% and covers 120% of the input box along each of the two axes). This means explicitly setting the x, y, width and height attributes:

<svg width='0' height='0' aria-hidden='true'>
  <filter id='grain' color-interpolation-filters='sRGB' 
	  x='0' y='0' width='1' height='1'>
    <feTurbulence type='fractalNoise' baseFrequency='.9713' numOctaves='4'/>
    <feDisplacementMap in='SourceGraphic' scale='150' xChannelSelector='R'/>
    <feBlend in2='SourceGraphic'/>
  </filter>
</svg>

Another option to get rid of this second problem would be to use clip-path: inset(0) on the element we apply this grainy filter on. This is one situation where it’s convenient that clip-path gets applied after filter (the order in the CSS doesn’t matter here).

.grad-box {
  background: linear-gradient(90deg, #a9613a, #1e1816);
  clip-path: inset(0);
  filter: url(#grain)
}
Grainy gradient with sharp edges, no tansparent pixels within, no opaque pixels outside.
the desired result

A problem with this solution

The inconvenient part about this filter is that it applies to the entire element, not just its gradient background. And maybe we want this element to also have text content and a box-shadow. Consider the case when before applying the filter we set a box-shadow and add text content:

Card with a banded gradient, text and box-shadow.
the case when we also have a shadow and text

In this case, applying the filter to the entire element causes all kinds of problems. The text “dissolves” into the gradient, the black box-shadow outside the box has some pixels displaced inside the box over the gradient – this is really noticeable in the brighter parts of this gradient. Furthermore, if we were to use the clip-path fix for the gradient pixels displaced outside the initial gradient box, this would also cut away the outer shadow.

Previous card with a banded gradient, text and box-shadow, now with a filter pplied on it too. This has unpleasant side effects as dscribed above.
problems arising when we apply the grainy filter on the entire element

The current solution would be to put this gradient in an absolutely positioned pseudo behind the text content (z-index: -1), covering the entire padding-box of its parent (inset: 0). This separates the parent’s box-shadow and text from the gradient on the pseudo, so applying the filter on the pseudo doesn’t affect the parent’s box-shadow and text.

.grad-box { /* relevant styles */
  positon: relative; /* needed for absolutely positioned pseudo */
  box-shadow: -2px 2px 8px #000;
	
  &::before {
    position: absolute;
    inset: 0;
    z-index: -1;
    background: linear-gradient(90deg, #a9613a, #1e1816);
    filter: url(#grain);
    clip-path: inset(0);
    content: '' /* pseudo won't show up without it */
  }
}
Previous card with a gradient, text and box-shadow, except now the gradient is grain, which fixes the banding issue.
the desired result when having a shadow and text content (live demo)

Improving things for the future

While this works fine, it doesn’t feel ideal to have to use up a pseudo we might need for something else and, ugh, also have to add all the styles for positioning it along all three axes (the z axis is included here too because we need to place the pseudo behind the text content).

And we do have a better option! We can apply the filter only on the gradient background layer using the filter() function.

This is not the same as the filter property! It’s a function that outputs an image and takes as arguments an image (which can be a CSS gradient too) and a filter chain. And it can be used anywhere we can use an image in CSS — as a background-imageborder-imagemask-image… even shape-outside!

In our particular case, this would simplify the code as follows:

.grad-box { /* relevant styles */
  box-shadow: -2px 2px 8px #000;
  background: filter(linear-gradient(90deg, #a9613a, #1e1816), url(#grain));
}

Note that in this case we must restrict the filter region from the <filter> element attributes, otherwise we run into a really weird bug in the one browser supporting this, Safari.

Safari problem: it's trying to fit the filter output, including what goes outside the input image box, into the input image box, basically scaling down the image to make room for its pixels displaced outside its initial boundary by the filter.
the Safari problem when we don’t restrict the filter region

Because, while Safari has supported the filter() function since 2015, for about a decade, sadly no other browser has followed. There are bugs open for both Chrome and Firefox in case anyone wants to show interest in them implementing this.

Here is the live demo, but keep in mind it only works in Safari.

This would come in really handy not just for the cases when we want to have text content or visual touches (like box-shadow) that remain unaffected by the noise filter, but especially for masking. Banding is always a problem when using radial-gradient() for a mask and, while we can layer multiple (pseudo)elements instead of background layers and/ or borders, masking is a trickier problem.

For example, consider a conic spotlight. That is, a conic-gradient() masked by a radial one. In this case, it would really help us to be able to apply a grain filter directly to the mask gradient.

.conic-spotlight {
  background: 
    conic-gradient(from 180deg - .5*$a at 50% 0%, 
                   $side-c, #342443, $side-c $a);
  mask: 
    filter(
      radial-gradient(circle closest-side, red, 65%, #0000), 
      url(#grain))
}

In this particular case, the grain filter is even simpler, as we don’t need to layer the non-grainy input gradient underneath the grainy one (so we ditch that final feBlend primitive). Again, remember we need to restrict the filter region from the <filter> element attributes.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='grain' color-interpolation-filters='sRGB' x='0' y='0' width='1' height='1'>
    <feTurbulence type='fractalNoise' baseFrequency='.9713'/>
    <feDisplacementMap in='SourceGraphic' scale='40' xChannelSelector='R'/>
  </filter>
</svg>

Here is the live demo. Keep in mind it only works in Safari.

Since we can’t yet do this cross-browser, our options depend today on our constraints, the exact result we’re going for.

Do we need an image backdrop behind the spotlight? In this case, we apply the radial mask on the .conic-spotlight element and, since, just like clip-path, mask gets applied after filter, we add a wrapper around this element to set the filter on it. Alternatively, we could set the conic spotlight background and the radial mask on a pseudo of our .conic-spotlight and set the filter on the actual element.

.conic-spotlight {
  display: grid;
  filter: url(#grain);
	
  &::before {
    background: 
      conic-gradient(from 180deg - .5*$a at 50% 0%, 
                     $side-c, #342443, $side-c $a);
    mask: radial-gradient(circle closest-side, red, 65%, #0000);
    content: ''
  }
}

If however we only need a solid backdrop (a black one for example), then we could use a second gradient layer as a radial cover on top of the conic-gradient():

body { background: $back-c }

.conic-spotlight {
  background:
    radial-gradient(circle closest-side, #0000, 65%, $back-c), 
    conic-gradient(from 180deg - .5*$a at 50% 0%, 
                   $side-c, #342443, $side-c $a);
  filter: url(#grain)
}

Note that neither of these two emulate the Safari-only demo exactly because they apply the grain filter on the whole thing, not just on the radial-gradient() (which allows us to get rid of the mask banding, but preserve it for the conic-gradient() to give the radiating rays effect). We could tweak the second approach to make the cover a separate pseudo-element instead of a background layer and apply the grain filter just on that pseudo, but it’s still more complicated than the filter() approach. Which is why it would be very good to have it cross-browser.

Some more examples

Let’s see a few more interesting demos where we’ve made visuals grainy!

Grainy image shadows

a grid of square images, each with a grainy shadow that's a blurred copy of itself
realistic grainy image shadows

Shadows or blurred elements can also exhibit banding issues where their edges fade. In this demo, we’re using a slightly more complex filter to first blur and offset the input image, then using the feTurbulence and feDisplacementMap combination to make this blurred and offset input copy grainy. We also decrease its alpha a tiny little bit (basically multiplying it with .9). Finally, we’re placing the original filter input image on top of this blurred, offset, grainy and slightly faded copy.

- let d = .1;

svg(width='0' height='0' aria-hidden='true')
  filter#shadow(x='-100%' y='-100%' width='300%' height='300%'
                color-interpolation-filters='sRGB'
                primitiveUnits='objectBoundingBox')
    //- blur image
    feGaussianBlur(stdDeviation=d)
    //- then offset it and save it as 'in'
    feOffset(dx=d dy=d result='in')
    //- generate noise
    feTurbulence(type='fractalNoise' baseFrequency='.9713')
    //- use noise as displacement map to scramble a bit the blurred & offset image
    feDisplacementMap(in='in' scale=2*d xChannelSelector='R')
    //- decrease alpha a little bit
    feComponentTransfer
      feFuncA(type='linear' slope='.9')
    //- add original image on top
    feBlend(in='SourceGraphic')

Since our input images are square here, we can use relative length values (by setting primitiveUnits to ObjectBoundingBox) and still get the same result cross-browser. A relative offset of 1 is equal to the square image edge length, both for the dx and dy attributes of feOffset and for the scale attribute of feDisplacementMap.

In our case, the dx and dy offsets being set to .1 means we offset the blurred square image copy by 10% of its edge length along each of the two axes. And the displacement scale being set to .2 means any pixel of the blurred and offset copy may be displaced by at most half of that (half being 10% of the square image edge), with plus or with minus, along both the x and y axes. And it gets displaced by that much when the selected channel (given by xChannelSelector and yChannelSelector) of the corresponding map pixel is either zeroed (in which case it’s displaced in the positive direction) or maxed out (negative displacement).

The shadow doesn’t need to be a copy of the input image, it can also be a plain rectangle:

<svg width='0' height='0' aria-hidden='true'>
  <filter id='shadow' x='-50%' y='-50%' width='200%' height='200%'
          color-interpolation-filters='sRGB'
          primitiveUnits='objectBoundingBox'>
    <!-- flood entire filter region with orangered -->
    <feFlood flood-color='orangered'/>
    <!-- restrict to rectangle of filter input (our image)  -->
    <feComposite in2='SourceAlpha' operator='in'/>
    <!-- blur and everything else just like before  -->
  </filter>
</svg>

Grainy image fade

This is pretty similar to the previous demo, except what we displace are the semi-transparent fading edge pixels obtained using a blur. And we obviously don’t layer the original image on top.

There are a couple more little tricks used here to get things just right, but they’re outside the scope of this article, so we’re not going into them here.

Noisy gradient discs

These are created with SVG <circle> elements just so we can use SVG radial gradients for them. Compared to CSS radial-grdient(), SVG radialGradient has the advantage of allowing us to specify a focal point (via fx and fy), which allows us to create radial gradients not possible with pure CSS.

The filter is a bit more complex here because the aim was to create a specific type of noise, but the main idea is the same.

Animated single img gradient glow border

a grid of images with total or partial gradient borders, each having a glow, which is a grainy glow for every second image
screenshot (live demo)

Animated gradient glow borders seem to be all the rage nowadays, which is something I never imagined woukd happen when I first started playing with them almost a decade ago. But wherever there’s a fade effect like a glow, we may get banding. It’s pretty subtle in this case, but the grainy glow looks better than the no grain version.

Grainy CSS backgrounds

Another example would be this one, where I’m layering a bunch of linear gradients along the circumradii to the corners of a regular polygon in order to emulate a mesh gradient. Even when blending these gradients, subtle banding is still noticeable. Applying our standard grain filter discussed earlier fixes this problem.

Also, since we’re using clip-path to get the polygon shape and this is applied after the filter, we don’t need to worry about opaque pixels displaced outside the polygon shape by our grain filter. This means we don’t need to bother with setting the filter region via the <filter> element attributes.

Grainy SVG backgrounds

The idea here is we layer a bunch of different SVG shapes, give them various fills (plain, linearGradient or radialGradient ones), blur them and then finally apply a grain filter.

a 3⨯3 grid of grainy abstract backgrounds
grainy SVG backgrounds (live demo)
]]>
https://frontendmasters.com/blog/grainy-gradients/feed/ 4 6066
ChatGPT and the proliferation of obsolete and broken solutions to problems we hadn’t had for over half a decade before its launch https://frontendmasters.com/blog/chatgpt-and-old-and-broken-code/ https://frontendmasters.com/blog/chatgpt-and-old-and-broken-code/#comments Tue, 20 May 2025 15:57:01 +0000 https://frontendmasters.com/blog/?p=5808 It was a lovely day on the internet when someone asked how to CSS animated gradient text like ChatGPT’s “Searching the web” and promptly got an answer saying “Have you tried asking ChatGPT? Here’s what it told me!” – well, maybe not these exact words, but at least it rhymes.

Both the question and this answer have since been deleted. But we still have the chat link that got posted in the answer and we’re going to look into it.

Screenshot of ChatGPT Generated Code
screenshot of the code produced by ChatGPT

This is the code that ChatGPT spat out in text format:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Text Color Loading Animation</title>
    <style>
      body {
        background-color: #111;
        display: flex;
        justify-content: center;
        align-items: center;
        height: 100vh;
      }

      .loading-text {
        font-size: 3rem;
        font-weight: bold;
        background: linear-gradient(90deg, #00f, #0ff, #00f);
        background-size: 200% auto;
        background-clip: text;
        -webkit-background-clip: text;
        color: transparent;
        -webkit-text-fill-color: transparent;
        animation: shimmer 2s linear infinite;
      }

      @keyframes shimmer {
        0% {
          background-position: -100% 0;
        }
        100% {
          background-position: 100% 0;
        }
      }
    </style>
  </head>
  <body>
    <div class="loading-text">Loading...</div>
  </body>
</html>

Now you may be thinking: what’s the problem with this code, anyway? If you copy-paste into CodePen, it does produce the desired result, doesn’t it?

Well, we also get the exact same result if we replace this CSS:

.loading-text {
  font-size: 3rem;
  font-weight: bold;
  background: linear-gradient(90deg, #00f, #0ff, #00f);
  background-size: 200% auto;
  background-clip: text;
  -webkit-background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
  animation: shimmer 2s linear infinite;
}

@keyframes shimmer {
  0% {
    background-position: -100% 0;
  }
  100% {
    background-position: 100% 0;
  }
}

with this CSS:

.loading-text {
  font-size: 3rem;
  font-weight: bold;
  background: linear-gradient(90deg, #00f, #0ff, #00f) -100%/ 200%;
  -webkit-background-clip: text;
          background-clip: text;
  color: transparent;
  animation: shimmer 2s linear infinite;
}

@keyframes shimmer {
  to {
    background-position: 100%;
  }
}

You might think the ChatGPT solution includes some fallbacks that maybe make it work in more browsers. You’d be wrong. There are exactly zero browsers in which the ChatGPT solution isn’t broken, but the alternative above is. Zero! Not a single one. In all of the (very old) browsers where the alternative above breaks, the ChatGPT solution breaks too.

The history of gradient text solutions and what ChatGPT gets wrong

Let’s go some 15 years back in time. I discovered CSS in August 2009, just as it was getting new shiny features like transforms and gradients. One of the first tricks I came across online in early 2010 was precisely this — creating image text in general and CSS gradient text in particular.

The declaration I ditched completely from what ChatGPT generated was:

-webkit-text-fill-color: transparent;

This answer only included -webkit-text-fill-color, though I’ve seen versions of this circulating online that use:

-webkit-text-fill-color: transparent;
        text-fill-color: transparent;

There is no such thing as text-fill-color. There isn’t even a standard spec for it. It’s just something that was implemented in WebKit with a prefix almost 2 decades ago and then became used enough on the web that other browsers had to support it too. With the -webkit- prefix. So the prefixed version is the only one that has ever been implemented in any browser.

While WebKit introduced this property alongside -webkit-text-stroke, its usage would end up far exceeding that of -webkit-text-stroke. This is something that started about 15 years ago when -webkit-text-fill-color became a common tactic for making text transparent only in WebKit browsers. Precisely for image text in general and then a lot more often for gradient text in particular.

At the time, clipping backgrounds to text was only supported in WebKit browsers via the (back then) non-standard -webkit-background-clip: text. If we wanted to get a visible gradient text this way, we had to be able to see through the actual text on top of the clipped background. Problem was, back in 2010, if we set color to transparent, then we’d get:

  • Gradient text as desired in browsers supporting both -webkit-background-clip: text and CSS gradients
  • No visible text and a gradient rectangle in browsers supporting CSS gradients, but not -webkit-background-clip: text (in 2010, this would have been the case for Firefox)
  • Nothing visible at all in browsers not supporting CSS gradients (remember the first IE version to support CSS gradients was IE10, which only came out in 2012; plus Opera still had its own engine back then and wouldn’t get CSS gradients for another year)
Visual representation of the three cases described above.
the state of things in 2010

So given the only browsers we could get gradient text in at the time were WebKit browsers, it made sense to restrict both setting a gradient background and making the text transparent to WebKit browsers. At the time, all gradients were prefixed, so that made the first part easier. For the second part, the solution was to use -webkit-text-fill-color to basically override color just for WebKit browsers.

So to get an aqua to blue gradient text with a decent in-between blue text fallback in non-supporting browsers, the code we had to write in 2010 looked like this:

color: #07f;
background: 
  -webkit-gradient(linear, 0 0, 100% 0, 
    color-stop(0, #0ff), color-stop(1, #00f));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent

Note that at the time, this was seen as a temporary WebKit-only solution, as other options for getting such gradient text were being discussed. The “temporary” solution stuck, I guess. And so it’s now in the spec.

And yes, that is a different gradient syntax (which, by the way, is still supported to this day in Chrome). The syntax for CSS gradients went through multiple iterations. After this one, we had another -webkit- prefixed version, a lot more similar to what we have today, but still not the same. If you’re curious, you can see a bit more about the support timeline in my 10 year old rant about using gradient generators without understanding the code they spit out and without having a clue how much of it is really necessary. Because before we had AI chatbots spitting out too much needless CSS for the desired result, we had all these CSS3 generators doing pretty much the same! Like a song says, oh, it was different… and yet the same!

Using -webkit-text-fill-color to override color just for WebKit browsers, we got:

Visual representation of possible results in 2010, when using -webkit-text-fill-color: transparent to allow seeing through the text in WebKit browsers. If all -webkit- prefixed properties were supported, we got a left to right, aqua to blue gradient text. If none were supported, we got an in-between blue fallback. If -webkit-background-clip: text was not supported while -webkit-gradient and -webkit-text-fill-color were, then we got no visible text on a left to right, aqua to blue gradient rectangle. If -webkit-text-fill-color was supported and set the text to transparent, but -webkit-gradient wasn't, then nothing would show up. In this final case, the support status for -webkit-background-clip: text was irrelevant.
the state of things in 2010

Better, I guess, but not ideal.

Since support for -webkit-text-fill-color came before for CSS gradients, that left a gap where the text would be made transparent, but there would be no gradient to be clipped to (making the support status for -webkit-background-clip: text irrelevant). In this case, there would be no visible text and no gradient… just nothing but empty space.

I first thought that anyone who’d be using one of these browsers that would always be the first to support new and shiny things would also care about keeping them updated, right? Right?

Wrong! Some months later, I went to a job interview. I’ve always been a show-off, I’m like a fish in the water when live coding, so I jumped on the chance to actually get in front of a computer and impress with all the cool things I could do thanks to the new CSS features. They were using a Chrome version that was quite a bit behind the then current one. CSS gradients did not work.

I started using Modernizr to test for CSS gradient support after that. We didn’t have @supports back then, so Modernizr was the way to go for many years.

Then there was the Android problem – CSS gradients being supported, but not -webkit-background-clip: text, though support tests returned false positives. Since I didn’t have a smartphone and I don’t think I even knew anyone who had one at the time, I couldn’t test if any fix found on the internet would work and I don’t recall anyone ever complaining, so I confess I never even bothered with trying to cover this case.

Then things started to get even funnier.

In 2011, WebKit browsers started supporting a newer, different gradient syntax. Still with a prefix, still different from the standard one. Most notably, angles were going in the opposite direction and the gradient start was offset by 90° relative to the current version, meaning the  gradient start was taken to be at 3 o’clock, rather than at 12 o’clock like in the case of the current standard one.

This really caught on. Prefixes were meant to solve a problem, but a lot of developers skipped reading the instructions on the box, so they wrote code with either too many prefixed versions of the same properties or, more commonly, not enough… usually just the WebKit one. Which meant other browsers started considering supporting the -webkit- prefix too.

There was a lot written about it at the time, but basically what this meant was that Opera first implemented -webkit- prefixes in 2012 and then switched away from Presto altogether a year later, at around the same time Blink was announced. Another year later, IE added the -webkit- prefix too. This would carry over to Edge and then Edge would also move to Blink. And then in 20152016, Firefox also implemented some non-standard -webkit- prefixed properties and mapped some other -webkit- prefixed properties to their -moz- or standard equivalents.

What this meant was that for a while, we had support for -webkit- prefixed gradients in non-WebKit browsers… but not support for -webkit-text-fill-color-webkit-background-clip: text, so the following code:

color: #07f;
background: -webkit-linear-gradient(left, #0ff, #00f);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent

would for example produce a mid blue text on an aqua to blue gradient rectangle in Opera in-between starting to support the -webkit- prefix and switching to Blink. Not very readable, right?

The text 'Loading...' in an in-between blue on top of a left to right, aqua to blue gradient. The contrast between the text and the background is poor. It could at most pass AA for large text (above 18pt or bold above 14pt) and AA for user interface components and graphical objects on the left end, but would fail WCAG 2.0 and 2.1 everywhere else.
bad contrast

So the solution for that was to add another fully transparent -o- prefixed gradient after the -webkit- one:

color: #07f;
background: -webkit-linear-gradient(left, #0ff, #00f);
background: -o-linear-gradient(transparent, transparent);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent

Quite the mess indeed.

Since 2016, all browsers except Opera Mini have supported clipping backgrounds to text. Given the lack of support for clipping backgrounds to text was why we avoided setting color to transparent, what’s the point of still using -webkit-text-fill-color now?

You might say support. Indeed, there are people stuck on old browsers without the option to update because they are stuck on old operating systems which they cannot upgrade because of the age of hardware. I live in Romania and not only do I know people still using Windows XP on computers from the ’90s, I have seen that some public institutions still use Windows XP too. Newer browser versions don’t work there… but even so, the last browser versions that support Windows XP all support clipping backgrounds to text. And with automatic updates, all I’ve seen have been warnings about being unable to update to a newer version, not browsers stuck even further back.

Even if they were that far back, the ChatGPT solution sets color to transparent alongside -webkit-text-fill-color. The whole point of using -webkit-text-fill-color before clipping backgrounds to text became cross-browser was to avoid setting color to transparent, which ChatGPT isn’t doing because it’s dumping that declaration in there too. So it’s not improving support, it’s just adding redundant code. Or breaking the solution for a problem that had become obsolete over half a decade before ChatGPT was launched. Whichever you prefer.

In any case, the ChatGPT code is what we call “struţocămilă” in Romanian – an impossible animal that’s half ostrich, half camel.

A strange hybrid animal with the body of an ostrich and the head and neck of a camel.
even when it came to creating this wonder, AI couldn’t do a better job than a human in Photoshop

Not to mention today we have something far better for handling such situations: @supports!

I get the extending support argument for not setting background-clip: text in the shorthand or even unprefixed. Though now supported unprefixed and in the shorthand in the current versions of all major desktop and mobile browsers, support doesn’t extend to the last browser versions on Windows XP, 7 or 8.

That being said, if manually including both the prefixed and standard versions of a property, please always include the unprefixed one last. There are very rare situations where having the prefixed version of a property override the standard one might make sense for getting around some weird bug, but that’s definitely not the case here.

On background-size

Another thing you may have noticed is the ChatGPT solution sets background-size separately, not in the shorthand and that it uses two values, not just one, the second one being auto.

There is a historical (although also completely pointless today) reason behind this too. 15 years ago, when we first got this pure CSS gradient text technique, most browsers didn’t yet support setting background-size in the shorthand. So it was set separately. But that has not been an issue for over a decade.

Another issue with background-size was that initially, WebKit browsers implemented an earlier draft of the spec where a missing second value was taken to be equal to the first value, rather than auto, which in the case of gradients means the height of the box specified by background-origin (by default the padding-box). However, in the case of a horizontal gradient like the one we have here, the second background-size value is completely irrelevant, regardless of the background-position. Whether it’s 200% (duplicating the first value) or auto (100% of the padding-box height in this case), the visual result is always exactly the same for horizontal gradients.

So there is no reason why setting it in the shorthand with a single value wouldn’t produce the exact same result. And that has been the case for over a decade.

Finessing things

Similar to how we can omit the second background-size value, we can also omit it for background-position. The default is 50%, but any other value produces the exact same result in the case of any gradient covering the entire height of the background-origin box. And in the case of a horizontal gradient like we have here, it wouldn’t matter even if we had a different background-size height.

We can also easily omit one of the two end keyframes and set its background-position in the shorthand. Then the missing end keyframe gets generated out of there. This is not a new feature, I’ve been using this for over a decade.

Then I’m not a fan of those body styles. Firstly, without zeroing its default margin, setting its height to 100vh creates a scrollbar. Secondly, it can also be problematic even when setting margin: 0. Just don’t do it and do this instead, it has better support than dvh:

html, body { display: grid }

html { height: 100% }

body { background: #222 }

.loading-text { place-self: center }

Finally, default fonts might be ugly, so let’s go for a prettier one. We could also make it scale with the viewport within reasonable limits.

font: 900 clamp(2em, 10vw, 10em) exo, sans-serif

Here’s a CodePen demo:

This is not just ChatGPT

ChatGPT is in the title because it was what got used in this case. But this “dump in old popular solutions and sprinkle in some modern CSS to create a grotesque hybrid” is not unique to ChatGPT.

I saw Gemini spit out this monstruosity (that doesn’t even produce the desired result) just a day earlier:

screenshot

It makes sense. Older solutions have had more time to become more popular and they’re often at the top in search results too. But that doesn’t necessarily mean they’re still the best choice today. At least when you’re looking at an article or a Stack Overflow answer, you can check the date. An AI solution might link to resources (and in one instance, I discovered it was my own 12-year old, obsolete StackOverflow answer that was being referenced), but if it doesn’t or if you don’t check the resources, then you can never know just how outdated a technique might be. Or how badly it got messed up on the way.

]]>
https://frontendmasters.com/blog/chatgpt-and-old-and-broken-code/feed/ 6 5808
CSS Bursts with Conic Gradients https://frontendmasters.com/blog/css-bursts-with-conic-gradients/ https://frontendmasters.com/blog/css-bursts-with-conic-gradients/#respond Thu, 03 Apr 2025 17:12:38 +0000 https://frontendmasters.com/blog/?p=5521 You can make stripes with CSS gradients pretty easily. It’s that classic thing where you don’t fade a color from one to another, you just switch colors by having two colors share the same “color stop”. I made this one time to explain that:

background: linear-gradient(
  to right, 
  red,
  red 50%,
  blue 50%,
  blue
);

To turn that into stripes, we can set the background-size smaller and let it repeat. But perhaps the more-correct tool is to use repeating-linear-gradient() which automatically handles, ya know, repeating the gradient.

background: repeating-linear-gradient(
  #d84315,
  #d84315 10px,
  #fbc02d 10px,
  #fbc02d 20px
);

I think it’s a bit lesser-known, but there is an updated syntax to gradients where you can list two color stop lengths instead of just one, so the above code actually can get a little simpler:

background: repeating-linear-gradient(
  #d84315 0 10px,
  #fbc02d 10px 20px
);

OK that took me a minute to get to the point lol.

It occurred to me that these hard-stops can work for conic-gradient() as well. I was literally trying to make a burst background and was pleasantly surprised when I tried this and it worked.

background-image: repeating-conic-gradient(
  black 0deg 10deg,
  #666 10deg 11deg
);

I was actually trying to set some text in the middle, so I wanted to start the burst away from the center. Easy enough with a radial gradient sitting on top of it.

While I was thinking about this, I happed to see the Robinhood homepage and it was pretty burstin’.

See how that burst has lines breaking it up. I bet you could figure out how to do that by laying on more radial gradients, or perhaps a repeating radial gradient with transparent color stops.

Ughgkgh fine I’ll do it.

But I’ll let you figure out how to animate that. I bet you could do it with @property on some custom properties that you sneak into those gradient definitions.

This also reminds me that I worked on some bursts one time that were a bit more randomized using SVG. Feel free to click to reset what’s going on below:

]]>
https://frontendmasters.com/blog/css-bursts-with-conic-gradients/feed/ 0 5521
Mesh Gradient Generator https://frontendmasters.com/blog/mesh-gradient-generator/ https://frontendmasters.com/blog/mesh-gradient-generator/#respond Thu, 20 Feb 2025 15:51:41 +0000 https://frontendmasters.com/blog/?p=5210 A nice tool for generating mesh gradients from Erik D. Kennedy. You might call it a bit of a trend, but as Erik pointed out in a recent newsletter, they can be quite versatile because it’s just a nice background look that doesn’t demand anything in particular from the brand.

Mesh gradients can work across a huge variety of brands.

  • Dark? Yes.
  • Light? Yes.
  • Grainy? Sure.
  • Pensive? Absolutely.
  • Delicate? Yup.
  • Weatherworn and rugged? Totally.
]]>
https://frontendmasters.com/blog/mesh-gradient-generator/feed/ 0 5210
Pure CSS Halftone Effect in 3 Declarations https://frontendmasters.com/blog/pure-css-halftone-effect-in-3-declarations/ https://frontendmasters.com/blog/pure-css-halftone-effect-in-3-declarations/#comments Tue, 03 Dec 2024 20:50:56 +0000 https://frontendmasters.com/blog/?p=4594 About half a decade ago, I got an idea about how to create a halftone effect with pure CSS. My original idea (which Michelle Barker wrote about a 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 a halftone effect is, a very basic pattern looks like this:

The simplest possible halftone pattern

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

The first declaration is a background and it consists of two layers. One is the pattern – the dots in our most basic case. The other is the map – 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 the space value for background-repeat.

Taken separately, the two layers look like this:

the pattern and the map

Before we move any further, let’s take a closer look at these gradients. Each of the two layers goes from black, which can also be written as rgb(0%, 0%, 0%) or hsl(0, 0%, 0%) to white, which can also be written as rgb(100%, 100%, 100%) or hsl(0, 0%, 100%).

Dead in the middle we have grey, which is rgb(50%, 50%, 50%) or hsl(0, 0%, 50%). This is the 50% lightness grey or, in short, as we’ll be calling it from now on, the 50% grey.

Note that in the case of any grey, wherever it may be situated in between black and white, the saturation (the ‘S’ in HSL) is always 0%, while the hue (the ‘H’ in HSL) is irrelevant, so we just use 0. The only value that changes is the lightness (the ‘L’ in HSL), which goes from 0% for black to 100% for white.

Basically, going from 0% to 100% along the gradient line means going from 0% to 100% along the lightness axis of the HSL bicone.

HSL bicone slice showing the lightness axis (live demo)

So in general, any p% grey can be written as rgb(p%, p%, p%) or hsl(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 our background with the pattern dots layer on top of the linear-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 two background layers using the multiply blend mode.

This means the second declaration is:

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.

blending two layers at a pixel level

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.

R = R₀·R₁
G = G₀·G₁
B = B₀·B₁

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 have rgb(50%, 50%, 50%), then the result of the multiply blend mode is .25 = .5·.5 for each channel.

We can see that the result of the multiply blend mode is always at least as dark as 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 to black.

our two gradient layers, blended

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 the 50% grey limit for each.

darker dot vs. lighter dot

For the darker dot, the 50% grey limit is a bigger circle than in the case of the lighter dot. Inside each dark red circle, we have greys darker than a 50% one. Outside, we have greys lighter than a 50% one. Keep this in mind for later.

The third and final declaration is a filter using a large contrast() value.

For those not familiar with how contrast() 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 of 1 means no change, while a value of 0 means the channel has been pushed all the way to .5.

This means that contrast(0) always gives us a 50% grey, regardless of the filter input.

You can see this in the interactive demo below – regardless of whether we apply our filter on a plain solid background box, opaque or semitransparent, a gradient or an image one, dragging the contrast down to 0 always turns it into a 50% grey with the same alpha as the input.

Note that contrast(100%) is the same as contrast(1)contrast(50%) is the same as contrast(.5) and so on.

If the argument of the contrast() function is greater than 1 however, then each channel value gets pushed towards either 0 or 1, whichever is closer. A contrast large enough can push the channel values all the way to 0 or 1.

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.

8 possible RGB values where all channels are either zeroed or maxed out

Coming back to our halftone pattern, we use:

filter: contrast(16)

Here, all greys darker than a 50% one (grey or rgb(50%, 50%, 50%) or hsl(0, 0%, 50%)) get pushed to black and all the others to white.

Now remember how the 50% 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 a 50% one, so they get pushed to black by large contrast vales. Outside it, the greys are lighter than a 50% one, so they get pushed to white by large contrast values.

Since the darker the dot, the bigger the 50% 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:

the result so far

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 a 50% grey (the RGB channel values are below 50% or .5 in decimal representation of the percentage), blending any other layer with it using the multiply 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 a 50% one, so that large value contrast pushes everything in the left half to black.

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 a 50% 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 (the background-size for the pattern layer), then we also need to increase the contrast value accordingly.

Let’s say we increase the background-size from 1em to 9em.

bigger dots, blurry edges

The dot edges are now blurry, so we also increase the contrast value from 16 to let’s say 80.

increased contrast, jagged edges

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)
tiny blur + contrast smoothing fix

An even better fix would involve using a custom SVG filter, 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 of 2em and compute the offsets for the second layer of dots to be half of that.

real halftone dots pattern

Also, since we’ve increased the size of the dots, we’ve also bumped up the contrast value from 16 to 24.

Another option would be to use a repeating-radial-gradient().

--pattern: repeating-radial-gradient(circle, #777, #fff, #777 1em)
halftone ripples

Something like this can even be animated or made interactive. We can place these halftone ripples at var(--x) var(--y) and change these custom properties on mousemove.

We don’t have to limit ourselves to radial gradients. Linear ones work just as well. We can use a repeating-linear-gradient(), for example:

--pattern: repeating-linear-gradient(#777, #fff, #777 1em)
thinning lines

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 the background-size of a linear-gradient():

--pattern: linear-gradient(45deg, #fff, #777) 0 / 1em 1em
triangles

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
fragments

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 any filter adjustment however, the edges of these rays look bad, and so does the middle.

rays, but with ugly edges

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

smooth ray edges, but faded pattern edges

… but the pattern’s edges are now faded! We have two possible fixes here.

The first would be to remove the filter from the element itself and apply it on another element stacked on top of it as a backdrop-filter.

The second would be to make the element extend outwards a bit using a negative margin and then clip its edges by the same amount using inset().

Things get a lot more fun if we limit the background-size of such a conic-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.

Our linear-gradient() map doesn’t necessarily need to go along the x axis – 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 a radial-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 halftone background 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’s not enough. In order for our dots to always be perfectly round, we need an SVG filter solution. 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.

using a random image as it is for the map doesn’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.

The filter() function could help here, but, sadly, for almost 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 contrast filter on the pseudo-elements’ parent. This way, the map pseudo could have a filter 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 the background-color any grey and blending it with the map image using the luminosity blend mode gives us a result that has the luminosity of the map image on top, the saturation of the background-color below and, since this is a grey (its saturation is 0%), the hue becomes irrelevant.

Note that luminosity is not the same as lightness (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.

using a fully desaturated map obtained via blending

But it’s still not what we want, as this desaturated map is too dark, just like the first black to white map gradient we tried.

We can brighten our map using the screen blend mode. Think of this blend mode as being the same as multiply, 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 use screen 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!

using a fully desaturared and brightened map via blending (live demo)

Again, some of the dots aren’t fully round, but in order to get fully round dots, we’d need an SVG filter 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 chain invert(1) to our filter.

Or… we can do something else! We can use the screen blend mode we’ve used before to brighten the image map. As mentioned, this works like multiply, 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)
inverted halftone dots pattern (live demo)

But we’re not limited to just black and white.

Remember the part about how contrast works? Large contrast values push all pixels of the filter input to one of 8 possible RGB values. So far, our filter 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 CSS blending technique I detailed in a talk on the topic I used to give in 2020 or by using SVG filters. 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 to 10% of the element, we can get the effect below without needing to use a mask 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’s background-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 to bug 1481498, which has everything to do with the text on the right side and nothing to do with the halftone part):

card with halftone effect (live demo)

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 a repeating-radial-gradient() pattern and a conic-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 a conic-gradient() checkerboard mask.

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.

excavated cube with animated halftone (live demo)

Finally, even more demos showcasing halftone patterns can be found in this CodePen collection:

the CodePen collection

]]>
https://frontendmasters.com/blog/pure-css-halftone-effect-in-3-declarations/feed/ 8 4594
Mesh Gradients https://frontendmasters.com/blog/mesh-gradients/ https://frontendmasters.com/blog/mesh-gradients/#respond Tue, 09 Jul 2024 13:11:02 +0000 https://frontendmasters.com/blog/?p=2938 CSS has linear, radial, and conic gradients, that can all do interesting and complex things… but not quite this, at least not on their own:

Example from Josh Tumath who is interested in getting a better syntax for them in CSS.

That’s what we’ve been calling a Mesh Gradient. There are all sorts of ways to pull it off, like using multiple backgrounds with radial gradients placed at different locations, or placing shapes and using the blur() filter.

Here’s a gallery of them, the thumbnails of which have nice hover effects, and many of which have slow classy animations.

]]>
https://frontendmasters.com/blog/mesh-gradients/feed/ 0 2938