CSS gradient vs WebGL gradient: which one for an animated background

When a CSS gradient is enough, when WebGL is worth it, and what each costs, with a Chrome paint trace.

Updated

Use a CSS gradient when the background is still, or when the motion is a gradient sliding, rotating or fading. It ships no JavaScript, and if you animate it with transform the browser doesn't have to repaint it while it moves. Use a WebGL gradient when you want colours that fold into each other, organic motion that never looks like a moving strip, film grain, or smooth gradients without banding on large screens. That costs a script (about 52 KB gzipped for gradients.sh) and GPU work on every frame, so a good component limits when and how much it draws.

The rest of this post shows both, with a trace of what each CSS animation actually costs in Chrome.

What a CSS gradient can do

linear-gradient, radial-gradient and conic-gradient cover most still backgrounds. Stack several radial-gradient layers and you get a mesh-style gradient in CSS. Current browsers can also blend the stops in a perceptual colour space, which keeps the middle of the blend from going dark and dull the way sRGB blends often do:

.hero {
  background: linear-gradient(in oklab 90deg, #ff5ca8, #38d6f5);
}

The in oklab syntax works in current Chrome, Safari and Firefox. Older browsers drop the whole declaration, so put a plain background line before it as a fallback.

A gradient that sits still costs nothing after its first paint. The question is how you make it move.

Three ways to animate a CSS gradient, and what they cost

We put each of these on a 1280×720 box in headless Chrome 154 and recorded a 3-second performance trace, twice.

TechniquePaint and commit events in 3 sRepaints the gradient
Animate background-positionabout 360 paints, 180 commitsevery frame
Animate an @property angle in a gradientabout 360 paints, 180 commitsevery frame
Slide a wider layer with transformnonenot while it moves

The first two are the usual recipes. A sliding Tailwind gradient moves background-position, and a spinning gradient border animates a registered @property angle. Both make the browser recalculate and repaint the gradient on the main thread, 60 times a second. The paint itself was cheap on a desktop CPU (under 7 ms per second of animation), so this isn't a reason to avoid them on a card or a button. It matters on a full-screen hero on a slow phone, and it matters when your own JavaScript is busy: the animation needs the main thread, so it stutters when the main thread is blocked.

The third one paints the gradient once and hands the movement to the compositor, the same path the browser uses for scrolling. Once it was running, the trace showed no paint or commit events for it at all.

Pure CSS, on the compositor

Here is the same thing as plain CSS:

.hero {
  position: relative;
  isolation: isolate;
  overflow: hidden;
}

.hero::before {
  content: "";
  position: absolute;
  inset: 0 auto 0 0;
  z-index: -1;
  width: 200%;
  background-image: linear-gradient(90deg, #7c5cff, #38d6f5, #ff5ca8, #7c5cff);
  background-size: 50% 100%;
  animation: slide 12s linear infinite;
}

@keyframes slide {
  to {
    transform: translateX(-50%);
  }
}

@media (prefers-reduced-motion: reduce) {
  .hero::before {
    animation: none;
  }
}

The layer is two tiles wide, each tile one box wide, and the gradient starts and ends on the same colour. Sliding it by half its width moves it exactly one tile, so the last frame matches the first and the loop has no jump. isolation: isolate keeps the z-index: -1 layer inside the box, behind the text.

Where CSS runs out

CSS can move, rotate, scale and fade a gradient, and with registered custom properties it can animate a gradient's positions, sizes and angles too. What it can't do is warp the colours freely. That's the limit you notice first:

  • Shapes stay simple. Every CSS gradient is a line, an ellipse or a cone. You can animate its stops, centre and radii, but a radial blob stays an ellipse. Colours that swirl and fold into each other need a different value for every pixel on every frame, and CSS has no way to compute that.
  • Layers stack, they don't mix. Overlapping radial-gradient layers are alpha-composited, so the top colour partly covers the one below. With opposite colours the overlap can look muddy.
  • Big smooth gradients band. Screens show 8 bits per channel, so a dark gradient stretched over 1,500 pixels has to repeat each shade across several pixels. You see the steps as stripes. CSS has no dithering to hide them.
  • Grain doesn't move. You can lay an SVG noise tile over a gradient, and our film grain post shows how, but it's the same noise every frame unless you step its position with another animation.

None of these matter for a button or a section divider. They matter for a hero that's meant to be the visual centre of the page.

What a WebGL gradient does differently

A WebGL (or WebGPU) gradient runs a small program on the GPU for every pixel of every frame. Both APIs run it at about the same speed; WebGPU vs WebGL2 for UI backgrounds has the measurements. That's what makes the effects above possible. In the gradients.sh engine, each pixel's colour comes from noise that loops in time, colours blend in OKLab, grain is generated fresh for each frame, and a faint trace of that noise stays on at zero grain to break up 8-bit banding.

Keep it moving

That's the free Linear Flow component. Install it into any React project with a components.json (run npx shadcn@latest init first if you don't have one):

pnpm dlx shadcn@latest add https://gradients.sh/r/linear-flow.json

What WebGL costs, and how to keep it down

Three costs come with it:

  1. JavaScript. The gradients.sh engine is about 52 KB gzipped up front, plus about 8 KB loaded later on browsers that use WebGPU. Other libraries range from 8.4 KB to 293.6 KB for one gradient; the React gradient libraries comparison has the measurements and how to rerun them.
  2. GPU work per pixel. The work grows with the number of pixels and frames. A 1440×900 hero on a 3× phone screen is many times more pixels than the same hero at 1×.
  3. A script on every frame. The draw call is scheduled with requestAnimationFrame, so like the first two CSS techniques, it uses the main thread on every frame, if only briefly.

These are the limits a well-behaved component should apply, and what the gradients.sh components do:

  • Cap the pixel ratio. By default the engine draws at most 1.5 device pixels per CSS pixel. A soft gradient looks the same, and a 3× screen draws a quarter of the pixels.
  • Share a pixel budget. Moving gradients on a page share about 2 million pixels per frame (a gradient marked as the page's one hero gets its own, larger budget). Over that, they render at a lower internal scale and are scaled up. If that still isn't enough, they drop to every other display frame.
  • Stop when nobody is looking. Gradients scrolled away from the viewport stop drawing, and so does every gradient when the tab is hidden.
  • Respect reduced motion. Visitors who ask for reduced motion get one still frame.
  • Ship a CSS fallback. The server renders the canvas with a CSS gradient built from the same colours, so the first paint, and browsers without WebGL2, show the gradient instead of an empty box.
  • Keep the GPU contexts few. Browsers drop the oldest WebGL context when a page creates too many. The engine shares at most 8 among all its gradients instead of giving each its own.

If you write your own shader, copy at least the first four. They make more difference than the shader code.

Which one to use

  • Still background: CSS. Use in oklab for smoother colour stops.
  • A gradient that slides, spins or pulses on a button, card or border: CSS. Use transform or opacity where you can; background-position and @property are fine at this size.
  • A full-screen hero that slides: CSS with transform, as in the demo above.
  • A hero where colours drift and fold, with grain, or on a large dark gradient that bands: WebGL, with the limits above.
  • A background where the motion is fixed and no one needs to change the colours: a seamless MP4 loop is a third option. Check its weight against the script first: background video vs animated gradient has the numbers, and grain makes video very heavy.

You can also use both. A CSS gradient in the same colours as the shader makes a good first paint and a fallback, which is what our components already do. To build a WebGL gradient without writing a shader, open the gradient editor and tune it. Copying its component code needs a free account.