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-gradientper blob, all stacked in a singlebackground-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 arotateanimation, 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 withcos()andsin().- <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. Theseinherit, 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 setsanimation-play-state: pausedinside 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
rotateandscale, 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.