Colour

Gradients that do not band

An 8-bit channel holds 256 values, a dark ramp spends them faster than a light one, and the fix is more contrast, a little noise, or a shorter ramp.

You built the gradient the way it is supposed to be built. Two stops, a long soft ramp, no edge anywhere in the values you typed. Then you put it on a large screen and the steps are there anyway. Nobody did anything wrong. The display ran out of numbers.

What banding actually is

Every channel of an 8-bit colour holds one of 256 values, and that is the whole vocabulary a display has for the red of a pixel. A gradient cannot draw anything between two neighbouring values. It picks one, holds it, and moves on only when the ramp has travelled far enough to justify the change. So a gradient is a staircase, and the only question is whether the treads are narrow enough for the eye to miss.

steps  = difference between the two end values   in 8-bit levels
tread  = length ÷ steps                           in pixels per level

a 1,400 px ramp from #0B0B0F to #1A1A22
  start   0x0B = 11
  end     0x1A = 26
  steps   15
  tread   1400 ÷ 15 = 93 px

the same ramp over a 60-level range
  tread   1400 ÷ 60 = 23 px

Ninety-three pixels of one flat value, a jump, then ninety-three more. That is a stripe you could measure with a ruler. Twenty-three is better and still visible, because a wide flat area beside a slightly different one gives the eye room to place the boundary. Below about three pixels of tread the ramp reads as smooth; past roughly fifteen on a low-contrast ramp, most people see the steps unprompted. Those are my working numbers, not a standard's.

Why the dark end bands worst

Here is the fact that explains nearly every bad gradient I have looked at. The 256 values in a channel are not spread evenly across what the eye can see, they are spread evenly across the encoded signal, and the encoding stretches the bottom of the range on purpose. Take one step near black and one near white and convert each to the light it represents: for a channel value v, that is ((v ÷ 255 + 0.055) ÷ 1.055) raised to the power of 2.4.

level  11    light 0.0033
level  12    light 0.0037      one step, about a tenth of the value

level 240    light 0.871
level 241    light 0.881       one step, about a hundredth of the value

One step in the file is a change of roughly ten percent at the bottom of the range and roughly one percent at the top. The eye is far more sensitive to proportional change than to absolute change, which is why the steps show at the bottom.

Put that beside two real pairs. #0B0B0F to #1A1A22 is about fifteen levels across a full-screen hero, and every step in it is a ten-percent jump in light. #F0F0F0 to #FFFFFF is also about fifteen levels and bands almost not at all, because every step is a one-percent jump. Same count, opposite outcome. A ramp in the bottom third of the range should be assumed to band.

Spend the levels more evenly

The panel will not give you a 257th value, so the first thing to try is a better distribution of the ones you have. A default CSS gradient interpolates between its stops in sRGB, walking a straight line through the encoded values. That is reasonable, because the encoding is close to perceptually even. Close, not exact, and the gap widens as the ramp gets darker.

CSS can interpolate in a different space, named inside the gradient itself: linear-gradient(in oklab, #0B0B0F, #1A1A22). Oklab's lightness axis is built so that equal distances along it are roughly equal distances in perceived lightness, so the ramp spends its steps at a constant rate of perceived change instead of a constant rate of encoded change. It is not a new set of numbers, it is the same 256 placed better. Support is broad enough for progressive use: in oklab and in oklch ship in current Chrome, Firefox and Safari. Gate it, base rule first, so an older engine keeps the plain ramp:

.hero {
  background: linear-gradient(#0B0B0F, #1A1A22);
}

@supports (background: linear-gradient(in oklab, red, blue)) {
  .hero { background: linear-gradient(in oklab, #0B0B0F, #1A1A22); }
}

Add a whisper of noise

If you cannot change how the ramp is interpolated, because the tool does not offer it or the gradient is baked into a raster, the fix that always works is dither: a small amount of noise over the ramp.

The mechanism explains why so little of it does so much. A band is visible because a wide flat area sits beside another wide flat area and the eye can place the edge between them precisely. Perturb each pixel by a fraction of one level, in a pattern the eye reads as grain rather than structure, and the edge stops being an edge: the noise averages away and the transition looks gradual again. A quarter of a level is enough on most panels, and this is what a film grain overlay is doing.

On an image the cost is real. A smooth gradient compresses to almost nothing, because the encoder can describe a ramp in a handful of bytes, and noise is close to the definition of incompressible. The file has to spell out pixel by pixel what it used to predict. On a CSS gradient the cost is nothing measurable: a tiled noise texture at low opacity, or a turbulence filter, costs a few hundred bytes and fixes every panel at once.

Shorten the ramp or steepen it

Tread is length divided by steps, so there are two ways to narrow it. Halve the height of the hero and the tread halves with it. Widen the range between the ends and the steps get finer: shifting the 1,400 pixel hero from a fifteen-level range to a sixty-level range takes the tread from ninety-three pixels to twenty-three. Most banded gradients I have been handed are banded because of the range, not the length. The ends were chosen close together, because that is what makes a soft, low-contrast, expensive-looking ramp, and the closeness is what starves the interpolation.

Do not ramp between two near-identical darks

This is where the soft aesthetic goes wrong hardest. #0B0B0F and #14141A differ by nine levels, and nine levels across a full-width card gives a tread of well over a hundred pixels. There is no interpolation to do at that distance. Those two colours are not a range, they are one colour with a rounding error, so the fix is not to dither harder. It is to change one end: a card ramping from #0B0B0F to #2A2A36 has well over twice the levels to work with and reads as a gradient rather than a fault. If the ends have to stay close, accept that the surface is flat and get the depth from a border or a shadow instead.

When it is the file, not the screen

Not everything that looks like banding is banding. If the gradient was saved as a JPEG, the contour you are looking at may be in the file and not on the panel. JPEG works on 8 by 8 pixel blocks, and a smooth ramp is close to a worst case for it: most of a gradient's energy sits in the lowest frequency term, and the quantiser discards the high-frequency coefficients that carry fine detail. Where the ramp's slope is shallowest, that discarded detail is the difference between smooth and stepped, so the reconstruction puts a visible edge back on the block boundaries, aligned to the grid.

A second effect appears when the ramp changes hue as well as lightness: JPEG stores colour at half the resolution of brightness, so a hue ramp degrades at twice the rate of a neutral one. A grey ramp at quality 85 is clean while a blue-to-orange ramp at the same setting shows contours through the middle.

Telling the two apart takes one test. Banding from the file is blocky and aligned to a grid, and it is in the pixels: resizing the window or opening the image on another display changes nothing. Banding from the display is clean, full width, parallel to the gradient axis, and it changes when you move the window to another screen. If a resize changes what you see, the display is producing it. If it does not, the file is carrying it.

What you are looking at, and what to do

The table below is the diagnostic I run through, in the order the evidence usually arrives.

What you seeWhat is happeningThe fix that works
Even bands across the full width, parallel to the gradient axisThe ramp spends more than about fifteen pixels on each 8-bit levelSteepen the ramp, shorten it, or lay noise over it
Bands worst at the dark end, fading toward the lightOne level is a ten-percent light change near black, one percent near whiteInterpolate in oklab, or move the dark end away from black
Bands eight pixels apart, blocky and griddedJPEG block quantisation: the contour is in the file, not the panelRe-export as PNG, or raise the quality and check again
Contours only where the ramp turns through a hueChroma subsampling: colour is stored at half the resolution of brightnessExport at full chroma resolution, or stay inside one hue
No bands in the file, bands on one display onlyTwo panels differing in bit depth, dithering, or driver behaviourDither the ramp so it holds on the worst panel you care about

The middle column is the thing to fix and the right column is the cheapest way to fix it. Only the last row is about the hardware; the rest are decisions you can still change.

Where the tools fit. The Gradient Generator is where the ramp gets built and measured: set the two ends and the length, and read the tread off the result before the gradient goes near a stylesheet. The Colour Studio is where the two ends get checked, for how far apart they are in levels and how far apart they are in perceived lightness. A pair that looks like a wide range in a swatch is often fifteen levels, and fifteen levels across a hero is the whole problem.

The rule of thumb

Any ramp longer than about a screenful, sitting in the darker half of the range, will band. Treat that as a fact about the medium: if a design needs a long dark gradient, it needs either more contrast between its ends or a light dusting of noise.

The check I run on my own work takes a minute. Screenshot the gradient, open it in an editor and sample the pixel values along the ramp. If consecutive samples hold the same value for more than twenty pixels, the eye will find the edges. If they hold for under three, leave it alone. And when someone hands you a banded gradient to fix, ask which pair of colours is in it first. The answer is usually two darks about ten levels apart, and the fix is not a filter. It is a wider range.