A background animation runs all the time and asks for nothing. That is the difficulty with it: a scroll or hover effect gets a moment of attention and then leaves, but a backdrop that never stops has to stay quiet enough that the headline on top of it still wins. Here is a basic version that does: three soft colour fields drifting slowly behind a hero, built with GSAP, that stop when they scroll out of view and render as a still for anyone who has asked for reduced motion.
What you’ll build
A full-height hero with three large, soft-edged colour fields wandering behind the content. Each field picks a new random destination every time it arrives at the last one, so the motion never visibly loops. The fields pause when the hero leaves the viewport, and under prefers-reduced-motion they sit still at their starting positions, which still reads as a designed backdrop rather than a blank one.
No canvas, no WebGL, no images and no plugins: the GSAP core is enough.
Step 1: Add GSAP
Add the GSAP core before your closing </body> tag. No plugins are needed for this one:
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
Step 2: Add the markup
The hero wraps two layers: the field of colour behind, and your content in front.
<section class="ambient">
<div class="ambient-field" aria-hidden="true">
<span class="ambient-blob ambient-blob-1"></span>
<span class="ambient-blob ambient-blob-2"></span>
<span class="ambient-blob ambient-blob-3"></span>
</div>
<div class="ambient-content">
<h1>Light that never sits still</h1>
<p>Your lede goes here.</p>
</div>
</section>
The field is aria-hidden because it is decoration; a screen reader has nothing to gain from three empty spans.
Step 3: Add the CSS
The fields are radial gradients that fade to transparent at their edge. That gives the soft, out-of-focus look without a filter: blur(), which matters more than it sounds (see the performance note below).
.ambient {
position: relative;
overflow: hidden;
min-height: 100vh;
display: grid;
place-items: center;
background: #0b0b12;
color: #f4f4f8;
}
/* Overscan the field so a blob can drift past the edge without showing its rim */
.ambient-field {
position: absolute;
inset: -20%;
z-index: 0;
}
.ambient-blob {
position: absolute;
width: 55vmax;
height: 55vmax;
border-radius: 50%;
opacity: 0.55;
background: radial-gradient(circle at center, var(--blob-color) 0%, transparent 65%);
will-change: transform;
}
.ambient-blob-1 { --blob-color: #7c3aed; top: 5%; left: 0; }
.ambient-blob-2 { --blob-color: #06b6d4; top: 30%; right: 0; }
.ambient-blob-3 { --blob-color: #ec4899; bottom: 0; left: 25%; }
.ambient-content {
position: relative;
z-index: 1;
max-width: 40rem;
padding: 2rem;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.ambient-blob { will-change: auto; }
}
Three things are doing quiet work here. The overflow: hidden on the section and the inset: -20% on the field mean a blob can drift well past the section’s edge without its circular rim ever showing. The opacity: 0.55 is what keeps the headline readable: the fields are bright, but never fully opaque over the dark ground. And will-change: transform promotes the three blobs to their own layers up front, so the first frame of motion does not pay for the promotion.
Step 4: Add the animation
Add this after the GSAP script:
document.addEventListener('DOMContentLoaded', () => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const section = document.querySelector('.ambient');
const blobs = gsap.utils.toArray('.ambient-blob');
// Each blob wanders to a random offset, then picks a fresh one.
// repeatRefresh re-rolls every random() value on each repeat, so the
// path never repeats and the tween never snaps back to where it began.
blobs.forEach((blob) => {
gsap.to(blob, {
xPercent: 'random(-30, 30)',
yPercent: 'random(-30, 30)',
scale: 'random(0.85, 1.2)',
duration: () => gsap.utils.random(9, 16),
ease: 'sine.inOut',
repeat: -1,
repeatRefresh: true,
});
});
// A backdrop nobody can see should not be spending frames.
const observer = new IntersectionObserver(([entry]) => {
gsap.getTweensOf(blobs).forEach((tween) => {
entry.isIntersecting ? tween.play() : tween.pause();
});
});
observer.observe(section);
});
The 'random(-30, 30)' strings are GSAP’s own syntax: the value is rolled when the tween records its targets, and repeatRefresh: true rolls it again at every repeat. Because each repeat starts from wherever the blob currently is, the motion is continuous. The duration is a function for the same reason: a fixed duration would give all three blobs the same rhythm, and the eye picks that up as a loop within a few cycles.
GSAP already stops its ticker in a hidden tab. The IntersectionObserver covers the other case, a hero that has scrolled off the top of a long page while the visitor reads below it.
That’s it
Load the page and the three fields drift behind your headline, each on its own timing. Scroll the hero out of view and they stop; scroll back and they resume from where they were. Under reduced motion, nothing moves and the composition still holds.
Making it yours
- Colours. Change the three
--blob-colorvalues. Two or three hues from one side of the wheel read as atmosphere; three from opposite sides read as a screensaver. - Speed. The
durationrange is the whole mood. Nine to sixteen seconds is calm; halve it and the field starts to feel busy. - Range.
xPercentandyPercentat ±30 keep every blob near its home. Widen them and the blobs cross paths more, which is livelier and also more likely to pool two bright colours under your text at once. - A light ground. Swap the
backgroundto near-white and drop the blobopacityto around 0.35. Saturated fields over white need less strength to read.
A note on performance
The tempting way to build this is filter: blur(80px) on solid discs. It looks the same and it is a trap: a blur filter is re-rasterised every frame the element moves, and on a 55vmax element that is a large part of the screen, three times over. On a mid-range phone that alone can hold the page under 30fps. The radial gradient with a transparent edge gives the same soft look and the browser paints it once, then only composites the transform each frame.
The same logic rules out mix-blend-mode on the blobs for the basic version. It makes overlapping colours bloom nicely, but it forces the browser to re-composite everything beneath them on every frame. If you want that look, it belongs in a version that measures the cost, which is what the effects below do.
Want more control?
This basic version gets you a drifting backdrop. The Aurora Gradient Field is the finished version of the same idea, and it adds the parts that take the longest to get right:
- A light that follows the pointer: a compact bright bloom and a wider, slower halo, bound to the window so the content on top never swallows the movement
- Two gradient layers that lean toward the pointer by different amounts and in opposite directions, for depth without a second asset
- A slow conic sweep behind the colour stops, which is what stops a field reading as a flat wash
- Local contrast treatment: a feathered band of shade behind the reading column, so body text holds AA while the field stays vivid in the margins
- Four palettes as CSS hue tokens, with a dip-through cross-fade when they change
- A grain layer that kills the banding large gradients suffer from on 8-bit displays
If you want distinct shapes rather than a wash, the Ambient Orb Field keeps six luminous orbs visibly separate as they drift and lean, and for a denser, ember-toned look the Canvas Particle Flow moves the whole thing to a single canvas.
View the full effect with all options →
More of this kind in the GSAP background animations collection.