Grainy Gradients
Grainy Gradients êŽë š
You know when you set a background gradient or a gradient mask and you get an uglybanding effect? If you canât picture what I mean, hereâs an example:

example gradient with banding
Previous Solutions
Over time, Iâve seen a couple of approaches commonly recommended for solving this. The first is tosimply 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 tomake 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 thisin response to a questionasked 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 sawan articlethat 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 makea second videoon the topic, presenting a much simplified technique. Basically, the gradient would get fed into an SVGfilter, 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.
CodePen Embed Fallback https://codepen.io/thebabydino/pen/qEdbEQZ Noise layering problem
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 adisplacement 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 thexandyaxes.
Both thefilterinput (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 below50%means moving in the positive direction of the axis, a channel value above50%means moving in the negative direction of the axis and a channel value of exactly50%means not moving at all.
The displacement formula for a generic channel value ofCand a displacement scale of S is the following:
(.5 - C)*S
If we use the red channelRfor displacement along thexaxis and the alpha channe A for displacement along theyaxis, then we have:
dx = (.5 - R)*S
dy = (.5 - A)*S
Note that the values for bothRandAare in the[0, 1]interval (meaning channel values are zeroed at 0 and maxed out at1), so the difference between the parenthesis is in the[-.5, .5]interval.
The bigger thescalevalueSis, the more the gradient pixels mix along the gradient axis depending on the redRand 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 ourfilter(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 usingaria-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 itsid. 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 tosRGBwhenever weâre doing anything with the RGB channels in thefilter. ThesRGBvalue isnât the default one (linearRGBis), but itâs the one we likely want most of the time and the only one that works properly cross-browser.
ThefeTurbulenceprimitive 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 thebaseFrequencyvalues (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 thatnumOctavesvalues above the default 1 allow us to get a better-looking noise without having to layer the results of multiple feTurbulence primitives with differentbaseFrequencyvalues. In practice, I pretty much never use numOctaves values bigger than3or at most4as 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 thefilterinput (SourceGraphic). We use ascalevalue of150, which means that the maximum an input pixel can be displaced by in either direction of thexoryaxis 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 alphaA, so we donât need to set it explicitly, we only set it for thexaxis displacement.
Weâre using absolute pixel displacement here, as relative displacement (which requires the primitiveUnitsattribute to be set toobjectBoundingBox on the <filter> element) is not explicitly defined in the spec, so Chrome, Firefox and Safari eachimplement it in a different way (w3c/fxtf-drafts)from the other two for non-squarefilterinputs. 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:

Not quite what we want. The dashed bright pink line shows us where the boundary of the filterinput gradient box was. Along the edges, we have both transparent pixels insidethe initial gradient boxandopaque pixelsoutsidethe initial gradient box. Two different problems, each needing to get fixed in a different way.
To cover up the transparent pixelsinsidethe initial gradient box, we layer the initial gradient underneath the one scrambled by feDisplacementMap. We do this using feBlend with the defaultmodeofnormal(so we donât need to set it explicitly), which meands 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 usingfeCompositewith the default operator ofover orfeMergeto 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 pixelsoutsidethe initial gradient box, we restrict the filterregion to its exact input box â starting from the0,0point of this input and covering100%of it along both thexandyaxis (by default, thefilterregion starts from -10%,-10% and covers120%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 useclip-path: inset(0) on the element we apply this grainyfilteron. This is one situation where itâs convenient that clip-pathgets appliedafterfilter(the order in the CSS doesnât matter here).
.grad-box {
background: linear-gradient(90deg, #a9613a, #1e1816);
clip-path: inset(0);
filter: url(#grain)
}

the desired result
A problem with this solution
The inconvenient part about thisfilteris that it applies to the entire element, not just its gradientbackground. And maybe we want this element to also have text content and a box-shadow. Consider the case when before applying thefilterwe set abox-shadowand add text content:

the case when we also have a shadow and text
In this case, applying thefilterto the entire element causes all kinds of problems. The text âdissolvesâ into the gradient, the blackbox-shadowoutside 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 theclip-pathfix for the gradient pixels displacedoutsidethe initial gradient box, this would also cut away the outer shadow.

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âsbox-shadowand text from the gradient on the pseudo, so applying thefilteron the pseudo doesnât affect the parentâs box-shadowand 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 */
}
}

the desired result when having a shadow and text content (live demo (thebabydino))
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 (thezaxis 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 filteronly on the gradient background layer using thefilter()function.
This is not the same as thefilterproperty! Itâs afunctionthat 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-image,border-image,mask-image⊠evenshape-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 thefilterregion from the<filter>element attributes, otherwise we run intoa really weird bugin the one browser supporting this, Safari.

the Safari problem when we donât restrict thefilterregion
Because, while Safari has supported the filter() function since 2015, for about a decade, sadlyno other browser has followed (web-platform-tests/interop). There are bugs open for bothChromeandFirefoxin case anyone wants to show interest in them implementing this.
Here is thelive demo (thebabydino), 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 (likebox-shadow) that remain unaffected by the noise filter, but especially for masking. Banding is always a problem when using radial-gradient() for amaskand, while we can layer multiple (pseudo)elements instead ofbackgroundlayers and/ or borders, masking is a trickier problem.
For example, consider a conic spotlight. That is, aconic-gradient()masked by a radial one. In this case, it would really help us to be able to apply a grain filter directly to themaskgradient.
.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 grainfilteris 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 filterregion 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 thelive 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 maskon the .conic-spotlight element and, since, just like clip-path, mask gets applied after filter, we add a wrapper around this element to set thefilteron it. Alternatively, we could set the conic spotlightbackgroundand the radialmaskon a pseudo of our.conic-spotlightand set thefilteron 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 theconic-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)
}
CodePen Embed Fallback https://codepen.io/thebabydino/pen/xxMvLWy Spotlight in a circle: banding (1st) vs. filter graininess (2nd)
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 theradial-gradient() (which allows us to get rid of themaskbanding, 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 abackgroundlayer and apply the grainfilterjust on that pseudo, but itâs still more complicated than thefilter() 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

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 firstblurandoffsetthe 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 of1is equal to the square image edge length, both for the dxand dy attributes offeOffsetand for the scale attribute of feDisplacementMap.
In our case, thedxanddyoffsets being set to.1means we offset the blurred square image copy by10%of its edge length along each of the two axes. And the displacement scale being set to.2means any pixel of the blurred and offset copy may be displaced by at most half of that (half being10%of the square image edge), with plus or with minus, along both thexandyaxes. 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).
CodePen Embed Fallback https://codepen.io/thebabydino/pen/OJYwgpe Realistic grainy shadows with no image duplication
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>
CodePen Embed Fallback https://codepen.io/thebabydino/pen/MWPZNMw Grainy shadow
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.
CodePen Embed Fallback https://codepen.io/thebabydino/pen/LYgqPbQ Grainy edge fade
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 tospecify a focal point(viafxand fy), which allows us to create radial gradients not possible with pure CSS.
CodePen Embed Fallback https://codepen.io/thebabydino/pen/bGJvajr Noisy gradient discs
Thefilteris a bit more complex here because the aim was to create a specific type of noise, but the main idea is the same.
Animated singleimggradient glow border

live demo (
thebabydino)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 grainfilterdiscussed earlier fixes this problem.
CodePen Embed Fallback https://codepen.io/thebabydino/pen/abxpmMe Mesh gradient polygon: banding vs. grain
Also, since weâre usingclip-pathto get the polygon shape and this is applied after the filter, we donât need to worry about opaque pixels displacedoutsidethe polygon shape by our grainfilter. This means we donât need to bother with setting thefilter 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 grainfilter.

live demo (
thebabydino)