Skip to content
Allin

Gradients, Banding, and Why the Middle Looks Muddy

Published 8/21/2025 · 17 min read · Developer tools

Daniel Okonkwo

Daniel OkonkwoFront-end developer and tech writer at Allin

Web performance · File formats

Checked against 6 sources

View profile
In short

A CSS gradient interpolates the numbers stored in the colours, and those numbers are gamma-encoded. sRGB spends its 8 bits where the eye is sensitive rather than in proportion to light, so averaging two encoded values does not give the colour with the average amount of light. Take red #ff0000 to green #00ff00. The plain sRGB midpoint is #808000, with a WCAG relative luminance of 0.1986. Linearise both endpoints, average them there, and re-encode, and you get #bcbc00 with a luminance of 0.4639 — exactly the mean of the two endpoint luminances, as half-way light should be. The default midpoint is 57.2% short of it, and that deficit is the muddy dark band people see in the centre of every two-colour gradient. A second, unrelated problem is banding. Eight bits per channel give 256 values, so a ramp between two nearby dark colours may contain only 28 distinct colours; stretched across 1920 pixels that is a mean band width of 68.6 pixels, visible from across the room. Dithering hides it by trading spatial noise for tonal resolution. CSS now lets you fix the first problem directly with `in oklab` or `in oklch`, and the hue direction keyword decides which way round the wheel the colour travels.

Interpolating in sRGB averages gamma-encoded numbers, so the midpoint of red to green is #808000 when the half-light answer is #bcbc00 — 57.2% too little light. Banding is a separate arithmetic problem: 8 bits give 256 steps, and a dark gradient may have only 28 of them. Here is both, computed.

The average is taken in the wrong units

A linear-gradient in CSS does the obvious thing: for each pixel it computes a position t between 0 and 1 and returns the endpoint colours blended by t. The subtlety is what it blends. A hex code is not a measurement of light; it is a code word, and sRGB deliberately assigns those code words nonlinearly so that the 256 available values are spent where human vision can tell them apart. The stored value is roughly the light raised to the power 1/2.2, not the light itself.

The sRGB transfer function is written out in the standard and is easy to implement. To decode, divide the 8-bit value by 255 to get c, then return c ÷ 12.92 if c is at most 0.04045 and ((c + 0.055) ÷ 1.055) to the power 2.4 otherwise. To encode, invert it: multiply by 12.92 below 0.0031308, otherwise 1.055 times the value to the power 1/2.4, minus 0.055. Those two functions are all you need to move between code words and light.

Run one number through and the size of the problem is already visible. Half the light — linear 0.5 — encodes to 0.735357, which is 8-bit 188, or #bcbcbc. The colour most people call fifty percent grey, #808080, decodes to 0.215861. It is not half of anything. It is the code word that sits half-way along the code, which is a completely different thing from half-way along the light.

Red to green, measured both ways

Take #ff0000 to #00ff00 and compute the midpoint twice. The naive way averages the stored values: (255 + 0) ÷ 2 = 127.5 for red, the same for green, 0 for blue. That is #808000, and pushing it through the WCAG luminance pipeline gives 0.1986. The correct way decodes first: red becomes linear (1, 0, 0) and green (0, 1, 0), the average is (0.5, 0.5, 0), and re-encoding gives 187.5 in both channels — #bcbc00, with a luminance of 0.4639.

The second number is verifiable independently: the relative luminance of #ff0000 is 0.2126 and of #00ff00 is 0.7152, and their mean is 0.4639 — exactly what the linear midpoint measures. That is the definition of half-way in light, and the linear method reproduces it to the last digit. The sRGB midpoint delivers 0.1986 instead, which is 57.2% short. Well over half the light that should be at the centre of the gradient is missing.

The same shortfall appears wherever the two endpoints are far apart in code space. Black to white: sRGB midpoint #808080 at luminance 0.2140, linear midpoint #bcbcbc at 0.5000, the identical 57.2% deficit. White to blue: #8080ff at 0.2708 against #bcbcff at 0.5361, a 49.5% deficit. It is not a quirk of particular colours; it is the shape of the transfer function, and it applies to every default gradient on the web.

Blue to yellow goes grey — a different fault with a different fix

It is tempting to blame the grey centre of a blue-to-yellow gradient on the gamma problem too, and that turns out to be wrong. Compute both midpoints for #0000ff to #ffff00. The sRGB midpoint is #808080 with luminance 0.2140. The linear-light midpoint is #bcbcbc with luminance 0.5000. The linear version is much lighter, exactly as the previous section predicts — and it is still perfectly neutral grey. Measured in OKLCH, both have a chroma of 0.000. Working in linear light fixed the darkness and did nothing at all about the greyness.

The reason is that blue and yellow are close to opposite in the RGB cube, so averaging their channels cancels the colour out regardless of which units you average in. Any rectangular interpolation — sRGB, linear sRGB, even OKLab, which is rectangular despite being perceptual — walks a straight line through the middle of the colour space, and the middle of the colour space is neutral. Only a polar interpolation, which carries chroma and rotates hue rather than averaging opposing channels, goes round the outside.

A cleaner demonstration uses a pair that stays inside the sRGB gamut: #007bff to #ff6b00, a blue and an orange whose OKLCH hues are 257.2° and 45.1°. In plain sRGB the midpoint is #807380, a muddy grey-mauve with an OKLCH chroma of 0.024 — the colour has effectively died. In linear sRGB it is #bc73bc, chroma 0.131. In OKLab it is #a285a5, chroma 0.058. In OKLCH with the shorter hue arc it is #d454c9, chroma 0.208: a vivid magenta, and the chroma is now roughly the average of the endpoints' rather than a fraction of it. Same two stops, five different centres.

Banding: 256 values stretched over 2560 pixels

Banding has nothing to do with interpolation space. It is a counting problem. An 8-bit channel has 256 possible values, so a gradient can only ever contain as many distinct colours as its endpoints span. If a ramp is drawn across more pixels than it has distinct values, some values must repeat, and each repeated run is a flat stripe.

Rasterising the ramps pixel by pixel and counting gives the real figures. A full black-to-white gradient contains 256 distinct colours no matter how wide it is drawn. Across 320 pixels the mean band is 1.3 pixels and the longest flat run 2 pixels, which nobody will see. Across 1920 pixels the mean is 7.5 pixels and the longest run 8. Across 2560 pixels it is 10.0 and 11 — that is a visible stripe, on the widest possible tonal range.

Real gradients are far worse, because designers rarely span the full range. A fashionable dark hero from #1a1a2e to #16213e contains just 28 distinct colours. Over 1920 pixels that is a mean band of 68.6 pixels, with the longest flat run reaching 120 pixels. Over 2560 pixels the mean is 91.4 pixels. A subtle white-on-white surface from #ffffff to #f5f5f5 contains 11 colours: at 1920 pixels the mean band is 174.5 pixels wide. These are not artefacts a viewer might notice; they are stripes wider than a button.

A usable rule falls out of this. If you take 3 pixels as the width at which a flat band starts reading as an edge rather than as smooth tone, banding becomes visible once the drawn width exceeds three times the number of distinct levels. A full 256-level ramp survives to 768 pixels. A 52-level ramp survives to 156. The 28-level hero survives to 84 pixels, and the 11-level surface to 33. Almost every decorative gradient on a modern page is drawn far past its limit.

Dithering, and what more bits would buy

Dithering is the standard fix and it works by trading one kind of error for another. Instead of rounding every pixel to the nearest available value, you add a small amount of noise before rounding, so that pixels near a boundary scatter between the two neighbouring values in a ratio proportional to how close they are. The average over any small area is then correct even though no individual pixel is, and the eye integrates the area rather than the pixel. The visible stripe is replaced by a faint grain.

On the web you rarely control the rasteriser, so dithering is usually applied by hand: overlay a tiled, very low-contrast noise texture on top of the gradient, at an opacity of one or two percent, or generate the gradient as an SVG with a feTurbulence layer. Either approach breaks the flat runs. The trick is keeping the noise below the threshold where it reads as texture — if you can see the grain, you have overshot and traded a stripe for a dirty surface.

More bits would remove the need entirely. At 10 bits per channel there are 1024 levels, and the same full-range ramp across 1920 pixels gives a mean band of 1.88 pixels — below the visible threshold, so no dithering required. Wide-gamut displays and HDR pipelines increasingly deliver that depth, and CSS colours specified in oklch() or with the color() function can express it. The constraint is the whole chain: a 10-bit value composited into an 8-bit buffer is an 8-bit value again.

The CSS keywords: in oklch, in lab, and the hue direction

CSS Color 4 lets you name the interpolation space directly inside the gradient function: linear-gradient(in oklch, #007bff, #ff6b00). The accepted spaces include srgb, srgb-linear, lab, oklab, lch, oklch, hsl, hwb and xyz. The rectangular ones — srgb, srgb-linear, lab, oklab, xyz — interpolate coordinates componentwise. The polar ones — hsl, hwb, lch, oklch — interpolate a hue angle, and that is where the surprises live.

For a polar space you may append a hue interpolation method: shorter, longer, increasing or decreasing, each followed by the word hue. Shorter takes the arc of at most 180°, which is the default. Longer takes the complementary arc, going the long way round. Increasing forces the angle to rise, decreasing forces it to fall. Work an example: interpolating hue 20 to hue 320, shorter gives an arc of −60° and the path 20, 5, 350, 335, 320, while longer gives an arc of +300° and the path 20, 95, 170, 245, 320 — through green and blue, an entirely different gradient from the same two stops.

There is a second thing to watch in polar spaces. OKLCH can describe colours outside the sRGB gamut, and the polar path bulges outward: the OKLCH midpoint of blue #0000ff and yellow #ffff00 computes to coordinates that map to an out-of-range RGB triple and has to be gamut-mapped before it can be shown. Browsers do this for you, but the result will carry less chroma than the arithmetic asked for, so a gradient that looks brilliantly saturated in a colour picker can arrive noticeably tamer on screen. The in-gamut pair used above avoids the problem entirely, which is the practical lesson: check the middle, not just the ends.

Fading to transparent, and premultiplied alpha

The last classic gradient bug is the grey haze at the end of a fade to transparent. It happens when the interpolation treats colour and alpha as four independent numbers. Fade white to `transparent`, and since the CSS-wide keyword `transparent` means rgb(0 0 0 / 0) — transparent black — the colour channel is being dragged from white towards black at the same time as the alpha falls. At the midpoint the colour is 50% grey and the alpha is 0.5, so over a white page the pixel renders as #bfbfbf: a visible dirty band where there should be a clean fade.

Premultiplied alpha removes the artefact. Before interpolating, multiply each colour channel by its own alpha; interpolate the premultiplied channels and the alpha together; then divide back out at the end. A fully transparent stop contributes nothing to the colour, because its premultiplied channels are all zero, so it cannot drag the visible colour anywhere. Running the same white-to-transparent-black fade premultiplied gives #ffffff at the midpoint over a white background instead of #bfbfbf, and #808080 over black instead of #404040.

CSS gradients are specified to interpolate in premultiplied space, so modern browsers get this right. The manual fix, still worth knowing because it survives in canvas code, SVG, image editors and older engines, is to name the transparent stop explicitly in the colour you are fading from: use rgb(255 255 255 / 0) rather than the keyword `transparent`. Compute both midpoints and the equivalence is exact — fading white to transparent white gives #ffffff over white and #808080 over black in either method, premultiplied or not.

OKLCH chroma
The midpoint of #007bff to #ff6b00 in five interpolation spaces, computed
Interpolation spaceCSSMidpointOKLCH chromaResult
sRGB (default)in srgb#8073800.024Dark and almost colourless
Linear-light sRGBin srgb-linear#bc73bc0.131Correct brightness, muted hue
OKLab (rectangular)in oklab#a285a50.058Even lightness, still washed out
OKLCH, shorter arcin oklch shorter hue#d454c90.208Vivid magenta, chroma preserved
OKLCH, longer arcin oklch longer hue#00b1450.208Green, out of gamut and clipped
Gradient GeneratorBuild a linear or radial CSS gradient from two colours and an angle, with CSS and Tailwind output.Try the tool

Frequently asked questions

Why does my two-colour gradient have a dark band in the middle?
Because the browser averaged gamma-encoded numbers instead of light. sRGB stores a value close to the light raised to the power 1/2.2, so the arithmetic mean of two stored values corresponds to much less light than the mean of the two lights. Red #ff0000 to green #00ff00 makes it concrete: the plain sRGB midpoint is #808000 with a relative luminance of 0.1986, while the true half-light midpoint is #bcbc00 at 0.4639 — the mean of the endpoints' luminances 0.2126 and 0.7152, exactly. The default is 57.2% short of the light it should carry, and that shortfall is the dark band. The one-line fix in CSS is to name a better interpolation space: linear-gradient(in oklab, red, lime) or linear-gradient(in srgb-linear, red, lime). Both raise the centre. If you also want the middle to stay colourful rather than turning neutral, you need a polar space — in oklch — because that is a separate problem, described in the next answer.
Why does blue to yellow pass through grey even in a better colour space?
Because the greyness and the darkness are different faults. Blue and yellow sit close to opposite each other, so averaging their channels cancels the colour out no matter which units you average in. The numbers confirm it: the sRGB midpoint of #0000ff and #ffff00 is #808080 with a luminance of 0.2140, and the linear-light midpoint is #bcbcbc with a luminance of 0.5000. The linear version is far lighter, exactly as expected, and both have an OKLCH chroma of 0.000 — perfectly neutral. Even OKLab, despite being a perceptual space, gives #6cabc7 with a chroma of only 0.077, because OKLab is rectangular and a straight line between two opposite colours passes through the neutral axis. Only a polar interpolation avoids it, because it rotates hue and carries chroma instead of averaging opposing channels. Use linear-gradient(in oklch, blue, yellow). Be aware that for this particular pair the polar midpoint falls outside the sRGB gamut and will be gamut-mapped, so you will get less chroma than the arithmetic promised. If you want a reliably vivid centre, add an explicit middle stop in the hue you actually want.
How wide can a gradient be before it starts banding?
It depends entirely on how far apart the endpoints are, not on the size of the element. Count the distinct 8-bit colours the ramp can contain, then multiply by about three: taking 3 pixels as the width at which a flat band starts reading as an edge, that product is the width at which banding appears. A full black-to-white ramp holds 256 colours and survives to roughly 768 pixels. A ramp from #0f172a to a nearby slate holds 52 colours and survives to 156. A fashionable dark hero from #1a1a2e to #16213e holds only 28 colours and starts banding past 84 pixels — which means it bands in every layout it will ever appear in. A near-white surface from #ffffff to #f5f5f5 holds 11 colours and gives up at 33 pixels; drawn across 1920, its mean band is 174.5 pixels wide. Three practical moves. Widen the tonal distance between the stops so there are more levels to work with, add a faint noise overlay at one or two percent opacity to dither the boundaries, or run the gradient diagonally rather than straight across, which lengthens the ramp relative to the element and puts the band edges out of alignment with the layout.
What does `longer hue` actually do, and when would I want it?
It forces the hue to travel the long way round the circle instead of taking the short arc. In a polar interpolation the browser has two candidate paths between two hue angles, and by default it picks the one of at most 180°. Interpolating hue 20 to hue 320, `shorter hue` gives an arc of −60° and passes through 5, 350 and 335 — a short run through reds. `longer hue` gives an arc of +300° and passes through 95, 170 and 245 — greens, cyans and blues, effectively the entire spectrum. Same two stops, completely different gradient. The keyword is worth reaching for when you want a rainbow sweep from two endpoints without writing intermediate stops, which is common for progress indicators, data-visualisation ramps and decorative borders. There are two more keywords: `increasing hue` forces the angle to rise and `decreasing hue` forces it to fall, which is how you control the direction rather than just the length. Watch the edge case where the two hues are exactly 180° apart: shorter and longer then describe the same arc length and you must use increasing or decreasing to say which way you mean.
Why does my fade to transparent look dirty at the edge?
Almost always because the transparent stop is transparent black. The CSS-wide keyword `transparent` expands to rgb(0 0 0 / 0), so a gradient written from white `to transparent` is dragging the colour channel from white towards black while the alpha falls. If colour and alpha are interpolated independently, the midpoint is 50% grey at alpha 0.5, which composites over a white page as #bfbfbf — a visible grey haze. Interpolating in premultiplied space fixes it: multiply each colour by its alpha before blending, blend, then divide back out, and a fully transparent stop contributes nothing to the colour because its premultiplied channels are zero. The same midpoint then composites to #ffffff over white and #808080 over black. CSS gradients are specified to interpolate premultiplied, so current browsers are correct here; the bug survives in canvas code, SVG, image editors and older engines. The portable fix is to write the stop in the colour you are fading from — rgb(255 255 255 / 0) rather than `transparent` — which produces identical results under either method and costs nothing.

Articles you may find interesting

All guides
ExplainerColour Schemes Are Geometry on a Wheel — and the Wheel Is WrongComplementary, triadic, analogous and split-complementary are just rotations: add 180°, 120°, 30° or 150° to a hue. The arithmetic is trivial. The problem is that the HSL hue circle is not perceptually uniform — yellow and blue at the same HSL lightness differ in luminance by 12.85 times — so a generated palette has to be contrast-checked afterwards.ExplainerContrast Ratio: How WCAG Actually Computes ItThe WCAG ratio is (L1 + 0.05) ÷ (L2 + 0.05), and L is relative luminance, not brightness. Green carries 71.52% of it and blue 7.22%, which is why pure blue on white passes at 8.59:1 while mid grey fails at 3.95:1. Here is the whole computation, run end to end.How-toHEX vs RGB: How to Read and Convert ColoursHEX and RGB are two notations for the same colours. Here's how to read each, convert between them, and add transparency.GuideWhat a Minifier Can Remove, and What It Must NotMinification has to preserve meaning, and the interesting part is the whitespace that carries meaning: the descendant combinator, the spaces inside calc(), the gap between two inline elements. Measured here on real files, including how much brotli was going to save you anyway.ExplainerHow Instagram Fonts Actually Work (They Are Not Fonts)Nothing is styled. Each letter is swapped for a different Unicode character that happens to look bold or cursive — which is why a screen reader reads the result as gibberish and some devices show empty boxes.ExplainerYAML Looks Friendly and BitesYAML is JSON plus a type-inference layer, and the inference is the dangerous part. The same file run through a YAML 1.2 parser and a YAML 1.1 parser: no is a string in one and false in the other, 01234 is 1234 in one and 668 in the other, and 12:30:00 is a number in one of them.

Related tools

Sources

Spotted a mistake in this article?