How a CSS Gradient Actually Works
A CSS gradient is not a color. It is an value, generated by the browser's rasterizer at paint time. That single fact explains most of the rules you work around: gradients go in properties that accept images — background-image, border-image, mask-image, list-style-image — and are rejected by colour-only properties like color, background-color and border-color.
Because it is generated rather than downloaded, it costs no network request and stays sharp at any device pixel ratio; the price is a repaint whenever the box changes size.
Every gradient function has the same shape: a geometry argument saying where the gradient line runs, then color stops placed along it. Reach for background-image rather than the background shorthand, which also resets colour, position, size and repeat.
.card {
background-image: linear-gradient(180deg, #1e3a8a 0%, #150f2b 100%);
}
Three functions cover all the geometry, and each has a repeating- twin: linear-gradient, radial-gradient, conic-gradient.
Linear Gradients: Angles and Color Stops
linear-gradient() runs its color stops along a straight line, defined either by a to keyword or by an angle.
Angles are the part people get wrong. In CSS, 0deg points to the top and angles increase clockwise: 90deg right, 180deg bottom, 270deg left. That is neither the trigonometric convention nor the old -webkit- prefixed one, where 0deg pointed right and angles ran counter-clockwise.
/* Identical: keyword form and angle form */
background-image: linear-gradient(to right, #f97316, #db2777);
background-image: linear-gradient(90deg, #f97316, #db2777);
Omit the geometry argument and the default is to bottom.
A corner keyword like to bottom right is subtler than a 45-degree line: the browser picks the angle that puts the two *other* corners on the same color, so the gradient line runs perpendicular to the opposite diagonal. Only on a square box is that 135deg; on a box twice as wide as it is tall it is about 153deg.
Color stops take an optional position in any :
background-image: linear-gradient(90deg, #0ea5e9 0%, #6366f1 40%, #a855f7 100%);
Two extra pieces of syntax do a lot of work. A stop can carry two positions, which is shorthand for repeating it, and a bare percentage between two stops is an interpolation hint that moves the 50% mixing point.
/* Two hard bands, no blending */
background-image: linear-gradient(90deg, #ef4444 0 50%, #22c55e 50% 100%);
/* Hint: the midpoint colour sits at 25%, so the fade starts fast then eases */
background-image: linear-gradient(90deg, #000, 25%, #fff);
If you would rather drag stops than count percentages, the CSS Gradient Generator writes this syntax out as you move the handles.
Radial and Conic Gradients
radial-gradient() spreads stops outward from a point. Its geometry argument is [.
/* ellipse + farthest-corner are the defaults */
background-image: radial-gradient(#fde68a, #b45309);
/* circle, explicitly sized, positioned off-centre */
background-image: radial-gradient(circle 180px at 30% 20%, #fff, #0ea5e9);
/* Spotlight: fade to zero alpha, never to black */
background-image: radial-gradient(circle at 50% 0, rgb(255 255 255 / 0.35), rgb(255 255 255 / 0) 60%);
The four size keywords — closest-side, closest-corner, farthest-side, farthest-corner (the default) — decide where 100% falls. closest-side gives a clean circle that never clips.
conic-gradient() sweeps stops around a centre point. Its angle convention matches linear-gradient: from 0deg starts at the top and sweeps clockwise. Stops are angles or percentages of a full turn, and double-position stops give the hard edges a pie chart needs.
/* Progress ring: mask out the middle */
.ring {
background-image: conic-gradient(#22c55e calc(var(--pct) * 1%), #e5e7eb 0);
-webkit-mask: radial-gradient(circle, transparent 62%, #000 63%);
mask: radial-gradient(circle, transparent 62%, #000 63%);
aspect-ratio: 1;
border-radius: 50%;
}
Conic gradients shipped in Chrome 69, Safari 12.1 and Firefox 83, so no fallback is needed on any supported browser.
Picking the Right Function
| Function | Geometry arg | Natural use | Watch out for |
linear-gradient | angle or to | heroes, button fills, fade-to-transparent overlays | 0deg is up, not right |
radial-gradient | shape, size, at position | spotlights, glows, vignettes | ellipse default distorts non-square boxes; use circle |
conic-gradient | from angle, at position | pie charts, progress rings, colour wheels | a seam shows unless first and last stops match |
repeating-* | same as its base | stripes, hatching, rulers | needs an explicit final stop position |
Colour Interpolation: Why Gradients Go Muddy
By default the browser blends stops in sRGB, channel by channel. Blending complementary colours that way drags the midpoint through a desaturated grey — the classic "dead zone" in a blue-to-yellow gradient. Modern CSS lets you name the interpolation space instead:
/* sRGB: grey sag in the middle */
background-image: linear-gradient(90deg, #0000ff, #ffff00);
/* Oklab: even lightness, still a rectangular space */
background-image: linear-gradient(90deg in oklab, #0000ff, #ffff00);
/* Polar: pick which way round the hue wheel to travel */
background-image: linear-gradient(90deg in oklch longer hue, #0000ff, #ffff00);
Be precise about what each space buys. oklab is rectangular, so a blue-to-yellow blend still passes close to neutral; what it fixes is uneven lightness and the bunching sRGB produces mid-fade. To hold chroma up across a complementary pair you need a polar space: oklch interpolates hue as an angle, so longer hue travels the long way round the wheel through greens and magentas rather than cutting through grey.
These spaces reached all three engines by mid-2024 (Chrome 111, Safari 16.2, Firefox 128). Keep the plain declaration above as a fallback — an older browser drops the whole declaration if it cannot parse the space. Polar spaces (oklch, lch, hsl, hwb) accept a hue-interpolation keyword: shorter (default), longer, increasing, decreasing.
One myth worth retiring: fading to the transparent keyword no longer produces a grey haze. The spec requires premultiplied-alpha interpolation, so transparent means "this colour at zero alpha" in every current browser — though rgb(255 255 255 / 0) is still clearer to the next reader.
Converting hex values into oklch() to tune them is what Color Converter is for, and Color Palette Generator picks stops that already sit at even perceptual spacing.
Stripes and Patterns With Repeating Gradients
repeating-linear-gradient() restarts the stop list every time it reaches the last stop's position, so that last position controls the tile width. Leave it off and the stops default to 0% and 100%, covering the whole line with nothing left to repeat — an ordinary one-pass gradient.
/* 40px diagonal barber pole */
background-image: repeating-linear-gradient(45deg, #1f2937 0 20px, #374151 20px 40px);
/* Ruled paper */
background-image: repeating-linear-gradient(#fff 0 23px, #cbd5e1 23px 24px);
Every background-image layer carries its own background-size, background-position and background-repeat, comma-separated in the same order, and the first layer in the list paints on top.
A checkerboard needs no repeating function at all: one conic gradient of four hard quadrants, tiled by background-size, is enough — conic-gradient(#e5e7eb 25%, transparent 0 50%, #e5e7eb 0 75%, transparent 0) with background-size: 24px 24px.
Gradient Text, Borders and Overlays
Text: paint the gradient on the element, clip it to the glyphs, and make the text itself transparent.
.headline {
background-image: linear-gradient(90deg, #06b6d4, #8b5cf6);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
Borders: border-color rejects images, so use border-image or a two-layer background where the inner layer is clipped to the padding box.
.gradient-border {
border: 2px solid transparent;
border-radius: 12px;
background-image:
linear-gradient(#fff, #fff),
linear-gradient(120deg, #f43f5e, #8b5cf6);
background-origin: border-box;
background-clip: padding-box, border-box;
}
That two-layer trick survives border-radius; border-image does not follow rounded corners.
Readability overlays are the most practical use of all. A photo behind white text needs a scrim, and that scrim wants eased stops rather than a straight linear fade, which reads as a visible band:
.scrim {
background-image: linear-gradient(to top, rgb(0 0 0 / 0.75) 0%, rgb(0 0 0 / 0.45) 25%, rgb(0 0 0 / 0) 70%);
}
Check the result: text over the darkest part of a scrim still has to clear 4.5:1 for body copy, and Color Contrast Checker will tell you whether it does.
Animating Gradients Without Wrecking Performance
Browsers treat background-image as a discrete property, so gradient stops do not interpolate in a plain transition. Two approaches work around it.
The cheap one: make the gradient larger than the box and animate background-position, which only moves the already-rasterised image.
.animated {
background-image: linear-gradient(90deg, #0ea5e9, #8b5cf6, #0ea5e9);
background-size: 200% 100%;
animation: slide 6s linear infinite;
}
@keyframes slide {
to { background-position: -200% 0; }
}
The precise one: register a custom property with a type so the browser can interpolate it, then feed it into the gradient. This is how you rotate a conic gradient smoothly.
@property --angle {
syntax: "";
inherits: false;
initial-value: 0deg;
}
.spinner {
background-image: conic-gradient(from var(--angle), #6366f1, #ec4899, #6366f1);
animation: spin 3s linear infinite;
}
@keyframes spin {
to { --angle: 360deg; }
}
Gate both behind @media (prefers-reduced-motion: reduce), remembering that a media block wraps whole rules — .animated, .spinner { animation: none; } — not a bare declaration. If you are shipping a stylesheet full of long gradient declarations, run it through CSS Minifier — repeated rgb(... / ...) stops compress well.
One last defect worth naming: banding. Long, low-contrast gradients across a wide viewport quantise into visible stripes because 8-bit channels cannot express enough intermediate steps. Shorten the gradient, raise the contrast between stops, add an interpolation hint so the steps fall where the eye is least sensitive, or overlay a faint noise texture at 2-4% opacity to dither the edges away.