Metaball Blobs
A gooey organic background — token-tinted blobs drift on seeded orbits and fuse into one body through an SVG alpha-threshold filter, optionally leaning toward the pointer.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/metaball-blobs.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "MetaballBlobs" component — a decorative
gooey background wrapper. Its only dependency is a cn() class merger (clsx +
tailwind-merge). It uses hooks, a rAF loop and pointer events, so it is a client
component ("use client").
Contract
- export const MetaballBlobs = React.forwardRef<HTMLDivElement, MetaballBlobsProps>(...)
with props extending React.HTMLAttributes<HTMLDivElement> (rest props spread on
the root, ref forwarded to it via useImperativeHandle over an internal ref):
- count?: number (default 5) — blobs in the field, clamped to 1..8.
- speed?: number (default 24) — SECONDS PER ORBIT of the base blob; 0 or less
parks the drift without disabling the component.
- blur?: number (default 24) — the goo gaussian radius in px, clamped 0..80.
It is both the edge softness and the distance at which two blobs merge.
- interactive?: boolean (default true) — blobs lean toward the pointer.
- intensity?: "subtle" | "medium" | "bold" (default "medium") — opacity tier of
the whole goo layer: 0.34 / 0.52 / 0.72.
- colors?: string[] (default ["chart-1".."chart-5"]) — theme token names
WITHOUT the leading "--", cycled across the blobs.
- It is a WRAPPER, not a bare layer: children render in a `relative z-10` sibling
above the decoration, and the root is what receives the pointer.
Behavior
- Seeded layout: a 32-bit LCG with a fixed seed (never Math.random) derives every
blob's orbit centre, orbit radii, diameter, two angular rates and two phases.
Server and client therefore agree and the preview is identical every render.
Place blob i on a golden-angle spiral whose ring radius grows with
sqrt(i / count) — raising `count` fills fresh sectors instead of crowding the
centre — and stretch the cluster ~1.45x horizontally so wide heroes read well.
- Units: the decoration layer is a size container (container-type: size) and
every blob is written in cqmin — diameter ~38..58cqmin, orbit centre as
calc(50% + Ncqmin), orbit radii ~4..13cqmin. Sizes and spacing then follow the
container's SHORT edge, so the field composes the same in a wide hero and in a
tall sidebar with zero JS for layout.
- Resting pose: each blob's inline transform is
translate(-50%, -50%) translate3d(<rx*cos(phase)>cqmin, <ry*sin(phase)>cqmin, 0).
That is the server-rendered arrangement, the first paint, and the
prefers-reduced-motion arrangement — all three identical, and all three
composed (overlapping, off-balance) rather than a ring of tidy circles.
- Motion: ONE rAF loop for the whole field writes only `transform` on each blob.
Track elapsed ORBIT ANGLE in a ref (advance by dt * 2π / speed) so the loop can
restart without the field jumping back to t = 0. Clamp dt to 1/30s so a
backgrounded tab does not teleport on return. Position at time t is
centre + [rx*cos(fx*t + px), ry*sin(fy*t + py)] with fx ≠ fy per blob, i.e. a
Lissajous path — no two blobs ever visibly sync.
- Pointer: pointermove/pointerleave/pointercancel are PASSIVE listeners on the
root that only store client coordinates; every computation happens in the rAF
frame (one getBoundingClientRect per frame, taken before any style write).
Inside a radius of ~62cqmin a blob targets a translation of
(pointer - blobCentre) * 0.42 * (1 - dist/radius) plus up to a 1.16x scale, and
eases toward that target with a frame-rate-independent factor
1 - exp(-dt / 0.22). Measure the distance from the UNPULLED position, otherwise
a caught blob pulls itself harder and sticks to the cursor. Snap the tail of
the easing to exactly zero so a released blob truly comes to rest.
- Suspension: IntersectionObserver stops the loop off screen, visibilitychange
stops it in a hidden tab, a ResizeObserver re-measures (read clientWidth /
clientHeight — the padding box is what inset-0 and cqmin resolve against). When
the drift is parked AND no pull is left, close the loop entirely and reopen it
from the pointer handler or from the effect that syncs `speed`. Cancel the rAF,
disconnect both observers and remove every listener on unmount.
- prefers-reduced-motion: read it with matchMedia through useSyncExternalStore so
a mid-session system change is honoured and the listener cleans itself up. When
it is set, never open the loop — write each blob back to its resting transform.
The background stays fully painted; only movement stops. Also disable the
pointer pull on coarse pointers (matchMedia("(pointer: coarse)")): on touch
there is no hover, and the effect must never compete with a scroll gesture.
Rendering & styling
- The merge is a filter, not a stack of gradients. Render one inline
<svg class="absolute size-0" aria-hidden> holding
<filter colorInterpolationFilters="sRGB" x="-45%" y="-45%" width="190%" height="190%">
with feGaussianBlur(stdDeviation = blur) followed by an alpha-only feColorMatrix
(last row "0 0 0 20 -9"). The blur melts the discs into halos; the matrix
re-hardens them at the ~0.45 alpha iso-line, and two overlapping halos cross
that line in the gap between them — that gap becoming solid IS the neck. Give
the filter a per-instance id from useId (strip the colons) so several fields on
one page do not share one filter. A generous filter region is required or the
blobs are clipped at the box edge.
- The decoration layer carries `filter: url(#id)` and the intensity opacity, is
aria-hidden + pointer-events-none, and sits inside a root that is
`relative isolate overflow-hidden`.
- Blobs are OPAQUE discs: alpha thresholding is what merges them, so translucency
belongs to the layer, not to the disc. Paint each as
radial-gradient(circle at 34% 30%, color-mix(in oklab, var(--<token>) 70%,
var(--background)), var(--<token>) 68%) — an off-centre light stop keeps a blob
from reading as a flat decal.
- Semantic tokens only: var(--chart-1..5) / var(--primary) / var(--background) via
color-mix. No hex literals and no raw colour functions anywhere, so the field re-skins itself with
the host theme and dark mode. Merge consumer className with cn() on the root.
- Accessibility: the whole decoration is aria-hidden and inert; the pointer
reaction is ornamental and carries no information, so nothing is lost without a
cursor. Keep overlaid copy on text-foreground.
Customization levers
- Density and palette: `count` 1..8 and `colors` (token names) are the two knobs
that change the character most — ["primary"] alone gives a monochrome brand
wash, the full chart ramp gives a spectrum.
- Goo strength: `blur` 0 renders separate hard discs, 24 grows visible necks, 48+
fuses the field into one body. Retune the feColorMatrix multiplier/offset
(20 / -9) to move the threshold: a bigger ratio gives crisper, fatter shapes.
- Loudness: the OPACITY tier map is the single source of intensity — retune
0.34 / 0.52 / 0.72 or add a tier without touching anything else.
- Pace: `speed` is seconds per orbit; 30–60s reads as ambient, under 10s starts
competing with the copy. speed={0} is a legitimate static mode.
- Pointer feel: POINTER_RADIUS / POINTER_PULL / POINTER_SWELL / POINTER_EASE are
four constants — raise the pull for a sticky "liquid magnet", drop the radius
for a subtle nudge, or set interactive={false} for a pure ambient field.
- Geometry: SPREAD_X / SPREAD_Y reshape the cluster (widen for banners, equalise
for square cards), and the size / orbit-radius ranges in the seeded generator
decide whether the field is a few big lobes or many small ones.
- Scope: it wraps content, so it works as a card backdrop, a sidebar panel or a
full hero; the root's overflow-hidden and rounding clip the goo to any shape.Concepts
- Alpha-threshold merge — the gooeyness is not painted, it is thresholded: blur every disc into a halo, then re-harden the alpha at one iso-line. Where two halos overlap, the sum crosses that line in the empty gap between them, and the gap turns solid — that neck is the whole illusion.
- Blur as merge distance —
bluris the only dial that matters for character, because the same gaussian radius sets both how soft an edge is and how far apart two blobs can still find each other (0 = separate discs, 48 = one body). - Resting pose as the contract — the seeded t = 0 arrangement is written in container-query units as an inline transform, so the server HTML, the first client paint and the reduced-motion state are literally the same picture; the loop takes over from there instead of composing from nothing.
- cqmin sizing — the decoration layer is a size container and every blob is measured against the container's short edge, so a 1440×420 hero and a 320×280 card both get blobs that actually overlap, with no JS in the layout path.
- Unpulled distance — pointer proximity is measured from where a blob would be without the pull already applied; feeding the pulled position back in is a positive feedback loop that ends with every blob glued to the cursor.
- Parked, not spinning —
speed <= 0plus no residual pull closes the rAF loop rather than re-writing identical transforms forever; the pointer handler and the pace effect are what reopen it.
Dot Globe
A rotating sphere of dots on one canvas — lat/lng places or a Fibonacci lattice, projected orthographically with a dimmed far hemisphere, pulsing markers and optional drag-to-spin.
Warp Tunnel
A hyperspace speed tunnel on one canvas — tapered streaks leave a movable vanishing point and accelerate outward with perspective foreshortening.