Lesson 23 / 26
Gradients
Linear and radial color gradients; the rule for resolving stop positions, calculating intermediate colors, and producing a line with a hard transition.
Contents
Background layers got built with images coming from a file. A layer can also get produced without a file: with calculated transitions between colors.
A gradient is not a color, it is an image. This distinction
has consequences: it cannot get written into background-color, it
gets written into background-image; it has no intrinsic size and
gets sized by the area it gets placed in; background-size,
background-position, and background-repeat apply to it too.
Color Stops
A gradient consists of color stops. Each stop carries a color and an optional position. When positions do not get written, they get filled in by a rule: the first stop lands at 0%, the last at 100%, and the ones in between get distributed at equal intervals.
Every point between two stops is a linear mix of the two colors.
// gradient.mjs — resolving color stops and calculating intermediate colors const hexToRgb = (h) => ({ r: parseInt(h.slice(1,3),16), g: parseInt(h.slice(3,5),16), b: parseInt(h.slice(5,7),16) }); const hex = ({r,g,b}) => "#" + [r,g,b].map(v => Math.round(v).toString(16).padStart(2,"0")).join(""); // stops with no position get distributed evenly between neighboring known points function resolveStops(stops) { const resolved = stops.map((s) => ({ ...s })); if (resolved[0].position == null) resolved[0].position = 0; if (resolved.at(-1).position == null) resolved.at(-1).position = 100; let i = 0; while (i < resolved.length) { if (resolved[i].position != null) { i++; continue; } let j = i; while (resolved[j].position == null) j++; const start = resolved[i - 1].position, end = resolved[j].position; const step = (end - start) / (j - i + 1); for (let t = i; t < j; t++) resolved[t].position = +(start + step * (t - i + 1)).toFixed(2); i = j; } // stop positions cannot decrease: raise anything that falls below the previous value for (let t = 1; t < resolved.length; t++) if (resolved[t].position < resolved[t-1].position) resolved[t].position = resolved[t-1].position; return resolved; } function midColor(stops, percent) { const resolved = resolveStops(stops); if (percent <= resolved[0].position) return resolved[0].color; if (percent >= resolved.at(-1).position) return resolved.at(-1).color; for (let i = 1; i < resolved.length; i++) { if (percent <= resolved[i].position) { const a = resolved[i-1], b = resolved[i]; const t = b.position === a.position ? 0 : (percent - a.position) / (b.position - a.position); const ca = hexToRgb(a.color), cb = hexToRgb(b.color); return hex({ r: ca.r + (cb.r-ca.r)*t, g: ca.g + (cb.g-ca.g)*t, b: ca.b + (cb.b-ca.b)*t }); } } } const A = [{ color: "#143a52" }, { color: "#8a9da9" }, { color: "#ffffff" }]; const B = [{ color: "#143a52", position: 0 }, { color: "#8a9da9", position: 20 }, { color: "#ffffff", position: 100 }]; const C = [{ color: "#143a52", position: 0 }, { color: "#143a52", position: 50 }, { color: "#ffffff", position: 50 }, { color: "#ffffff", position: 100 }]; for (const [label, stops] of [["equal distribution", A], ["manually positioned", B], ["hard transition (line)", C]]) { console.log(`--- ${label} ---`); console.log("resolved stops:", resolveStops(stops).map((x) => `${x.color}@${x.position}%`).join(" ")); const samples = [0, 10, 20, 25, 49, 50, 51, 75, 100]; console.log(samples.map((p) => `${String(p).padStart(3)}%:${midColor(stops, p)}`).join(" ")); console.log(""); } console.log("--- angle direction (linear-gradient) ---"); for (const [angle, direction] of [[0,"bottom to top"],[90,"left to right"],[180,"top to bottom"],[270,"right to left"]]) { const rad = (angle - 90) * Math.PI / 180; console.log(`${String(angle).padStart(3)}deg -> ${direction.padEnd(18)} unit vector (${Math.cos(rad).toFixed(3)}, ${(-Math.sin(rad)).toFixed(3)})`); }
--- equal distribution --- resolved stops: #143a52@0% #8a9da9@50% #ffffff@100% 0%:#143a52 10%:#2c4e63 20%:#436275 25%:#4f6c7e 49%:#889ba7 50%:#8a9da9 51%:#8c9fab 75%:#c5ced4 100%:#ffffff --- manually positioned --- resolved stops: #143a52@0% #8a9da9@20% #ffffff@100% 0%:#143a52 10%:#4f6c7e 20%:#8a9da9 25%:#91a3ae 49%:#b4c1c8 50%:#b6c2c9 51%:#b7c3ca 75%:#dae0e4 100%:#ffffff --- hard transition (line) --- resolved stops: #143a52@0% #143a52@50% #ffffff@50% #ffffff@100% 0%:#143a52 10%:#143a52 20%:#143a52 25%:#143a52 49%:#143a52 50%:#143a52 51%:#ffffff 75%:#ffffff 100%:#ffffff --- angle direction (linear-gradient) --- 0deg -> bottom to top unit vector (0.000, 1.000) 90deg -> left to right unit vector (1.000, 0.000) 180deg -> top to bottom unit vector (0.000, -1.000) 270deg -> right to left unit vector (-1.000, -0.000)
The first block shows the equal distribution: three stops got
written, and the middle one landed at 50%. The color at the 10%
point, #2c4e63, is a one-fifth mix of the first two stops.
The second block uses the same three colors with different positions.
Once the middle stop gets pulled to 20%, the transition’s speed
changes: the color lightens quickly in the first fifth, then slows in
the remainder. At the 10% point, the color that was #2c4e63 in
the first block is #4f6c7e here — much lighter.
Producing a Line With a Hard Transition
The third block shows a pattern that looks like a trick but is a direct consequence of the definition. When two stops get written at the same position, no distance remains between them to mix over, and the gradient turns into an edge: still dark at 49%, fully white at 51%.
This is the way to produce lines, stripes, and checkerboard patterns with gradients:
.measurement-table { background-image: linear-gradient( to right, var(--line) 0 1px, transparent 1px 100% ); background-repeat: repeat-x; background-size: 120px 100%; }
Writing two positions on one stop (0 1px) declares that the color
stays constant across that range; it is a shorthand for writing two
stops.
Three Gradient Types
linear-gradient() — colors change along a line. Direction gets
given with an angle or a keyword. The output’s last block gives the
angle measurement: 0deg points upward, and the angle increases
clockwise. The writing to top is the same as 0deg, to right the
same as 90deg. Positive y in the vectors points upward.
radial-gradient() — colors change outward from a center. Shape
(circle or ellipse), size (like closest-side,
farthest-corner), and the center can also be given.
conic-gradient() — colors change by angle around a center. This
is the type that pie charts and color wheels can get written with.
All three have repeating- prefixed forms; they run the stop
sequence to its end and restart it from the beginning.
Banding and the Mixing Space
In a gradient between two close colors over a large area, color steps becoming visible is common. In eight-bit channels, intermediate values get rounded, and over a large area, neighboring regions that round to the same value look like a band.
Which color space the mix gets done in can get declared:
.surface { background-image: linear-gradient(in oklab, #143a52, #8a5b00); }
When the space does not get declared, the mix happens in the sRGB space. A mix done in a perceptually uniform space reduces the washing-out of the in-between tones between two colors — a visible difference especially in transitions between complementary colors.
Whether this writing is recognized can get tested, and if it is not, the declaration before it stays in place:
.surface { background-image: linear-gradient(#143a52, #8a5b00); } @supports (background-image: linear-gradient(in oklab, red, blue)) { .surface { background-image: linear-gradient(in oklab, #143a52, #8a5b00); } }
Gradients Are an Image
The distinction from the start of this lesson has practical consequences.
A gradient is a background-image layer; multiple gradients can get
stacked with a comma, and transparent regions show the layer below.
A gradient has no intrinsic size; when background-size does not get
given, it fills the area it gets placed in. When it does get given,
it can get tiled with background-repeat — the line example above
rests on this.
A gradient downloads no file. The same visual effect could also get achieved with an image file; a gradient produces it without an extra request, and rescales itself whenever the area changes.
/* station.css — step 22: gradients */ .masthead { background-image: linear-gradient(to bottom, hsl(var(--hue) 61% 96%), hsl(var(--hue) 18% 97%)); } .measurement-chart { background-image: linear-gradient(to top, var(--line) 0 1px, transparent 1px 100%); background-size: 100% 40px; } .loading-bar { background-image: linear-gradient( to right, var(--brand-dark) 0 var(--percent, 0%), var(--line) var(--percent, 0%) 100% ); }
The .measurement-chart class places a horizontal grid line every 40
units behind the measurement chart. The lines do not exist in the
document and should not: a grid line carries no information, it is a
visual aid that helps reading.
The .loading-bar class uses a custom property as the stop position.
When the --percent value changes, the bar’s filled portion changes;
the gradient itself does not get rewritten. If the value comes from
the document, the fill ratio also has to get declared accessibly —
with a progress element, say, or the appropriate ARIA attributes.
The visual bar alone does not correspond to a state.
Summary
- A gradient is not a color, it is a background image; it gets
written into
background-image, has no intrinsic size, and gets sized by the area it gets placed in. - Unwritten stop positions get filled in: the first stop at 0%, the last at 100%, and the ones in between get distributed at equal intervals between neighboring known points.
- Changing the stop positions changes the gradient’s speed; when two stops get written at the same position, no distance remains to mix over, and a sharp edge forms.
- Writing two positions on one stop declares that the color stays constant across the range; line and stripe patterns get produced with this writing, without downloading a file.
- The color space the mix happens in can get declared; when it does not get declared, sRGB gets used, and the in-between tones between distant colors can wash out.
Next Step
The boxes’ inside and back are complete. Their edges are missing: the border’s drawing style, rounding the corners, and shadows falling outside the box have not been taken up yet. The next lesson combines these three topics and shows how the corner radius gets calculated with the border thickness.
To keep your progress and take notes, Log in
My notes
Log in to take notes.