Animated gradient backgrounds, from CSS to shaders

Animated gradient backgrounds that stay smooth: CSS moved with transform, @property, WebGL shaders and particles, with real costs and a banding fix.

A glass sphere resting on pure black, filled with a slow swirl of violet light that deepens toward its base and brightens at its upper edge.
Painted once, moved forever. The repaint is the expensive part.

An animated gradient that changes its background-position repaints the whole element on every frame. The same gradient painted once onto an oversized layer and moved with transform does not repaint at all. That one decision separates a gradient background that stays smooth on a phone from one that stutters, and it applies to every technique on this page, from a CSS blob to a WebGL shader.

Gradient and shader backgrounds have one advantage over video: they download almost nothing. They have one consistent risk: they cost something on every frame, for as long as the tab is open. Everything below is about keeping that per-frame cost small, and about the values that make a moving gradient look deliberate rather than generated.

What makes a moving gradient look expensive#

A gradient reads as designed when its colors are chosen as a set and its motion is too slow to watch. Five values carry most of that:

ValueSettingWhy
Huestwo or three, close in lightnessbig lightness jumps read as a spotlight, not a field
Interpolationin oklchsRGB between distant hues passes through gray
Period20s to 30s per cycleslower than attention; the page feels alive, never busy
Easingcubic-bezier(0.45, 0, 0.55, 1), alternateno visible turnaround at the ends
Grainstatic noise at 3% to 5% opacityhides 8-bit banding on dark fields

The interpolation row is the easiest one to miss. Under CSS Color 4, hex and other legacy colors in a gradient interpolate in gamma-encoded sRGB, so blue #3B82F6 to amber #F59E0B passes through #989080 at the midpoint, a muddy gray. Writing linear-gradient(in oklch, #3B82F6, #F59E0B) keeps chroma through the middle. The syntax works in Chrome 111, Safari 16.2 and Firefox 127, per caniuse.

Neither of these is a CSS gradient, and that is the point worth taking from them. Brisk gets the soft, flowing color field that people reach for shaders to make from a looping film of a clay ribbon, and Caravan puts one object into a single saturated field instead of drifting three blobs behind it. When a gradient has to carry a hero alone, hold it to the same discipline: few hues, one direction of movement, and nothing else moving.

Three gradient backgrounds worth starting from#

Every animated gradient starts as a still one, and the still frame has to work on its own: it is what reduced-motion visitors see, what a screenshot shows, and what loads first. These three are tuned for three common pages, with the text color that sits on each.

css
.night {  /* dark hero; text #F4F1FF */
  background:
    radial-gradient(120% 80% at 20% 0%, #3B1D8F 0%, transparent 60%),
    radial-gradient(90% 70% at 100% 100%, #0F3B5F 0%, transparent 55%),
    #0B0A14;
}
.dawn {   /* light editorial page; text #2A1A14 */
  background: linear-gradient(170deg in oklch, #FFF4E6 0%, #FFD9C2 55%, #F7B4A7 100%);
}
.mint {   /* product or SaaS page; text #0F1B2D */
  background: linear-gradient(135deg in oklch, #E6FFF4 0%, #CDEBFF 100%);
}

The text colors were checked against the lightest and darkest stop of each gradient. On the night field #F4F1FF measures 10.42:1 against its brightest stop, #0F3B5F, and 17.68:1 on the base. On dawn, #2A1A14 measures 9.58:1 on the deepest stop, and on mint #0F1B2D measures 13.93:1 on #CDEBFF. All of them clear the 4.5:1 body-text minimum with room left for a moving layer.

There is no native mesh gradient in CSS. What design tools call a mesh is approximated on the web with two to four stacked radial gradients, as in the night example, or drawn by a shader. Stacked radials are enough for most heroes, and they animate cheaply with the technique below.

CSS: move the layer instead of repainting it#

Per web.dev, changing any property other than transform or opacity triggers paint, and backgrounds are named in that rule. So the gradient goes on a pseudo-element, gets painted once, and only its transform animates:

css
.hero { position: relative; overflow: clip; isolation: isolate; background: #0E0B1A; }
.hero::before {
  content: ""; position: absolute; inset: -15%; z-index: -1;
  background:
    radial-gradient(40% 50% at 30% 40%, #7C5CFF 0%, transparent 70%),
    radial-gradient(35% 45% at 70% 65%, #FF6A88 0%, transparent 70%);
  animation: drift 24s cubic-bezier(0.45, 0, 0.55, 1) infinite alternate;
}
@keyframes drift { to { transform: translate3d(8%, -6%, 0) rotate(6deg); } }
@media (prefers-reduced-motion: reduce) { .hero::before { animation: none; } }

The overscan has a memory price. A composited layer is stored at device pixels, four bytes each, so a 1440 by 900 hero at a pixel ratio of 2 is about 20.7 MB of texture, and inset: -15% raises it to about 35 MB. An inset: -50% makes it about 83 MB. Keep the overscan just larger than the travel.

When the colors should shift rather than the shapes drift, crossfade two static layers. Paint the first palette on ::before and the second on ::after, then animate only the opacity of ::after from 0 to 1 over 12s, alternating. Opacity is the other property the compositor handles alone, so the hues appear to change without a single repaint.

Rotating a gradient's angle needs a different tool, because a custom property inside a gradient cannot interpolate until it is registered:

css
@property --angle { syntax: "<angle>"; inherits: false; initial-value: 0deg; }
.ring { background: conic-gradient(from var(--angle) in oklch, #22D3EE, #A78BFA, #22D3EE);
        animation: turn 12s linear infinite; }
@keyframes turn { to { --angle: 360deg; } }

@property works in Chrome 85, Safari 16.4 and Firefox 128, per caniuse. This version repaints every frame, which is fine for a button border or a badge and wasteful across a full viewport.

Banding, and why dark gradients need grain#

A gradient between two close dark colors has very few steps to draw with. From #141824 to #1C2233, red moves 8 levels, green 10 and blue 15, so across a 1920 pixel hero each step of blue spans more than 100 pixels. On many screens those steps show as stripes, and video compression makes them worse.

Rookery makes a style of the technique that fixes banding. Dithering scatters pixels of neighboring values so the eye averages them into a tone the screen cannot show directly. On a gradient you want the invisible version of the same trick:

  • In CSS: a static noise texture over the gradient, at 3% to 5% opacity with mix-blend-mode: overlay or soft-light. It is painted once and never animates.
  • In a shader: add (random - 0.5) / 255.0 to each pixel's color, which is one line, shown in the next section.
  • In video: add the grain before encoding, and keep the CRF a step lower for dark gradient footage than for busy footage.

The grain value is small on purpose. At 3% the noise is hard to see and the field reads as smooth. At 10% the background starts to look like film stock, which can be a choice, but then it is a style and has to be specified as one.

WebGL shaders, capped#

A shader background is a canvas whose every pixel is computed each frame by a fragment shader, a small GPU program that takes the pixel's position and the time and returns a color. It can draw liquid, ink and noise motion that CSS cannot. The minimum useful one is short:

glsl
precision mediump float;
uniform vec2 uRes;
uniform float uTime;
uniform vec3 uA;   // #2B1055 as 0.169, 0.063, 0.333
uniform vec3 uB;   // #7597DE as 0.459, 0.592, 0.871

float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }

void main() {
  vec2 uv = gl_FragCoord.xy / uRes;
  float t = uTime * 0.15708;                 // pi / 20: the pattern repeats every 20 s
  float w = 0.5 + 0.5 * sin(uv.x * 3.0 + t) * cos(uv.y * 2.0 - t);
  vec3 col = mix(uA, uB, smoothstep(0.0, 1.0, w));
  col += (hash(gl_FragCoord.xy) - 0.5) / 255.0;   // dither against banding
  gl_FragColor = vec4(col, 1.0);
}

The cost scales with pixels, so the caps go in the JavaScript around it. At a pixel ratio of 2 a 1440 by 900 canvas is about 5.2 million pixels a frame; cap the ratio with Math.min(devicePixelRatio, 1.5), or render a soft gradient at half resolution and let CSS stretch the canvas. Throttle to 30 fps by skipping frames inside requestAnimationFrame, stop drawing when an IntersectionObserver reports the canvas off screen, and draw one frame and stop under reduced motion. Keep the CSS gradient underneath as the fallback when getContext("webgl") returns null.

You do not have to write the shader yourself. The gradient Stripe used on its homepage runs on a minimal WebGL layer that its code calls minigl, with four colors read from CSS variables, as Kevin Hufnagl's write-up documents, and ShaderGradient packages moving shader gradients for Framer, Figma and React. For full 3D scenes rather than flat fields, see the Three.js examples.

Particle backgrounds, counted#

Particle fields are the other half of this genre: dots that drift, and often lines drawn between dots that come close. The lines are the expensive part. The classic implementation checks every pair of particles every frame, a nested loop you can read in particles.js, whose last release dates from 2015. Pairs grow with the square of the count.

ViewportParticles at one per 12,000 px²Pair checks per frame
1440 by 900 desktop1085,778
390 by 844 phone27351
Fixed 150, any screen15011,175

Scaling the count to the area keeps a phone from running a desktop's workload. For a maintained library, tsParticles is actively developed and covers the same effects. Past a few hundred particles, draw them in a shader with instancing instead of on a 2D canvas.

The look most particle backgrounds are reaching for is light on dark, and these two designs get it from footage. Embercall fills its night scene with glowing reeds; Lorikeet sets its bird against warm blurred lights. A film like either costs bytes once, where a particle field costs CPU or GPU time for as long as the page stays open.

How to brief an AI model for a gradient or shader background#

A model asked for "an animated gradient background" tends to produce a drifting mesh of three saturated blobs, one of the defaults described in why AI websites look the same. The brief has to replace every guess with a value:

  • the colors in hex or oklch(), and the interpolation space
  • the technique: transformed CSS layer, registered property, or shader
  • the period, the easing and the travel distance
  • the grain opacity
  • the caps: pixel ratio, frame rate, pause off screen
  • the reduced-motion state and the fallback with no WebGL
  • the scrim under the headline, as a value
text
Background: CSS only. ::before layer, inset -15%, two radial gradients,
#7C5CFF at 30% 40% and #FF6A88 at 70% 65%, both to transparent at 70%,
over #0E0B1A. Animate transform only: translate3d(8%, -6%, 0) rotate(6deg),
24s, cubic-bezier(0.45, 0, 0.55, 1), infinite alternate.
Grain: static noise PNG at 4% opacity, soft-light. No other element moves.
prefers-reduced-motion: no animation, layer at its start position.

For a shader, the brief adds the parts a model would otherwise skip: a WebGL fragment shader with the two colors passed as uniforms, a period of 20s, a dither of one 255th per pixel, the pixel ratio as Math.min(devicePixelRatio, 1.5), a 30 fps cap, an IntersectionObserver pause, and the CSS gradient underneath as the no-WebGL fallback. Each of those is one line in the prompt and an afternoon of debugging when it is missing.

If a gradient will sit behind frosted panels, the panel values matter as much as the field; glassmorphism that still reads covers that pairing, and the dark mode values cover the palette around a dark field.

Mistakes that make gradient backgrounds look generated#

  • Three saturated hues of equal weight. One dominant field and one accent read as a palette; three equal blobs read as a default.
  • Animating background-position on a full-screen element. It repaints every frame. Move a layer instead.
  • A huge overscan. inset: -50% quadruples the layer's memory.
  • No grain on a dark gradient. Banding shows on many screens, and in screenshots and screen recordings of the page.
  • sRGB between distant hues. The midpoint goes gray; write in oklch.
  • A shader at full pixel ratio, 120 times a second, off screen. Each of those three is a cap you forgot to write.
  • Motion fast enough to watch. Under 20s a cycle starts competing with the headline.

A moving field you do not have to tune#

Sometimes the honest answer is footage. A soft abstract loop costs bytes once instead of GPU time on every frame, it decodes in hardware, and it has no banding to fight if it was graded and encoded with grain. Thanor's animated backgrounds page has an Abstract filter of looping ribbons, ink, smoke, silk and light, alongside landscapes, characters and objects. Copy a clip's URL into Claude, Cursor, Lovable or Bolt, or download it and host it yourself; the clips without a crown are free with an account.

The comparison is the same one animated website backgrounds makes across every technique: video costs bytes once, a shader costs GPU time forever, and a transformed CSS layer costs almost nothing if you write its caps. For encoding a downloaded loop, use the video background guide.

Open the Abstract filter on the backgrounds page and put one loop next to your CSS gradient in the same hero. Whichever one you stop noticing after ten seconds is the one to ship.

Questions this raises

How do I make an animated gradient background in CSS?

Put two or three radial gradients on a pseudo-element behind the content, give it a small overscan such as inset -15%, and animate its transform over 20 to 30 seconds with an ease-in-out curve and alternate direction. The gradient is painted once and the compositor moves it. Stop the animation under prefers-reduced-motion and keep a static gradient as the resting state.

Do animated gradients hurt website performance?

They can. Animating background-position, or a registered custom property used inside the gradient, repaints the element on every frame, which is costly on a full-screen hero and on phones. Moving a pre-painted layer with transform avoids the repaint. Large overscan layers cost GPU memory, so keep the overscan small.

How do I fix banding in a gradient background?

Banding appears when two close dark colors have only a few 8-bit steps between them, so the gradient is drawn in visible stripes. Add fine noise: a static grain texture at 3 to 5 percent opacity in CSS, or one 255th of random noise per pixel in a shader. Interpolating in oklch also avoids the gray middle that sRGB produces between distant hues.

What is a shader background?

A shader background is a canvas drawn by a fragment shader, a small GPU program that computes the color of every pixel each frame from its position and the time. It can produce liquid, ink or noise motion that CSS cannot. Its cost is GPU time per pixel, so cap the pixel ratio, cap the frame rate and pause it off screen.

Are particle backgrounds bad for performance?

Linked-particle effects compare every pair of particles each frame, so cost grows with the square of the count: 150 particles is 11,175 checks per frame. Scale the count to the viewport area, about one particle per 12,000 square pixels, drop the linking lines on phones, and pause the loop when the canvas leaves the screen or reduced motion is on.

Part of

Inspiration, with the prompt

Examples of the looks and effects people search for, from 3D scenes to scroll animation, each one shown working and paired with the specification that builds it.