When I started building my portfolio site, I knew I wanted a dynamic background. Something that felt alive without competing with the content. The obvious approaches didn’t appeal to me — animated HTML elements tend to feel clunky and artificial, and looping a prerecorded video introduces performance problems, especially on mobile where battery life and thermal throttling actually matter.
I’d been staring at the Apple Watch mindfulness animation — that slow, organic breathing of colour — and something about the contour lines it produces reminded me of topographical maps. The layered ridgelines, the way elevation data gets sliced into discrete bands that somehow convey a continuous landscape. I kept coming back to the idea: what if I could build something that had that topographic quality, but moved like a lava lamp?
That question sent me down a rabbit hole of shader programming, noise functions, and colour theory. This post is the result. I want to walk through how this animation works from first principles, building up one concept at a time. Each section has an interactive demo you can play with, because I genuinely believe you can’t develop intuition for this stuff just by reading about it — you need to poke at it and watch what happens.
I’m aiming this at the somewhat technical reader. If you’ve written code but never touched a shader, you’re the target audience. I’ll explain the maths in plain English where I can and trust you to follow along where I can’t simplify further.
1. Noise: The Foundation of Everything Organic
The first problem: computers are deterministic, but nature isn’t. If you want anything on screen to look organic — clouds, fire, terrain, flowing liquid — you need controlled randomness. Not Math.random() randomness, which gives you TV static. You need randomness that flows smoothly from one point to the next, like altitude across a hillside.
That’s what Perlin noise gives you. Ken Perlin invented it in 1983 while working on Tron because he was frustrated that computer graphics looked too synthetic. The idea is surprisingly simple: place random gradient vectors on a grid, then for any point in space, smoothly interpolate between the gradients of the surrounding grid cells. What falls out is a continuous, smooth, pseudorandom signal that looks surprisingly natural.
Here’s the function that generates those random gradients:
vec2 hash22(vec2 p) {
p = vec2(dot(p, vec2(127.1, 311.7)),
dot(p, vec2(269.5, 183.3)));
return -1.0 + 2.0 * fract(sin(p) * 43758.5453);
} This takes a grid coordinate and spits out a pseudorandom 2D direction. The magic numbers (127.1, 311.7, etc.) are arbitrary — they just need to be irrational enough that you don’t see repeating patterns. The sin and fract combo is a classic GPU trick for making hash values without bitwise operations, which older graphics hardware didn’t support.
One detail that matters more than you’d expect: the interpolation between grid points uses a quintic curve — f * f * f * (f * (f * 6.0 - 15.0) + 10.0) — instead of a straight linear blend. A linear blend produces visible grid artefacts where you can see the underlying square cells. The quintic curve has zero first and second derivatives at the grid boundaries, which makes the transitions imperceptibly smooth. It’s the difference between “this looks procedural” and “this looks natural.”
2. Fractal Brownian Motion: Layering Detail
A single layer of Perlin noise looks like gently rolling hills. Which is fine, but nature is more complicated — a real hillside has large-scale slopes, medium ridges, small rocks, and tiny pebbles. Every “zoom level” has its own detail.
Fractal Brownian Motion (fBM) recreates this by sampling the noise function multiple times at increasing frequencies and decreasing amplitudes, then summing the results. Each layer is called an “octave” — each one is roughly double the frequency of the last, like musical octaves.
float fbm(vec2 p, int octaves) {
float value = 0.0, amplitude = 0.55, frequency = 1.0;
for (int i = 0; i < octaves; i++) {
value += amplitude * perlin(p * frequency);
amplitude *= 0.42;
frequency *= 1.8;
}
return value;
} Two numbers control the character of the output. Persistence (0.42 here) sets how much each successive octave contributes — lower values give smoother results, higher values make fine detail more prominent. Lacunarity (1.8) controls how fast we zoom in between octaves. The textbook values are 0.5 and 2.0, but I’ve tweaked both. Persistence at 0.42 softens the high-frequency detail because I want a liquid feel, not rocky terrain. Lacunarity at 1.8 slightly compresses the octave spacing to keep the noise from looking too obviously fractal.
These kinds of small parameter tweaks are where the creative work actually lives. The maths gives you a framework, but the specific values determine whether the result looks like water, marble, or clouds.
3. Metaballs: Soft Organic Shapes
Noise gives us texture, but the lava lamp effect needs distinct blobs — regions of concentrated colour that merge and separate organically. This is where metaballs come in, and they’re one of those techniques that sounds complicated but is almost embarrassingly simple once you see how it works.
Place a point in space. Define a “field” around it that’s strongest at the centre and falls off smoothly to zero at some radius. Now do that for several points. At any pixel on screen, add up all the field contributions. Where the total is high, you’re “inside” a blob. Where it’s low, you’re in the background. When two blobs get close enough that their fields overlap, the values add together and they merge. Pull them apart and they separate. That’s the entire concept.
The falloff function matters though. A straight linear falloff produces hard-edged blobs that merge awkwardly. Instead, I use a smoothstep curve:
float inf = max(0.0, 1.0 - d / radius);
float blob = inf * inf * (3.0 - 2.0 * inf) * strength; That inf * inf * (3.0 - 2.0 * inf) is the Hermite smoothstep polynomial — it’s 1.0 at the centre, 0.0 at the boundary, and has smooth derivatives throughout. When two of these fields overlap, their contributions blend seamlessly. It’s what gives the “liquid merging” look.
To make them move naturally, each metaball drifts on slow sinusoidal paths with slightly different frequencies:
bx = 0.3 + 0.18 * sin(t * 0.13 + 1.0);
by = 0.35 + 0.15 * cos(t * 0.11 + 0.5); The trick to natural-looking motion is using irrational frequency ratios. If two blobs oscillate at frequencies 0.13 and 0.09, the ratio is irrational, so they’ll never perfectly synchronise. The combined motion has such a long period before it visually repeats that it effectively never does.
4. The Combined Field: Noise + Metaballs
Now we combine both ingredients. The fBM noise provides a constantly-shifting organic texture across the whole field, and the metaballs create large-scale structure — bright concentrated regions that drift through the noise like wax through oil.
float v = fbm(n * 2.8 + vec2(ts, ts * 0.7), 1.0, 2);
v += fbm(n * 4.5 + vec2(-ts * 0.5, ts * 0.4), 2.0, 2) * 0.22;
// ... then add each metaball's contribution to v There are two fBM layers here, and the details are deliberate. They use different seeds (1.0 and 2.0), different spatial scales (2.8 and 4.5), and drift in different directions. The second layer is much quieter (scaled by 0.22) and adds subtle secondary turbulence. Without it, the noise texture looks too uniform, like wallpaper. With it, there’s a sense of depth — a primary flow with smaller eddies churning underneath.
The metaballs then add their field values on top. Because fBM ranges from roughly -0.5 to 0.5, and each metaball adds up to ~0.55, the combined field spans roughly -0.5 to 2.0+. We’ll deal with clamping that range in the next step.
5. The Palette: From Abyss to Ember
Colour transforms everything. The same greyscale field can look like water, fire, or alien terrain depending on how you map values to colours. For this effect I wanted something that evokes looking into a volcanic fissure — deep blackish-purple at the coldest values, climbing through magenta and hot coral, ending in molten amber at the peaks.
The palette is 15 hand-picked RGB stops:
#040208 → #0C0414 → #1C062A → #340A48 → #52106C →
#761890 → #982694 → #B6388E → #D04E80 → #E4666C →
#F28058 → #FA9C48 → #FFB23E → #FFC43A → #FFD244 I interpolate between stops with simple linear mixing in RGB space. Normally this is a bad idea — linear RGB interpolation can produce muddy midpoints, especially between saturated colours. But because I hand-tuned each stop to look right in sequence, with roughly even perceptual spacing, it works. Each transition was eyeballed rather than computed, which is a fancier way of saying I adjusted values until it stopped looking wrong.
If you’re building your own palette and the transitions look muddy, try interpolating in a perceptual colour space like Oklab instead. But honestly, hand-tuning 15 stops took less time than implementing Oklab in GLSL would have.
6. Banding: The Topographic Map Effect
This is the step that ties back to what originally inspired the whole thing — those contour lines on topographical maps and the ridges in the Apple Watch animation. Instead of mapping the field smoothly to the palette, we quantise it into discrete bands, like altitude contour lines slicing through a continuous landscape.
float bandF = shaped * BANDS; // e.g. shaped * 20.0
float band = floor(bandF); // integer band index
float frac = bandF - band; // position within band (0..1) Each band gets a flat colour from the palette. But hard edges between flat colours look cheap and aliased. The character of the effect lives in how the transitions between bands are handled, and I spent more time tweaking this than any other part.
Within each band, there are three zones:
Leading edge (0%–10% of the band): A smooth anti-aliased ramp from the previous band’s colour into this one. This eliminates the jagged stair-stepping you’d get from raw floor().
Flat body (10%–78%): Solid colour. This is what gives the topographic, layered look.
Trailing ramp (78%–100%): A gentle ease into the next band’s colour, using a squared smoothstep. This is the subtle one — it creates a sense that each band slopes gently “upward” toward the next, like the lip of a terrace. Without it, the bands look like flat stickers. With it, they feel like stacked geological layers.
On top of all that, a subtle shadow modulation darkens the leading edge of each band:
float shadow = 1.0 - 0.18 * (1.0 - frac); This is only an 18% darkening at most, but it reinforces the illusion of depth — each band feels like it sits slightly above the one below it. The topographic map effect clicked into place when I added this. Before the shadow, it looked like a colour-by-numbers. After, it looked like terrain.
7. Click Interaction: Spawning Blobs
This was the part where I thought: if this is going to be a website background, it should reward curiosity. A background that reacts to touch turns passive decoration into something people actually want to play with. Each click or tap spawns a temporary metaball at the pointer location — a warm pool of colour that swells into existence, lingers, and then dissolves back into the field.
The lifecycle uses a simple envelope:
float swellIn = smoothstep(0.0, 0.6, age);
float fadeOut = 1.0 - smoothstep(lifetime * 0.5, lifetime, age);
float envelope = swellIn * fadeOut; The swell-in takes 0.6 seconds — fast enough to feel responsive, slow enough to look like liquid rather than just popping into existence. The fade-out begins at the halfway point of the blob’s lifetime and eases to zero at the end. The blob grows from nothing, exists as a warm pool of colour for a few seconds, then dissolves back into the noise field as though the liquid absorbed it.
On the JavaScript side, it’s a simple ring buffer:
clicks.push({ x: nx, y: ny, birth: time, strength: clickStrength });
if (clicks.length > maxClicks) {
clicks = clicks.slice(clicks.length - maxClicks);
} I cap at 16 simultaneous clicks because GLSL loops over uniform arrays need compile-time-known bounds. In practice, 16 is more than enough — clicks decay naturally well before you can exhaust the pool unless you’re deliberately hammering the screen (which, honestly, is also fun).
8. Performance: Making It Actually Usable
Remember, this is a website background, not a tech demo. It needs to run smoothly on phones, not just gaming GPUs. A WebGL fragment shader runs per pixel, per frame — on a 1440p display at 2x DPI that’s over 7 million fragment invocations every 16 milliseconds. Every instruction counts.
Several choices keep it performant. The fBM uses only 2 octaves — enough for organic texture without the cost of 4 or 5 octave terrain-grade noise. The metaball count is fixed at 8 ambient plus up to 16 from clicks, and each uses a simple distance check rather than an expensive exponential falloff. The palette lookup is an if-else chain rather than a texture sampler — ugly in code, but avoids the overhead of texture binding for what’s essentially a 15-entry table.
DPI awareness matters too. The canvas scales to devicePixelRatio but caps at 2x. Beyond that the visual improvement is negligible but the fragment count doubles. For 3x displays on newer phones, this cuts GPU load in half with no perceptible quality loss. That tradeoff is the difference between the phone getting warm and the phone staying cool.
The animation also has a startup envelope — rather than snapping to a steady pace on load, the time value accelerates through the first few seconds using an ease-in-out curve, then settles to cruising speed. The first thing you see is a burst of organic motion that relaxes into a gentle drift. It’s a small detail that makes the page load feel intentional rather than abrupt.
9. The Full Picture
The final fragment shader is around 200 lines of GLSL, and it assembles every layer we’ve walked through:
- Sample two fBM noise layers at different scales and drift directions
- Sum eight metaball field contributions, each on its own sinusoidal orbit
- Add any active click-spawned blob contributions
- Remap the combined field value into a 0–1 range
- Quantise into discrete bands
- Look up palette colours with anti-aliased transitions between bands
- Apply per-band shadow for depth
- Output with configurable opacity for layering over page content
Every parameter — band count, blob radii, drift speeds, noise scales, palette colours, click lifetime — is exposed as a prop on the Svelte component. The shader source is templated at mount time with these values baked in as GLSL constants, which the compiler can optimise better than runtime uniforms.
What I find most satisfying about shader work is that you’re not drawing a picture — you’re defining rules for a picture, and then watching the rules draw themselves, frame after frame, forever. None of the individual techniques here are new. Perlin noise is from 1983. Metaballs are from the early ’80s. Colour banding is literally how topographic maps have worked for centuries. The creative work isn’t in inventing new maths. It’s in the specific combination — the noise frequencies, the drift speeds, the transition widths, the palette — that makes this feel like a lava lamp rather than a weather map.
Change the palette to blues and whites and it becomes ocean currents. Crank the band count to 50 and it smooths into something like an oil painting. Drop the metaball strength to zero and the noise alone looks like satellite imagery of Jupiter. The same mathematical skeleton supports a huge range of aesthetics, and once you’ve built one shader from scratch, you start seeing parameters to tweak everywhere.
I went into this wanting a website background. I came out with a much better intuition for how simple rules compose into complex-looking systems. That’s probably the real takeaway — not the specific shader, but the process of building something organic out of deterministic maths, one layer at a time.