Steer a blob with the pointer; click or tap to drop one

Use the arrow keys to move the blob, hold Shift for bigger steps. Drop leaves a copy where it is.

How it works

Goo from a blur and a threshold

Blur shapes until their soft edges overlap, then cut the blurry alpha back into a hard edge. Where two halos overlapped, the cut runs around both: they melt together.

The idea: metaballs, cheaply

Metaballs are a classic computer graphics trick: every ball radiates a field that fades with distance, the fields of all balls are added up, and the surface is drawn wherever the sum crosses a threshold. Two balls far apart are two circles; two close ones share a bridge where their fields add up past the threshold.

An SVG filter can fake that with two primitives. A Gaussian blur turns every shape's alpha into a soft field (and where halos overlap, their alpha adds up). A color matrix then turns that soft alpha into a hard edge again. No canvas and no JavaScript; the shapes are ordinary elements with border-radius: 50%, moved by CSS animations.

1. Blur: feGaussianBlur

<feGaussianBlur stdDeviation="12"> spreads every pixel into its neighbors. stdDeviation (σ) is in px; the blur is visible up to roughly 3σ, here 36 px, beyond the edge of each shape. So two shapes start to merge once their edges come closer than about twice that. The blur also softens the colors, which is why the last step can bring the sharp ones back.

2. Threshold: the alpha row of feColorMatrix

<feColorMatrix type="matrix"> multiplies every pixel's [R G B A 1] with a 4×5 matrix. The first three rows here are the identity, so color passes through. The fourth row, 0 0 0 k −b, computes the new alpha:

α′ = clamp(k · α − b, 0, 1)

Values are in the 0…1 range, not 0…255. With k = 20 and b = 9, every pixel whose blurred alpha is below 0.45 becomes fully transparent, everything above 0.5 fully opaque, and only the narrow band between them (1/k wide) is the anti-aliased edge. That's the whole trick:

k (sharpness)
How steep the cut is. A large k gives crisp edges; around 4 the shapes keep a soft, glowing rim. Too large and the edges start to look aliased (stair-stepped).
b (offset)
Where the cut sits: around α = b / k. A blurred edge of a large shape passes α = 0.5 roughly where the original edge was, so b ≈ k / 2 keeps shapes at their original size. Lower values make them fatter and stickier, higher values thinner. Small shapes lose more alpha to the blur, so they shrink or vanish first.
<svg width="0" height="0" aria-hidden="true">
  <filter id="goo" x="-20%" y="-20%" width="140%" height="140%"
          color-interpolation-filters="sRGB">
    <feGaussianBlur in="SourceGraphic" stdDeviation="12" result="blur" />
    <feColorMatrix in="blur" type="matrix" result="goo" values="
      1 0 0 0 0
      0 1 0 0 0
      0 0 1 0 0
      0 0 0 20 -9
    " />
    <feComposite in="SourceGraphic" in2="goo" operator="atop" />
  </filter>
</svg>

color-interpolation-filters="sRGB" keeps the blurred colors from being mixed in linear light, which would make the bridges between two colors look lighter than either.

3. Keep the colors: feComposite operator="atop"

The thresholded result has the right shape, but its colors are blurred: two differently colored blobs leave a smeared mix behind. <feComposite in="SourceGraphic" in2="goo" operator="atop"> draws the original, unblurred shapes back on top, but only where the goo is opaque. Inside the shapes you see crisp source colors; the bridges, where the source is transparent, keep the blurred goo color. Toggle Keep source colors to compare.

4. Apply it: filter: url(#id)

The filter goes on the container of the shapes, not on each shape: the blur has to see all of them together for their halos to add up. The <svg> holding the <filter> can be zero-sized, but it must not be display: none, which makes some browsers (Firefox) ignore the filter.

.goo {
  filter: url(#goo);
}

.goo > .blob {
  position: absolute;
  inline-size: 120px;
  aspect-ratio: 1;
  border-radius: 50%;
  background: #ff4f9a;
}

Why the filter region matters

A filter only renders inside its filter region, which by default is the bounding box plus 10% on each side (x="-10%" y="-10%" width="120%" height="120%"). Everything outside is cut off with a straight line. For a filter on an HTML element, that bounding box covers the element and its overflowing descendants (checked in Chromium and Firefox 156), so shapes that stick out of a small container, like the menu items here, are still inside it. What gets cut is the part of the goo that grows beyond the outermost shapes: the blur spreads up to 3σ further out, and with a low offset the threshold turns that spill into a fatter outline. Without room, the outermost shapes get flat sides.

So pad the region by at least the blur reach: percentages in x, y, width and height are relative to the box, so a small container needs a larger percentage than a big one. To see the cut, open the menu, set Filter region padding to 0 and lower the offset to about 4: the goo is sliced off flat along every edge of the region.

The menu: goo behind, buttons on top

The gooey menu is the same filter on a different set of shapes: a big circle for the toggle and small ones for the items, which slide out with a springy transition on translate and stay stuck to each other until they're far enough apart.

Don't put the filter on the buttons themselves. The threshold eats anything thin or semi-transparent: text anti-aliasing, icons, and the focus outline. So the colored circles live on an aria-hidden layer with the filter, and the real <button>s are transparent, unfiltered, and move in sync on top of them. The toggle has aria-expanded and aria-controls; closed items are inert, so they can't be reached by Tab, and Escape closes the menu and puts focus back on the toggle. The toggle comes first in the DOM, so Tab moves from it to the items. The icons are drawn in near-black or white, whichever has the higher contrast ratio against the circle's color: for the default violet that's white, by a hair (4.5:1 against 4.2:1).

<GooeyMenu {items} label="Quick actions" />

Accessibility

  • The blob field is decorative and aria-hidden. The drift is a CSS animation: it stops entirely for prefers-reduced-motion: reduce and can be paused on the page (WCAG 2.2.2). Nothing flashes; the shapes only move.
  • Steering with the pointer has two alternatives: the Move button takes arrow keys (Shift for bigger steps), and clicking or tapping the field drops a blob, so no drag or hover is needed. Without a pointer, the steered blob rests in the center (or wherever the keyboard put it).

Browser support

SVG filters as CSS filter: url(#id) on HTML elements work in all current engines, and the three primitives used here are among the oldest and most consistently implemented. The rendering was checked in Chromium and Firefox 156, the interaction only in Chromium; Safari is untested. Container query units (cqi/cqb) used for the drift paths need a browser from 2023 or later.

Cost: the browser has to re-run the filter for the whole container on every frame something inside it moves, and a Gaussian blur's cost grows with the area and with σ. A full-screen field like this one is the expensive end and can cost frames on low-end phones. Keep gooey areas small (buttons, loaders, small illustrations), keep σ moderate, and stop the animation when the element is off-screen.

Back to the demo ↑