Better eased CSS gradients, or: not knowing when to stop

Skip to the end result

Why gradients

CSS gradients are often used to fade one flat colour area into another. In hero blocks, for example, we cover the background behind text with a flat colour to make the text more readable, then fade that colour smoothly to fully transparent. The trouble is with “smoothly”: the fade doesn’t really look the part:

Linear gradient

Both ends of the gradient show a jarring jump.

Photo: Jonne Mäkikyrö, Unsplash

Seeing things at the stops

Linear gradients look kind of bad: they seem to start and end abruptly, even overshooting a bit at both ends. This is a known “illusion”, or rather a quirk of our visual system: at a gradient’s stops, where the colour suddenly starts or stops changing, our eyes exaggerate the edge, and see Mach bands: a darker band where the ramp meets the dark side and a lighter one where it meets the light side.

Stop seeing things

As Andreas Larsen already showed, we can use eased gradients to get rid of the abrupt ends. This looks way better: no bands to be seen.

We can also start the fade a bit earlier, as it eases in gently:

Snowy forest photo under an eased gradient that fades in smoothly

Eased gradient

Both ends of the gradient blend in smoothly.

The existing tools

CSS does have a nice tool for creating eased curves: cubic-bezier(). Unfortunately, it isn’t supported in gradients (at least for now). Currently, the only usable way of getting smooth curves is to approximate them with a bunch of gradient colour stops. There are existing tools for this, but they do a rather suboptimal job of it. We can think of the multi-stop gradient as a polyline: the stops are knots, each partial gradient a line connecting those. The existing tools put their stops on the curve at even steps, wasting them on straight parts and biasing the error:

Knots on the curve

Fitted knots (ours)

How we do better

We could just add more stops, but that would make the CSS bigger, and might even crash your browser. Instead, we just try to make the most of a given stop budget. To keep things snappy, we also have a time budget, which keeps fitting a single curve close to real time. So how should the knots be placed? They can be placed on the curve in a few different ways, but an optimal solution places them freely:

Equal steps in x

Equal steps in t

Curvature-weighted

Freely placed (ours)

The good place

To find a “good” placement for the knots, we first have to define what “good” means. What we’re effectively doing here is minimising an error function: we just have to pick one. There are some overkill ones like Fréchet and Hausdorff, which we can skip because we know our curves are rather well behaved. That leaves us with simpler ones like the mean squared error (MSE) and the worst-case error (L∞). These two usually produce pretty much the same result (especially at higher knot counts), so we’ll just go with MSE, as it’s slightly less heavy. At low knot counts they can differ a bit, though:

Equal steps in t (the existing tools)

Lowest MSE (ours)

Lowest worst-case error

The error bars show the error in grey levels.

Finding the knots

Next up, we’ll have to use some magic method to minimise that error. Nelder-Mead works beautifully, but gets bogged down at higher knot counts. For those, we use a two-step method: dynamic programming, which is then fine-tuned with Levenberg-Marquardt.

The “old” ways of placing the knots on the curve are still useful: we can use them as a starting point for finding a good placement. I ended up using the curvature-weighted one, as it seemed to produce the best results.

Fitting a curve

For curves that are not clamped (see below), we tie the end knots in place and optimise the rest:

Equal steps in x

Optimised knots

Out of range

While CSS’s cubic-bezier() does keep x between 0 and 1, it doesn’t do the same for y. This means curves can reach any y value, be it negative or positive. People aren’t likely to use these, but we have to handle them somehow. As the only generic way of mixing colours in CSS is color-mix(), which only allows 0% to 100%, we’ll have to clamp the values. The existing tools mostly just break here. The CSSWG proposal leans towards extrapolating instead, which would carry the colours on past the gradient’s end colours.

Divide and conquer

Just letting the fit run as-is would produce a result; for a good one, we’ll have to do some chopping. Assuming we have enough knots to place, we can just handle each in-range span as a separate curve. We pin a knot exactly on each point where the curve crosses 0 or 1. That’s basically just a choice, but one that dividing and conquering pretty much needs: with its ends fixed, each span is just a smaller curve to fit, and the clamped parts stay exactly flat.

Knots per in-range span

Left span
Right span

Equal steps in x, clamped

Pinned and fitted

Three edge cases

We don’t always have enough knots to share, so we have to compromise, or even give up altogether and just let the basic fit do its job.

If a CSS gradient’s stops don’t cover the whole 0% to 100% range, the end stops are extended to fill the gaps, so a clamped end can save us a knot.

4 knots: not enough to pin the crossings around both in-range spans, so we give up on them and just minimise the error: the fit misses the crossings.

5 knots: enough to pin both in-range spans, but the first one is so small that its knot does more good in the other one, so we skip it: it’s drawn flat at 0, along with the out-of-range span after it.

4 knots: if the curve is clamped at the end, we can move the end knot inwards to the crossing (thus saving a knot).

How we got here

Finding good methods and tweaking them was wholly made possible by AI. I had no idea that any of these methods even existed, and researching the field would’ve taken me ages. I do suggest looking into Nelder-Mead though: its simplicity tickles your brain nicely.

I did try other approaches along the way. Machine learning model data was simply too large (and still produced subpar results for clamped curves). A large enough precomputed grid of fits, to interpolate between or to use as initial guesses, would’ve taken ages to calculate, and would’ve been way too large. I did also dig up a ton of papers with deep-research in the hopes of a good pre-existing solution, but ended up finding nothing suitable.

Results and limits

Compared to the best of the existing tools for each in-range curve, our gradients have between a third (at 3 stops) and a tenth (6 stops and up) of the mean squared error, or the same error with 20% to 40% fewer stops (5 stops and up).

As the approximation is just a bunch of straight lines, it still produces Mach bands. They’re fainter at the stop counts gradients usually use, but with only a few stops they can still show:

Browsers’ colour handling in other colour spaces is so buggy that it’s not feasible to support anything but sRGB for now. Every major browser gets some of them wrong: grey ends pick up a stray hue, colours outside sRGB get clipped, or the whole gradient is only roughly approximated.

Further work

It might be possible to create a smaller output for some use cases by creating a 1×256 PNG instead of a CSS gradient. The usability of this is questionable.

CSS gradients support colour hints (effectively tweening). This would produce smaller CSS, but would limit the optimisation possibilities. I’m not sure how well the current methods would work with this limitation.

MSE only measures how far the gradient is from the curve, not how much the knots show as Mach bands. An error measure that also penalises sharp bends at the knots might look better with the same stops, especially with only a few of them. As the result of the tool is mostly used for CSS gradients on 24-bit displays, we might want to use the 8-bit grey error in the error measure.

People would probably like a PostCSS or Tailwind plugin for this. It’s wholly possible to do; it’s mostly a question of what syntax to use. The CSSWG proposal is still a mess, and might even end up with a solution that isn’t polyfillable with color-mix(). Hopefully native easing in gradients lands one day, and makes all of this obsolete.

Bonus: other easing curves

There are easing curves that are not cubic Béziers, such as the classic easing functions and smoothsteps. All of them are “static”, as in they have no user controls, so we can just precalculate them. Turns out that the precalculated data takes up quite a bit of space though, so we just store the x placement and optimise y with the existing tooling.

Give it a go

Settings

Control point 1 (P1)
Control point 2 (P2)

Plot

Result

Start %
End %

Notes and links

Notes

  1. Equal steps in t vs. x: a cubic-bezier() curve is drawn by a parameter t that runs from 0 to 1 as a point travels along it. The point moves faster in some places than others, so equal steps in t don’t land at equal steps across the gradient (in x).
  2. Grey levels: MSE just gives us one number, whereas the error in grey levels shows locally how much we differ from the curve in 8-bit colour. An 8-bit gradient from black to white has 256 shades of grey, and the error in grey levels is how many of those shades the approximation is off from the curve.
  3. How similar are the fits? Across 216 in-range curves (CSS keywords, easings.net and 200 random curves), the MSE and worst-case-error fits differ by a median of 0.6 grey levels (95th percentile: 2.2) at 12 segments, and 0.25 (0.8) at 20 segments. At 3 segments, the median gap rises to 4.1 grey levels (95th percentile: 15), which is why the low-count caveat matters.

Links

  1. Mach bands — Wikipedia
  2. Easing Linear Gradients by Andreas Larsen — CSS-Tricks, 2017; and his easing gradients tool
  3. cubic-bezier() — MDN
  4. Easing gradients proposal — CSS Working Group, issue #1332
  5. Existing tools:
  6. Many gradient stops crash Firefox — Mozilla bug 2016487
  7. Fréchet distance — Wikipedia
  8. Hausdorff distance — Wikipedia
  9. Mean squared error — Wikipedia
  10. Uniform norm (worst-case error) — Wikipedia
  11. Nelder-Mead method — Wikipedia
  12. Dynamic programming — Wikipedia
  13. Levenberg-Marquardt algorithm — Wikipedia
  14. color-mix() — MDN
  15. Coloring the gradient line — CSS Images Module Level 3, §3.4.2
  16. Browser bugs with gradients in other colour spaces:
    • Chromium: white, black and grey ends get a stray hue in lch and oklch (407374337, 422917161, 40941369)
    • Chromium: increasing or decreasing hue turns the area before the first stop into a rainbow (575586564)
    • Firefox: every non-sRGB gradient is drawn as an approximation, wrong on steep parts (2084115)
    • Firefox: out-of-sRGB end colours are clipped (2084121)
    • Firefox: semi-transparent colours aren’t mixed with premultiplied alpha (1869681)
    • WebKit (its Skia ports, on Linux): out-of-sRGB stops are clipped before mixing (328178)
    • WebKit (its Skia ports, on Linux): display-p3-linear gradients are interpolated in sRGB (328179)
  17. Colour hints in linear-gradient() — MDN
  18. Easing — Nic Mulvaney
  19. Smoothsteps — Inigo Quilez

Papers

  1. Cromme, L. J., Kunath, J., & Krebs, A. (2017). Computing best discrete least-squares approximations by first-degree splines with free knots (arXiv:1704.05670). arXiv. https://doi.org/10.48550/arXiv.1704.05670
  2. Stone, H. (1961). Approximation of curves by line segments. Mathematics of Computation, 15(73), 40–47. https://doi.org/10.1090/S0025-5718-1961-0119390-6
  3. Pérez, J.-C., & Vidal, E. (1994). Optimum polygonal approximation of digitized curves. Pattern Recognition Letters, 15(8), 743–750. https://doi.org/10.1016/0167-8655(94)90002-7
  4. Schwetlick, H., & Schütze, T. (1995). Least squares approximation by splines with free knots. BIT Numerical Mathematics, 35(3), 361–384. https://doi.org/10.1007/BF01732610
  5. Berjón, D., Gallego, G., Cuevas, C., Morán, F., & García, N. (2016). Optimal piecewise linear function approximation for GPU-based applications. IEEE Transactions on Cybernetics, 46(11), 2584–2595. https://doi.org/10.1109/TCYB.2015.2482365 (open-access copy)
  6. Imai, H., & Iri, M. (1986). Computational-geometric methods for polygonal approximations of a curve. Computer Vision, Graphics, and Image Processing, 36(1), 31–41. https://doi.org/10.1016/S0734-189X(86)80027-5
  7. Douglas, D. H., & Peucker, T. K. (1973). Algorithms for the reduction of the number of points required to represent a digitized line or its caricature. Cartographica, 10(2), 112–122. https://doi.org/10.3138/FM57-6770-U75U-7727
  8. Magnani, A., & Boyd, S. P. (2009). Convex piecewise-linear fitting. Optimization and Engineering, 10(1), 1–17. https://doi.org/10.1007/s11081-008-9045-3
  9. Kégl, B., Krzyżak, A., Linder, T., & Zeger, K. (2000). Learning and design of principal curves. IEEE Transactions on Pattern Analysis and Machine Intelligence, 22(3), 281–297. https://doi.org/10.1109/34.841759
  10. Parvez, M. T. (2015). Optimized polygonal approximations through vertex relocations in contour neighborhoods. Image and Vision Computing, 34, 1–10. https://doi.org/10.1016/j.imavis.2014.10.012