Backgrounds

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** Layer opacity per intensity tier — the single knob for how loud the goo is. */
const OPACITY = {
  subtle: 0.34,
  medium: 0.52,
  bold: 0.72,
} as const

export type MetaballBlobsIntensity = keyof typeof OPACITY

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/metaball-blobs.json

Prompt

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 distanceblur is 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 spinningspeed <= 0 plus 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.

On This Page