CSS Gradients, Revisited
The first draft of this post said that linear-gradient(red, blue) goes through a muddy brown in the middle. It doesn’t. The midpoint is rgb(128 0 128), a dark purple. I’d repeated something I’d read instead of checking, which is a bad habit in a post about getting colour right.
The underlying point still holds, and it’s the most useful thing in here: how a gradient mixes colours is a choice now, and the default isn’t always the right one.
Three shapes
Quick grounding first. linear-gradient blends along a line, radial-gradient out from a point, and conic-gradient around a point, like a colour wheel or a pie chart. Each one takes its geometry from the pointer here, through two CSS variables the component sets.
Where the colours travel
A gradient between two colours is a path through a colour space, and the default space depends on how you wrote the stops. If every stop is a legacy colour (hex, rgb(), hsl() or a named colour), it’s sRGB, and the path is a straight line through the RGB cube. Blue #0000ff and yellow #ffff00 sit on opposite corners of that cube, so the line runs straight through the grey in the middle: rgb(128 128 128). If any stop is written as oklch(), oklab() or color(), the default switches to oklab.
Since mid-2024 (Chrome and Safari had it earlier, Firefox arrived last), every major browser lets you pick the space:
background: linear-gradient(to right in oklab, blue, yellow);
background: linear-gradient(to right in oklch, blue, yellow);The two “ok” spaces solve different problems:
- oklab is built so that equal steps look like equal changes. The path is still a straight line, but the brightness in the middle sits where your eye expects it. Whether it passes near grey depends on whether the two hues are opposite in Oklab, not in RGB. Blue and yellow are 154° apart there, so the blend misses grey and lands on a muted steel blue. Green and magenta are 186° apart and do pass close to grey, just a lighter one than sRGB gives.
- oklch is the same space in polar form: lightness, chroma, hue. Interpolating there walks around the hue wheel and keeps the saturation up. Blue to yellow stays vivid, but it gets there through cyan and green. Most of those in-between colours are outside what an sRGB screen can show, so the browser clips them.
Clipping isn’t graceful. A clipped colour shifts in hue and lightness, and where the path comes back inside the gamut you get a hard seam: the sharp edge in the green on the blue → yellow oklch row below. The fixes are to lower the endpoints’ chroma until the whole path fits, or to add a mid stop you picked yourself inside sRGB. A mid stop leaves a soft corner of its own, but that’s the smaller problem.
oklch also has to choose a direction round the hue wheel. By default it takes the shorter arc. longer hue takes the other one, and increasing hue or decreasing hue fix the direction outright:
background: linear-gradient(to right in oklch longer hue, blue, yellow);
background: linear-gradient(to right in oklch decreasing hue, blue, yellow);Green and magenta are 186° apart, which puts them on the knife edge: the short way is only 12° shorter than the long way, and nudging either colour a few degrees flips which one counts as shorter. For pairs near 180°, I name the direction.
Each row below uses the same two endpoints with no extra stops. Only the interpolation space changes, and the oklch row has a switch for the hue direction. The small square at the end of each row is the exact midpoint, computed with color-mix() in the same space:
.oklch {
background: linear-gradient(to right in oklch, var(--from), var(--to));
}
.oklchMid {
background: color-mix(in oklch, var(--from), var(--to));
}in srgbStraight line through the RGB cube. The midpoint is flat grey, rgb(128 128 128).
in oklabStraight line in a perceptual space. Blue and yellow are 154° apart in Oklab, not opposite, so the line misses grey: the midpoint is a muted steel blue.
in oklchHue takes the shorter arc (264° → 110°) through cyan and green. Most of that path is outside sRGB and gets clipped; the hard edge in the green is where the clipping stops.
This browser doesn't support in <colorspace> in gradients, so all three rows are drawn with the default sRGB interpolation.
How I choose now: oklab when the two colours are related and I want the blend to stay between them, oklch when I want saturation and I’m happy with the detour. The midpoint swatch is how I check. If it surprises me, I add a stop.
There’s one case where that check misses: oklch with white, black or a grey as a stop. Those have no hue (the spec calls it powerless), so the hue should come from the other stop. color-mix() does that. Chrome’s gradients don’t: in oklch, #fff → #f00 renders rgb(241 182 89) at the middle, an orange detour, while color-mix() gives rgb(255 161 145), a pink. I’ve only measured this in Chrome. For a fade to a neutral I use oklab, where there’s no hue to get wrong.
One gotcha with fallbacks. If the gradient comes through a CSS variable and the browser doesn’t support the in syntax, the whole declaration becomes invalid when it’s computed, and the element gets no background at all. It doesn’t fall back to sRGB on its own. The demo uses @supports to redeclare the plain version for those browsers.
Animating a gradient
You can’t transition one gradient into another: the browser treats gradient images as not interpolable, so it swaps them in one step. There are two ways round it. The first is to animate the values the gradient reads instead of the gradient. Register a custom property with @property and it gets a type, so the browser can transition it, and the gradient redraws at every step:
@property --from {
syntax: "<color>";
inherits: false;
initial-value: #6366f1;
}
.card {
background: linear-gradient(135deg, var(--from), #ef4444);
transition: --from 300ms;
}
.card:hover { --from: #22c55e; }The swatches in the first demo use this. --pointer-x and --pointer-y are registered as numbers, so when the pointer leaves or you press an arrow key they ease to the new position instead of jumping. While the pointer is over a swatch there’s no transition, so it tracks one to one.
The second way is to animate where the gradient sits. Make the tile twice as wide as the box, let it repeat, and slide it by exactly one tile:
.animatedGradient {
background-image: linear-gradient(
90deg, #ff6b6b, #feca57, #48dbfb, #ff9ff3, #ff6b6b
);
background-size: 200% 100%;
animation: gradientSlide 12s linear infinite;
}
@keyframes gradientSlide { to { background-position: 200% 0; } }The gradient starts and ends on the same colour, and a position of 200% on a 200%-wide tile is exactly one tile, so the last frame matches the first and the loop never jumps. Earlier versions ran back and forth, which always looks like it’s breathing rather than flowing.
The cost: moving a background repaints the element every frame. It isn’t a compositor animation. On a card this size that’s nothing. On a full-screen hero, I’d check the frame rate on a cheap laptop first.
A rotating border with @property
The same registration turns a conic gradient into a spinning border. It’s one element: a transparent 2px border, and two background layers. The card colour is clipped to the padding box, and the conic gradient fills the border box, so it only shows through the border. The card colour is written as a one-colour gradient because background-color can’t take its own clip box.
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.borderCard {
border: 2px solid transparent;
border-radius: 14px;
background:
linear-gradient(rgb(var(--background-raised)), rgb(var(--background-raised))) padding-box,
conic-gradient(from var(--angle), #ff6b6b, #feca57, #48dbfb, #ff9ff3, #ff6b6b) border-box;
animation: spinAngle 4s linear infinite;
}
@keyframes spinAngle { to { --angle: 360deg; } }@property has worked in every major browser since mid-2024. Without it, --angle has no type and no starting value. For the first half of each loop var(--angle) is empty, so the whole background is invalid and the card loses its border and its fill. Halfway through, it flips to 360deg and sits still.
conic-gradient(from var(--angle), …) border-box--angle turns 0° → 360° every 4s.Held at 135° because your system asks for reduced motion.This keeps the real border-radius. border-image takes a gradient directly, but it ignores border-radius, so the corners come out square. If the card needs to be see-through inside, there’s no opaque layer to cover the middle of the gradient. Then the gradient goes on a pseudo-element, and mask-composite: exclude cuts the middle out.
It also repaints every frame. The alternative, a rotating ::before behind the card, uses transform and stays on the compositor, but the pseudo-element has to be an oversized square so its corners still cover the card as it turns, and the card needs overflow: hidden to clip it. For one small card I take the simpler CSS.
Layers
background takes a list, and most interesting gradient effects are several simple ones stacked. The first layer listed paints on top. A mesh gradient is six soft radial spots over a dark linear base, which comes last so it sits at the bottom:
background:
radial-gradient(at 40% 20%, rgb(255 107 107 / 0.8) 0px, transparent 50%),
radial-gradient(at 80% 0%, rgb(72 219 251 / 0.8) 0px, transparent 50%),
/* …four more… */
linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);The other tiles use the same idea: a repeating-linear-gradient of translucent stripes over a colour sweep, an SVG noise texture over a gradient for grain, and a frosted panel. The frosted one taught me something obvious in hindsight: backdrop-filter blurs what’s behind the element, and the first version had nothing behind it. It was a white box on a white card. Now it sits over hard-edged shapes, so you can see the blur working.
Smaller things I check
If a gradient is built on a brand colour, derive the other stops from that one token instead of picking them, so changing the token moves the whole gradient. color-mix() does it, and so does relative colour syntax, which lets you edit one channel. Here it lightens and pulls chroma back, because a lighter colour at the same chroma can easily leave sRGB:
background: linear-gradient(
in oklab, var(--brand), color-mix(in oklab, var(--brand), white 30%)
);
background: linear-gradient(
in oklch, var(--brand), oklch(from var(--brand) calc(l + 0.15) calc(c * 0.6) h)
);Big, subtle gradients band: 8 bits per channel don’t have enough values across a dark, low-contrast range, and you see steps. A faint noise layer, like the grain tile above, dithers them away. Separately, a plain two-stop ramp starts and stops abruptly, which reads as a line at each end; a few extra stops following an easing curve soften that.
Text on a gradient has no single background colour, so there’s no single contrast number. Measure the text against the lightest and the darkest point that sits under it. Both have to pass.
What changed my mind
I used to think of gradients as decoration you get right by eye. Picking the colour space and checking the midpoint turned it into something I can reason about. It’s one keyword and a color-mix() to check it. I’m still not sure oklch is always the right default for UI. It can be too vivid for subtle surfaces. But I now choose the space deliberately instead of taking whatever default the stop syntax happens to imply.