Liquid Spheres SVGs
Three circles, one SVG filter and some nested CSS animations. How the liquid blobs on my lab page work, with every number turned into a slider.
There's a box on my lab page with coloured blobs floating around, merging into each other and pulling apart again. Most people assume it's canvas. It's actually three divs, one SVG filter and about sixty lines of CSS. No JavaScript runs while it animates.
Two things are going on:
- The circles look like liquid. They're still circles. A filter fakes the melting.
- The motion looks random. It's a loop. It's just long enough that you stop watching before it comes back around.
Every demo below is live, so drag the sliders. Breaking a number is the fastest way to understand what it does.
Part 1 — melting circles with a filter
An SVG filter is a little pipeline. Each step takes an input (in), does one thing,
and passes its output to the next step under a name (result). The browser runs it on
the rendered pixels of whatever you point it at, every frame.
The goo filter is three steps. Here it is in full, straight from liquid-animation.tsx:
<filter id={filterId}>
<feGaussianBlur in="SourceGraphic" stdDeviation="18" result="blur" />
<feColorMatrix
in="blur"
mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 28 -10"
result="goo"
/>
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
</filter>
Step 1: blur turns edges into ramps
feGaussianBlur smears each circle. The interesting part isn't that it looks softer,
it's what happens to the alpha channel. A circle that used to go from alpha 1 to
alpha 0 in a single pixel now fades out over ~50px instead.
Put two blurred circles near each other and their fades overlap. In that overlap the alpha values add up. Halfway between two dots you might get 0.2 from the left one and 0.25 from the right one, so 0.45 in a spot where neither circle on its own got above 0.3. That sum is what the next step is looking for.
Step 2: the colour matrix is doing alpha maths, not colour
feColorMatrix looks worse than it is. It's a 4×5 matrix where each row builds one
output channel out of the four input channels plus a constant. The first three rows
here don't change anything: red stays red, green stays green, blue stays blue. Only the
alpha row matters:
row 1 1 0 0 0 0 R_out = R
row 2 0 1 0 0 0 G_out = G
row 3 0 0 1 0 0 B_out = B
row 4 0 0 0 28 -10 A_out = 28·A − 10
So A_out = 28A − 10, clamped to 0–1. Solve both ends:
A_outhits 1 whenA = 11/28 ≈ 0.393, so anything above that is fully solidA_outhits 0 whenA = 10/28 ≈ 0.357, so anything below that is fully gone
The gradient the blur just made gets squashed back into a hard edge. Only a 3.6% sliver of alpha values survives as a soft transition, so the circle gets its edge back. And that summed alpha between two dots clears the bar, so the gap fills in with a curved neck instead of showing two separate outlines.
That neck is the whole effect. Blur so the alpha spills, then threshold so the spill snaps into a shape.
Lab 1 — the filter, one primitive at a time
- stdDeviation
- 18
- alpha
- 28a − 10
- solid above
- 0.393
- gone below
- 0.357
Two hard-edged circles. Nothing touches, nothing melts.
Step 3: put the sharp original back on top
After the threshold the shape is right, but the inside of it went through a blur, so any
detail is mush. feComposite with operator="atop" paints the original graphic
over the goo, but only where the goo already exists.
In practice: sharp centres, gooey seams. If your dots are flat colour you won't really notice. If they have gradients, text or images in them, this line is what saves them.
Here's the actual playground. Three numbers and one gap:
Lab 2 — blur, threshold, gap
alpha in → alpha out
Shaded strip = the only input alphas that produce a soft edge. Narrow strip, hard edge.
- solid above
- 0.393
- gone below
- 0.357
- soft band
- 3.6%
<feGaussianBlur in="SourceGraphic" stdDeviation="18" result="blur" /> <feColorMatrix in="blur" mode="matrix" result="goo" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 28 -10" /> <feComposite in="SourceGraphic" in2="goo" operator="atop" />
Try it: blur 4 and the two dots stop reaching for each other. Scale 4 and the threshold turns back into a gradient — smoke, not liquid. Offset above the scale and everything falls under the cut, so the layer disappears.
Stuff worth messing with:
stdDeviationis the reach. How far a dot can feel another dot. Small blur means no merging, the dots have to physically overlap to do anything.- The alpha scale is the hardness. The soft band is exactly
1 / scalewide. At 28 that's 3.6% of the range and looks like liquid. At 4 it's 25% and looks like smoke. - The offset picks where the cut lands. Raise it and the shapes shrink and the necks snap. Lower it and everything bloats into one mass. Push it past the scale and the layer disappears, because nothing can clear the bar anymore.
Three things that will bite you
Filter ids are global. url(#goo) resolves against the whole document, not your
component. Render the same component twice with a hardcoded id and one instance quietly
steals the other's filter. That's why the real component takes filterId as a prop, and
the labs on this page generate theirs with useId.
The filter region clips. By default a filter's region is the element's box plus 10%
on each side. Turn the blur up and the goo hits an invisible wall. Fix it on the
<filter> element:
<filter id={filterId} x="-30%" y="-30%" width="160%" height="160%">
Filters interpolate in linearRGB by default. That's the SVG spec, not a browser
being weird. It only affects the colour maths, not the alpha maths, so the goo shape is
the same either way, but two overlapping hues can blend darker than you'd expect. If the
colour looks wrong and the shape looks fine, add color-interpolation-filters: sRGB.
Part 2 — motion from two clocks
The filter makes it liquid. The motion makes it feel alive, and it's dumber than it looks.
Each dot sits in the centre, pulled back by half its own size:
.ball {
position: absolute;
top: 50%;
left: 50%;
width: var(--ball-size);
height: var(--ball-size);
margin-top: calc(var(--ball-size) / -2);
margin-left: calc(var(--ball-size) / -2);
}
Everything after that is transform, so the compositor handles it and the browser never
reflows the page.
The nesting trick
I wanted horizontal drift on one clock and vertical drift on another, so a dot doesn't
just slide back and forth along the same line. Problem: both are transform. On a
single element the second animation wins and the first one is silently dropped.
So the animations go on two nested elements. The wrapper does X, its ::before does
Y.
.ball {
animation: x-axis var(--x-axis-duration) infinite alternate ease-in-out;
}
.ball::before {
animation: y-axis var(--y-axis-duration) infinite alternate ease-in-out;
}
The child inherits the parent's transform, so the dot's real position is two independent oscillators multiplied together, which gives you a Lissajous figure. Pull the two durations apart and watch the path change:
Lab 3 — one dot, two clocks
- x round trip
- 18s
- y round trip
- 12s
- path repeats every
- 36s
Both animations write to transform. On one element the second declaration would win, so the X drift lives on the wrapper and the Y drift on its ::before. Set the two durations equal and the path collapses into a straight diagonal — the whole effect is in the mismatch.
Set both to the same value and the path collapses into a straight diagonal. All the wandering comes from the mismatch.
alternate and negative delays
Two keywords doing a lot of work here.
alternate plays the animation forwards, then backwards, so the dot eases into a turn
instead of teleporting back to the start. It also doubles the real cycle: a 9s
animation is an 18s round trip.
A negative animation-delay starts an animation already in progress. -0.4 × duration
means "start 40% of the way in".
.ball:nth-child(1) {
animation-delay: calc(var(--x-axis-duration) * -0.4);
}
.ball:nth-child(1)::before {
animation-delay: calc(var(--y-axis-duration) * -0.15);
}
Without those offsets all three dots reach their extremes on the same frame and the whole thing breathes in and out like one lung. With them, one dot is arriving while another is leaving. Costs nothing, and it's most of the reason it reads as a system instead of three copies of the same animation.
Why it feels like it never repeats
It does repeat. Here's the arithmetic on the real values:
| layer | x | y | x round trip | y round trip | layer repeats every |
|---|---|---|---|---|---|
| primary | 9s | 6s | 18s | 12s | 36s |
| pair | 7s | 5s | 14s | 10s | 70s |
Each layer comes back into phase at the least common multiple of its two round trips.
Both layers line up again at lcm(36, 70) = 1260 seconds, so 21 minutes.
Nobody is watching a background for 21 minutes. Pick durations that don't share many factors and a short loop passes for endless.
Part 3 — colour, layering, chance
The dots use background: currentColor, so their colour comes from whatever color the
wrapper inherits. One Tailwind text token re-skins the whole thing without touching the
filter.
The lab page stacks two instances:
<LiquidAnimation filterId="lab-liquid-primary" color={accent.wave}
xDuration={9} yDuration={6} className="opacity-80" />
<LiquidAnimation filterId="lab-liquid-pair" color={pairAccent.wave}
xDuration={7} yDuration={5} className="opacity-55 mix-blend-screen" />
Three details make that work:
- Different durations per layer, which is where the 21 minutes comes from.
mix-blend-screenadds light where the layers overlap, so a third colour shows up in the crossings that isn't in either layer.isolateon the container. Blend modes look for the nearest stacking context. Withoutisolatethe layer blends against the page behind it and drags the rest of the site into the effect.
The colour is picked on the server per request: one random accent out of eight, plus its opposite in the array as the pair. Same code, different mood on every load.
Everything together, with knobs:
Lab 4 — the whole field
- layer A
- 9s / 6s
- layer B
- 7s / 5s
- dots on screen
- 6
Drag phase spread to 0: every dot starts on the same frame, the field turns into one pulsing blob and the illusion dies. That single negative animation-delay is doing more work than any other number here.
Part 4 — making it behave
This is decoration, and decoration still has to behave.
It's invisible to assistive tech and to the pointer. aria-hidden="true",
pointer-events: none, user-select: none. Nobody should be tabbing into it, hearing
it announced, or accidentally selecting it while dragging.
It stops for people who asked it to stop. prefers-reduced-motion doesn't just
freeze the animation, because that would leave three dots stacked in the middle. It
kills the animation and poses the dots somewhere deliberate:
@media (prefers-reduced-motion: reduce) {
.ball,
.ball::before {
animation: none;
}
.ball:nth-child(1) {
transform: translate(-4rem, 2rem);
}
.ball:nth-child(3) {
transform: translate(4rem, -2rem);
}
}
Reduced motion should get a still frame that looks intentional, not the broken version of the animated one.
It only animates transforms, with will-change: transform on both animated
elements, so the work stays on the compositor and never triggers layout.
One honest caveat: the filter isn't free. The browser rasterises the layer, blurs it and runs the colour matrix every frame. Keep the filtered area to a contained box rather than a full-page background, keep the dot count low, and test on a mid-range phone before you ship. Two layers of three dots in a 4:3 box is cheap. The same effect behind your entire site is not.
What to steal
The bits that generalise past this one effect:
- Blur plus an alpha threshold is a metaball generator. Works on any shapes, not just circles. Works on text.
- Nest elements to stack transforms. Cheapest way to run independent animations on the same visual object without a JS loop.
- Offset phases with negative
animation-delay. One property turns copies into a system. - Pick durations with an awkward ratio. Long apparent loops for free.
- Drive colour with
currentColor. One token re-skins the whole thing.
The source is on the lab page: liquid-animation.tsx,
liquid-animation.module.css, liquid-field.tsx. About a hundred lines with comments,
and not a single requestAnimationFrame.