DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

I built a CSS gradient generator to finally understand gradients

For years I treated CSS gradients like a slot machine. Paste something off a gradient site, nudge a hex code, spin the angle until it looked right, move on. It worked, but I never actually understood what I was writing. So for Day 33 of my build-from-zero series I built my own gradient generator, and the point was never the tool. It was to force myself to know exactly what every character in that background string does.

Here's the reframing that made everything click: a gradient is not a colour, it's an image. That's why it lives in background-image (or the background shorthand), and why background-color: linear-gradient(...) silently does nothing. The value linear-gradient(...) is the same type of value as url('photo.png') — a CSS <image>. Nothing gets downloaded, nothing gets rasterised when you write it. You hand the browser a short recipe and it computes the pixels at render time, at whatever size and pixel density the element ends up being. That's why gradients stay razor sharp on a 4K screen while your exported PNG turns to mush.

Once you see gradients as generated pictures, the three types are just three ways of laying colour across the box.

The recipe has two parts

Every gradient is a direction plus an ordered list of colour stops. A stop is a colour with a position — #f59e0b 0%, #b45309 100%. Leave the positions off and the browser spreads them evenly. Between any two stops it interpolates smoothly. That ordered list of (colour, position) pairs is the gradient. The type and direction only decide how the 0%–100% line gets drawn.

  • Linear blends along one straight axis. linear-gradient(90deg, ...). Every line perpendicular to that axis is one flat colour.
  • Radial spreads outward from a centre, like a spotlight. radial-gradient(circle farthest-corner at 50% 50%, ...). Now 0% is the centre and 100% is an edge, so you get concentric rings.
  • Conic sweeps around a centre, like a clock hand painting as it turns. conic-gradient(from 0deg at 50% 50%, ...). The stop positions are angles, which is how you draw pie charts and colour wheels in pure CSS.

The angle thing that trips everyone up

CSS angles do not match school trigonometry. 0deg points straight up and the angle grows clockwise — so 90deg flows right, 180deg down, 270deg left. Maths puts 0 pointing right and goes counter-clockwise. This bites the moment you build a dial: a naive atan2(y, x) gives the wrong number.

The fix is to swap the arguments and flip the vertical sign (screen Y grows downward):

let deg = Math.atan2(dx, -dy) * 180 / Math.PI;  // dx,dy from dial centre
deg = (deg + 360) % 360;                         // 0..360, CSS convention
Enter fullscreen mode Exit fullscreen mode

That one line gives you degrees in the exact convention CSS wants, so the needle you draw and the gradient you emit finally agree.

Hard stops are a feature, not a bug

The trick I use most now: put two stops at the same position and there's zero distance to blend across, so you get a crisp line instead of a fade. red 50%, blue 50% is a clean two-colour split. Repeat it and you have stripes, bands, flags — with no image file:

linear-gradient(90deg, #f59e0b 0% 33%, #10b981 33% 66%, #3b82f6 66% 100%)
Enter fullscreen mode Exit fullscreen mode

The same technique in a conic-gradient gives you the sharp wedges of a pie chart. This is why my generator has to preserve stop order for equal positions — swap the two and the stripe's colours swap sides.

The whole engine is one function

The thing I keep relearning with these builds: the interesting part is tiny. I hold a state object (type, direction, and a stops array in insertion order so a knob I'm dragging never jumps index), and one pure function turns it into CSS:

function buildGradient(s) {
  const stops = sortStops(s.stops).map(x => `${x.color} ${x.pos}%`).join(", ");
  if (s.type === "radial")
    return `radial-gradient(${s.shape} ${s.size} at ${s.posX}% ${s.posY}%, ${stops})`;
  if (s.type === "conic")
    return `conic-gradient(from ${s.conicAngle}deg at ${s.conicX}% ${s.conicY}%, ${stops})`;
  return `linear-gradient(${s.angle}deg, ${stops})`;
}
Enter fullscreen mode Exit fullscreen mode

Note the sort happens only when I emit the string. I keep the array unsorted so dragging stays stable, then sort a copy — a stable sort, so equal positions keep insertion order and hard stops render correctly. Everything the UI does (drag a stop, spin the dial, hit randomize) just mutates state and re-renders. The browser does the actual painting.

The tool has a big live preview, a draggable stop track, an angle dial, presets and a randomize button, and it spits out the exact background value to copy. But the payoff was the understanding: gradients are cheap, resolution-independent, themeable recipes — reach for them for fades, glows, stripes, charts and UI sheen, and only reach for an image when you need real photographic detail.

Try it here: https://dev48v.infy.uk/solve/day33-gradient-generator.html

Next up, Day 34: a box-shadow generator.

Top comments (0)