Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close