Mesh gradient

Aurora

Soft blobs of color drifting on slow orbits, painted by the browser from a handful of gradients and registered custom properties.

How it works

An aurora from gradients that move

A mesh gradient is a few soft color spots that blend into each other. Making them drift is the interesting part: CSS can't animate a gradient, but it can animate the numbers inside one, once the browser knows what type those numbers are.

Two ways to the same picture

Both techniques on this page draw the same blobs on the same orbits. They differ in who does the work every frame:

mesh
One element, one radial-gradient per blob, all stacked in a single background-image. The blob positions are calculated from an animated custom property, so the browser repaints the whole gradient on every frame. Soft edges come from the gradient itself; a blur is optional.
blobs
One element per blob: a circle filled with the same fading gradient, usually blurred with filter: blur(). Each one orbits with a rotate animation, which the compositor can run on the GPU without repainting anything, as long as the colors stay put.

1. Why the custom properties have to be registered

A gradient is an image, and images don't interpolate: transition: background-image from one gradient to another just swaps them. A normal custom property doesn't help either. To the browser, --angle: 90deg is not an angle but a list of tokens that happens to be spelled like one. It has no idea what lies between two token lists, so it animates them discretely: the value flips from start to end halfway through.

@property registers a custom property with a type (its syntax), an initial-value and whether it inherits. Now the browser parses 90deg as an <angle>, knows how to interpolate it, and recomputes everything that uses the property on every frame, gradients included. The two squares below run the identical animation; only the left one uses a registered property.

The aurora registers three kinds of property:

<angle>
--aurora-t, a clock running from 0 to 360deg. Each blob's position is derived from it with cos() and sin().
<percentage>
--aurora-falloff, where each gradient fades to transparent. Swinging it between 70% and 100% makes the blobs breathe.
<color>
--aurora-c0 … --aurora-c4, one per palette color. With Color drift on, each fades towards the next palette color. These inherit, so the colors set on the container reach every blob.

2. The mesh: layered radial-gradients

Each blob is radial-gradient(circle R at X Y, color, transparent var(--aurora-falloff)). Multiple backgrounds are painted with the first one on top (the list is reversed so the last blob ends up on top, matching the DOM order of the blobs technique), and transparent parts let the layers below show through, so overlapping blobs mix like wet paint. background-blend-mode decides how they mix: normal stacks them, screen and lighten glow, multiply darkens.

The orbit is plain trigonometry. A blob that starts at (x, y) and circles a point r to its right sits at x + r − r·cos(a), y − r·sin(a), with a = turns × t + phase. turns is a whole number (±1 or ±2), so after one cycle of t every blob is exactly back at its start and the loop has no seam. Sizes and positions are in container query units (cqw, cqh, cqmax), so the composition scales with the box, not the viewport.

This is the CSS for your current settings:

@property --aurora-t { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
@property --aurora-falloff { syntax: '<percentage>'; inherits: false; initial-value: 100%; }
@property --aurora-c0 { syntax: '<color>'; inherits: true; initial-value: transparent; }
@property --aurora-c1 { syntax: '<color>'; inherits: true; initial-value: transparent; }
@property --aurora-c2 { syntax: '<color>'; inherits: true; initial-value: transparent; }
@property --aurora-c3 { syntax: '<color>'; inherits: true; initial-value: transparent; }

.aurora {
  container-type: size; /* cqw, cqh, cqmax below are relative to this box */
  position: relative;
  overflow: hidden;
}

.aurora > .mesh {
  position: absolute;
  --pad: 80px;
  inset: calc(var(--pad) * -1);
  filter: blur(40px);
  --aurora-c0: #7c3aed;
  --aurora-c1: #06b6d4;
  --aurora-c2: #ec4899;
  --aurora-c3: #22c55e;
  background-color: #0b0820;
  background-image:
    radial-gradient(circle 30cqmax at calc(var(--pad) + 38.37cqw + 11.46cqmax - 11.46cqmax * cos(calc(var(--aurora-t) * -2 + 344deg))) calc(var(--pad) + 69.65cqh - 11.46cqmax * sin(calc(var(--aurora-t) * -2 + 344deg))), var(--aurora-c0), transparent var(--aurora-falloff)),
    radial-gradient(circle 30cqmax at calc(var(--pad) + 64.35cqw + 10.69cqmax - 10.69cqmax * cos(calc(var(--aurora-t) * -1 + 95deg))) calc(var(--pad) + 74.2cqh - 10.69cqmax * sin(calc(var(--aurora-t) * -1 + 95deg))), var(--aurora-c3), transparent var(--aurora-falloff)),
    radial-gradient(circle 30cqmax at calc(var(--pad) + 74.68cqw + 10.57cqmax - 10.57cqmax * cos(calc(var(--aurora-t) * -1 + 249deg))) calc(var(--pad) + 28.53cqh - 10.57cqmax * sin(calc(var(--aurora-t) * -1 + 249deg))), var(--aurora-c2), transparent var(--aurora-falloff)),
    radial-gradient(circle 30cqmax at calc(var(--pad) + 51.25cqw + 14.74cqmax - 14.74cqmax * cos(calc(var(--aurora-t) * 1 + 250deg))) calc(var(--pad) + 31.33cqh - 14.74cqmax * sin(calc(var(--aurora-t) * 1 + 250deg))), var(--aurora-c1), transparent var(--aurora-falloff)),
    radial-gradient(circle 30cqmax at calc(var(--pad) + 31.41cqw + 15.46cqmax - 15.46cqmax * cos(calc(var(--aurora-t) * -2 + 123deg))) calc(var(--pad) + 51.28cqh - 15.46cqmax * sin(calc(var(--aurora-t) * -2 + 123deg))), var(--aurora-c0), transparent var(--aurora-falloff));
  animation:
    clock 40s linear infinite,
    breathe 10s ease-in-out infinite alternate,
    drift 30s ease-in-out infinite alternate;
}

@keyframes clock { to { --aurora-t: 360deg; } }
@keyframes breathe { from { --aurora-falloff: 70%; } to { --aurora-falloff: 100%; } }

/* every color fades towards the next one in the palette */
@keyframes drift {
  to {
    --aurora-c0: #06b6d4;
    --aurora-c1: #ec4899;
    --aurora-c2: #22c55e;
    --aurora-c3: #7c3aed;
  }
}

/* grain (opacity 0.12): a separate noise layer on top, see section 5 */

@media (prefers-reduced-motion: reduce) {
  .aurora > .mesh { animation: none; }
}

3. The blobs: blur and transforms

The classic aurora: big soft circles, heavily blurred. Each blob gets a transform-origin to the right of its center and rotates around it, which moves it along the same circle as in the mesh. A negative animation-delay starts it part-way round, at its phase angle. Blending uses mix-blend-mode, and isolation: isolate on the container keeps the blend inside it.

<div class="aurora" aria-hidden="true">
  <span class="orbit" style="left: 31.41%; top: 51.28%; --orbit: 15.46cqmax; animation-duration: 20s; animation-delay: -13.17s; animation-direction: reverse">
    <span class="blob" style="--color: var(--aurora-c0); animation-delay: 0s"></span>
  </span>
  <span class="orbit" style="left: 51.25%; top: 31.33%; --orbit: 14.74cqmax; animation-duration: 40s; animation-delay: -27.78s">
    <span class="blob" style="--color: var(--aurora-c1); animation-delay: -5.71s"></span>
  </span>
  <span class="orbit" style="left: 74.68%; top: 28.53%; --orbit: 10.57cqmax; animation-duration: 40s; animation-delay: -12.33s; animation-direction: reverse">
    <span class="blob" style="--color: var(--aurora-c2); animation-delay: -11.43s"></span>
  </span>
  <span class="orbit" style="left: 64.35%; top: 74.2%; --orbit: 10.69cqmax; animation-duration: 40s; animation-delay: -29.44s; animation-direction: reverse">
    <span class="blob" style="--color: var(--aurora-c3); animation-delay: -17.14s"></span>
  </span>
  <span class="orbit" style="left: 38.37%; top: 69.65%; --orbit: 11.46cqmax; animation-duration: 20s; animation-delay: -0.89s; animation-direction: reverse">
    <span class="blob" style="--color: var(--aurora-c0); animation-delay: -22.86s"></span>
  </span>
</div>
@property --aurora-c0 { syntax: '<color>'; inherits: true; initial-value: transparent; }
@property --aurora-c1 { syntax: '<color>'; inherits: true; initial-value: transparent; }
@property --aurora-c2 { syntax: '<color>'; inherits: true; initial-value: transparent; }
@property --aurora-c3 { syntax: '<color>'; inherits: true; initial-value: transparent; }

.aurora {
  container-type: size;
  position: relative;
  isolation: isolate;
  overflow: hidden;
  background: #0b0820;
  --aurora-c0: #7c3aed;
  --aurora-c1: #06b6d4;
  --aurora-c2: #ec4899;
  --aurora-c3: #22c55e;
  animation: drift 30s ease-in-out infinite alternate;
}

/* the outer span orbits... */
.orbit {
  position: absolute;
  width: 60cqmax;
  aspect-ratio: 1;
  translate: -50% -50%;
  /* rotating around a point to the right moves the blob on a circle */
  transform-origin: calc(50% + var(--orbit)) 50%;
  animation: orbit linear infinite;
}

/* ...the inner one is the soft circle, and breathes */
.blob {
  display: block;
  height: 100%;
  border-radius: 50%;
  background: radial-gradient(closest-side, var(--color), transparent);
  filter: blur(40px);
  animation: pulse 10s ease-in-out infinite alternate;
}

@keyframes orbit { to { rotate: 360deg; } }
@keyframes pulse { from { scale: 0.8; } to { scale: 1.05; } }

/* every color fades towards the next one in the palette */
@keyframes drift {
  to {
    --aurora-c0: #06b6d4;
    --aurora-c1: #ec4899;
    --aurora-c2: #22c55e;
    --aurora-c3: #7c3aed;
  }
}

@media (prefers-reduced-motion: reduce) {
  .aurora, .orbit, .blob { animation: none; }
}

4. Blur or pure gradient?

A radial gradient that fades to transparent is already soft, so the mesh needs no blur at all. That is the cheapest variant: no filter, just a gradient repaint. Blur adds a different softness: it smears the blobs into each other where they overlap and rounds off the hard ring a gradient can show at its edge. The price is a filter pass over the whole layer, and its cost grows with the blur radius. To keep the blurred edges from fading to the background, the mesh layer overscans the box by twice the blur radius.

The blobs technique blurs each blob separately. That is more filter passes, but each one is small, and a blurred layer only has to be redrawn when its content changes, not when it moves. The circles fade out with radial-gradient(closest-side, color, transparent) rather than being solid, so overlapping blobs mix instead of covering each other.

5. Banding, and why grain fixes it

Screens usually show 8 bits per color channel: 256 steps from dark to light. A gradient between two similar colors across a large area might only span 20 of those steps, so each step becomes a visible band, dozens of pixels wide. Our eyes exaggerate the edges between bands (Mach bands), which makes it look worse than the numbers suggest. Dark, low-contrast gradients like an aurora on a near-black background are the worst case.

A faint noise layer dithers the bands away: it nudges neighboring pixels up and down by a step or two, so the boundary between two bands dissolves into a fine, even texture. The grain here is the noise component at low opacity, blended with overlay.

Motion and accessibility

  • The animation stops completely when the user prefers reduced motion, and the Pause animations button freezes it (WCAG 2.2.2). The button sets data-motion="paused" on the stage, and a global rule sets animation-play-state: paused inside it.
  • Nothing flashes. Even at speed 3 the fastest blob takes about 7 seconds per orbit and colors fade over tens of seconds: changes are gradual, far from three flashes per second.
  • The art is aria-hidden. Text on top gets its own opaque surface: the contrast of text drawn straight onto moving colors can't be guaranteed.

Browser support

Every feature used here is in all current engines: @property, the trig functions cos() and sin(), container query units and relative color syntax (oklch(from … l calc(c * 1.2) h), used for Saturation). It was tested in Chromium 155 only; Firefox and Safari are untested. A browser without @property would treat the properties as unregistered: in the mesh technique the blobs would jump instead of drift, and color drift would step from one color to the next in both techniques. The orbits of the blobs technique animate rotate, a regular property, so they would still move smoothly.

Performance notes

  • Large blurs are expensive. Blur cost grows with the radius and the blurred area. Twelve blobs at 120px blur over a full-screen area can keep a laptop GPU busy; four or five blobs are plenty.
  • Transforms are cheap, repaints are not. The blobs technique animates rotate and scale, which the compositor applies to already rendered layers. The mesh technique changes the gradient itself, so it is repainted every frame. On a big screen that is more work, even though it is a single element.
  • Animated colors repaint in both techniques. Color drift changes an inherited registered property on every frame, which means a style update and a repaint of every blob. Turn it off for the cheapest motion.
  • Pausing (or reduced motion) costs nothing: a paused animation doesn't repaint.

Back to the demo ↑