Skip to main content
css

Debugging CSS gradients: muddy midpoints, banding, and rotated angles

Why your gradient renders gray in the middle, why adding color stops never fixes banding, and the legacy angle convention that silently rotates a gradient 90 degrees.

Thien Nguyen
By Thien Nguyen
Updated August 2, 2026 · 8 min read

Here is a hero gradient that reads fine in the stylesheet and looks broken in the browser:

.hero {
  background: linear-gradient(to right, #0038ff, #ffd400);
}

Deep blue to gold. What renders is deep blue, then a wide dead zone of flat gray, then gold. Nobody typed gray. The browser produced it, and it did so correctly.

The midpoint is arithmetic, not magic

By default, a gradient between two legacy sRGB colors — hex, named colors, rgb(), hsl(), hwb() — interpolates channel-by-channel in gamma-encoded sRGB. That means you can compute exactly what the browser will paint at the halfway mark by averaging the channels yourself:

StepValue
Declared start#0038ff = rgb(0 56 255)
Declared end#ffd400 = rgb(255 212 0)
Red at 50%(0 + 255) / 2 = 128
Green at 50%(56 + 212) / 2 = 134
Blue at 50%(255 + 0) / 2 = 128
What the browser paintsrgb(128 134 128) = #808680

#808680 is a flat, faintly green gray. The takeaway: whenever your two endpoints sit on roughly opposite sides of the color wheel, their channels cancel, and straight-line RGB interpolation walks the gradient through the middle of the cube rather than around its colorful outside. Red to blue does the same thing more subtly — it dips through a dark, chalky #800080 instead of the vivid violet you pictured.

This is not a bug in your CSS, and no amount of nudging the two hex values will fix it. The path between them is the problem.

Name the interpolation space

CSS Images Level 4 lets you tell the browser which color space to walk through, using a <color-interpolation-method> — the in <space> token that goes inside the gradient function, before the first comma:

.hero {
  background: linear-gradient(to right in oklch, #0038ff, #ffd400);
}

OKLCH is a polar, perceptually-uniform space: interpolating in it moves around the hue wheel instead of straight through the middle of the color cube, and carries lightness and chroma across separately. Those two endpoints sit at hue 264 and hue 94, so the halfway point is hue 179 — a saturated cyan-teal, with chroma still up around 0.23 instead of collapsing to zero. Same two colors, completely different middle.

The grammar is a single space-separated clause — direction and space, then one comma, then the stops. linear-gradient(to left, in oklch, red, blue) is invalid; the extra comma makes in oklch parse as a color stop and the whole declaration is dropped. Both orderings of the clause are legal, so pick one and be consistent:

/* no direction: the space can stand alone */
background: linear-gradient(in oklch, #0038ff, #ffd400);

/* with a direction: space-separated, one comma */
background: linear-gradient(to right in oklch, #0038ff, #ffd400);

For polar spaces you can also steer which way around the wheel it travels — in oklch longer hue takes the long arc and sweeps through the entire spectrum, increasing hue and decreasing hue lock the direction so it cannot flip mid-animation. shorter hue is the default. Per MDN this is Baseline "newly available" as of 2024, meaning every current engine has it but browsers a couple of years old do not; a gradient with an unrecognized in <space> clause is invalid and falls back to whatever background you declared before it, so put a plain two-stop version above it if that matters to you.

One terminology trap, because the two things sound identical and behave nothing alike: in oklch is a color interpolation method. A color interpolation hint is the bare percentage you can drop between two stops — linear-gradient(red, 20%, blue) — which does not add a color, it moves where the halfway blend lands and bends the transition into an exponential curve. Searching for "interpolation hint" when you meant the color space will send you to entirely the wrong docs.

If you want to eyeball stops and spaces against each other rather than reason about them, our CSS gradient generator will render the variants side by side. And if your actual question is "how do I write a good gradient from scratch" rather than "why does this one look wrong", the companion piece on generating gradients visually and shipping the simple version covers that ground.

Banding is a bit-depth problem, not a color-space problem

This is the one that wastes afternoons, because the symptom looks related and the fix is not.

.panel {
  /* 1400px wide, and you can count the stripes */
  background: linear-gradient(to bottom, #1a1a2e, #16213e);
}

Nothing here is muddy. The problem is visible stair-steps. Compositing happens at 8 bits per channel — 256 discrete values — and this gradient's widest channel movement is blue, from 46 to 62. Sixteen steps. Spread across 1400 pixels, that is one flat band every 87 pixels, which is not a subtle artifact, it is a set of stripes.

The useful heuristic is a division:

band width in px  ≈  gradient length in px  ÷  largest single-channel delta

Below about 3px per band you stop noticing. Above it, on a dark or low-contrast gradient where nothing else distracts the eye, you will see every one.

in oklch fixes muddy. Dithering fixes banding. They are unrelated failures with unrelated fixes, and reaching for the wrong one is why your first attempt did nothing. Switching interpolation space changes which colors get quantized — it does not add bits to quantize them into.

For the same reason, adding color stops does nothing. Ten stops between two nearly-identical navy blues still resolve to the same sixteen representable values; you have described the same path in more words. The actual fix is to break up the flat regions with noise, which lets dithering hide the boundaries:

.panel {
  background:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Cfilter id='n'%3E%3CfeTurbulence baseFrequency='0.8'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E"),
    linear-gradient(to bottom, #1a1a2e, #16213e);
}

Three or four percent opacity is enough. Failing that, widen the endpoints so the channel delta is larger, or make the gradient physically shorter.

Common mistakes

Assuming 0deg points right

It points up. Gradient angles start at to top and increase clockwise: 0deg is up, 90deg is right, 180deg is down, 270deg is left. So to right and 90deg genuinely are the same thing — that pair is not the trap people think it is.

The trap is older code. The original prefixed -webkit-linear-gradient() used the opposite convention: 0deg meant to right and angles increased counterclockwise. Copy a gradient off an old answer, strip the vendor prefix because you do not need it anymore, and the unprefixed version renders rotated 90 degrees from the screenshot you were copying. The declaration is valid, so nothing warns you.

Treating to bottom right as 135deg

They match only when the box is square. Corner keywords are defined geometrically, not as fixed angles: the browser picks a gradient line such that the two corners adjacent to the named corner end up exactly the color of the nearest endpoint — the "magic corners" behavior. On a 1600×400 banner, to bottom right resolves to something much flatter than 45 degrees off horizontal, and it stays visually correct as the box resizes. Hard-code 135deg and the corners drift as soon as the aspect ratio changes. Pick keywords when you want the corners to hold, angles when you want the sweep direction to hold.

Writing stops that go backwards

background: linear-gradient(to right, red 0%, yellow 60%, green 40%, blue 100%);

You might expect this to error, or to sort itself out. It does neither. Per the spec fixup rule, a stop positioned before any stop preceding it in the list is silently reset to that largest preceding position — so green 40% becomes green 60%, and yellow and green now occupy the same point. The result is a hard edge in the middle of what should be a smooth blend. Whenever a gradient has one unexplained crisp line in it, read your stop percentages in order; one of them is out of sequence, usually after a hand-edit.

Expecting repeating-linear-gradient to loop smoothly

A repeating gradient tiles the stop list end to end, which means the last stop's color sits directly against the first stop's color at every tile boundary. If those two colors differ, you get a seam at every repeat:

/* seam every 40px: #333 abuts #ccc */
background: repeating-linear-gradient(45deg, #ccc 0 20px, #333 20px 40px);

Sometimes that hard edge is the point — it is how you draw stripes. When you wanted a smooth repeating wave, the fix is to make the first and last stops the same color and put the contrast in the middle, so the tile is symmetrical and the boundary disappears.

Fixing it in order

When a gradient looks wrong, the symptom tells you which of these it is. Gray or chalky through the middle is an interpolation-space problem: add in oklch. Visible stripes are quantization: dither it or widen the endpoints. Right colors pointing the wrong way is an angle convention, almost always inherited from prefixed code. One unexplained hard line is a stop out of sequence.

None of those require guessing, and only the first is really about color theory. For anything with more than two stops or a non-linear layout, the gradient mesh generator is a faster way to find the shape than iterating percentages by hand.

Cover photo by Evie Shaffer on Pexels.

References

Primary documentation and specifications checked when this article was last updated.

csscolordebugging

Related articles

All articles