← All articles
SVGReactBackground

How to build a metaballs (lava-lamp) background in React

An SVG gooey filter plus a few animated circles gives you a fluid, fusing blob background - no WebGL, no canvas, no dependency.

21 May 2026 · 7 min read

Metaballs are the lava-lamp effect: soft blobs that drift around and, when they get close, melt into each other instead of overlapping. People reach for WebGL or a canvas signed-distance shader to do this. You do not need either. A handful of SVG circles and a two-step filter give you the whole thing in plain markup, and it scales crisply because it is all vector.

The trick is a threshold on blur

The effect is two operations stacked. First you blur the shapes so their edges become soft gradients. Where two blurred circles are near each other, their faded edges add up and the gap between them fills with mid-range alpha. Second - and this is the part that does the work - you take that blurred result and apply a hard threshold to the alpha channel: anything above a cutoff becomes fully opaque, anything below becomes fully transparent.

The soft gradient inside a single circle snaps back to a crisp edge. But the filled-in alpha between two near circles also crosses the threshold, so it snaps to solid too. The result is that separate shapes appear to fuse into one organic blob. That is the entire metaball illusion: blur, then threshold.

Building the SVG filter

In SVG, blur is feGaussianBlur and the threshold is feColorMatrix in type="matrix" mode, operating on the alpha channel. Here is the filter and the shapes it applies to:

<svg width="100%" height="100%">
  <defs>
    <filter id="goo">
      {/* 1. Blur every shape in the group */}
      <feGaussianBlur in="SourceGraphic" stdDeviation="18" result="blur" />
      {/* 2. Snap soft alpha edges to hard ones.
            The last row multiplies alpha by 20 then
            subtracts 9 - a steep ramp that pushes
            mid-range alpha to either 0 or 1. */}
      <feColorMatrix
        in="blur"
        type="matrix"
        values="1 0 0 0 0
                0 1 0 0 0
                0 0 1 0 0
                0 0 0 20 -9"
      />
    </filter>
  </defs>

  {/* The filter applies to the whole group */}
  <g filter="url(#goo)">
    <circle cx="120" cy="160" r="60" fill="#7c3aed" />
    <circle cx="240" cy="190" r="70" fill="#7c3aed" />
    <circle cx="360" cy="150" r="55" fill="#7c3aed" />
  </g>
</svg>

The feColorMatrix values matrix is four rows of five numbers. The first three rows pass red, green, and blue through unchanged. The last row is the alpha row: 0 0 0 20 -9 means "new alpha = old alpha multiplied by 20, then minus 9". That steep slope means any pixel with low blurred alpha lands well below 0 (clamped to transparent) and any pixel with high alpha shoots well above 1 (clamped to opaque). The transition zone collapses to a thin, sharp edge. Tune stdDeviation for how far blobs reach toward each other, and the multiplier for how tight the edge is.

The mistake that makes it look frozen

Here is the failure almost everyone hits. You build the blobs as absolutely-positioned <div> elements, animate them with CSS or Framer Motion, and apply filter: url(#goo) to their container. It looks perfect on the first frame and then the fusing never updates - the blobs slide around but the gooey melting is stuck in its starting pose.

The reason is layer caching. When the browser applies a CSS filter: url()to an element, it rasterizes that element's subtree into a single bitmap, runs the filter on the bitmap, and caches the result. Moving a child with a transform just repositions the already-filtered cached layer. The filter does not re-run, so the threshold never re-evaluates the new gaps between blobs.

The fix is to animate inside the filtered SVG group. Move the circles by animating their cx and cyattributes - not a wrapper's transform. Because the geometry itself changes, the filter pipeline re-runs each frame and the fusing stays live:

<g filter="url(#goo)">
  {blobs.map((b) => (
    <circle key={b.id} r={b.r} fill="#7c3aed">
      {/* Native SMIL animation of the geometry,
          so the filter re-runs every frame */}
      <animate attributeName="cx" values={b.cxKeyframes}
        dur={b.duration} repeatCount="indefinite" />
      <animate attributeName="cy" values={b.cyKeyframes}
        dur={b.duration} repeatCount="indefinite" />
    </circle>
  ))}
</g>

If you prefer JavaScript-driven motion, the same rule holds: update the cx and cy attributes on the <circle> elements with React state or a Framer Motion motion.circle. The non-negotiable part is that the moving thing lives under the filter attribute, not under a CSS-filtered wrapper.

Finishing touches

Drop the SVG into a fixed, full-bleed container behind your content with a low z-index, give the circles a fill that matches your palette, and add a subtle background gradient under them for depth. Keep the circle count low - six to nine is plenty - because each one widens the area the filter has to process. For accessibility, pause the animation under @media (prefers-reduced-motion: reduce); a static blob field still looks good.

The metaballs background block ships this with the geometry animation already inside the filter. If you want a different fluid look, the aurora mesh background and liquid cursor use related techniques.

Want the whole pack?

Every component in this article ships in shadcn Motion Blocks - one file each, tunable in a live studio. €19 for all of them.

See pricing