Writing

← writing

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:

  1. The circles look like liquid. They're still circles. A filter fakes the melting.
  2. 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_out hits 1 when A = 11/28 ≈ 0.393, so anything above that is fully solid
  • A_out hits 0 when A = 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

stage
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:

  • stdDeviation is 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 / scale wide. 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

animate
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:

layerxyx round tripy round triplayer repeats every
primary9s6s18s12s36s
pair7s5s14s10s70s

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-screen adds light where the layers overlap, so a third colour shows up in the crossings that isn't in either layer.
  • isolate on the container. Blend modes look for the nearest stacking context. Without isolate the 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

palette
blend
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.

miguel de mora
xgithublinkedin