Skip to content
Plinth

Backgrounds

Metaball Lava

Molten blobs that fuse and part as they drift, thresholded out of a blur by one contrast filter on the parent.

#background#metaball#filter#animation#gooey

Install

npx shadcn@latest add https://plinthui.com/r/metaball-lava.json
Open in v0
// No hooks or handlers, so this stays a server component — it is pure CSS.
// A filter-threshold and two blend modes are outside what Tailwind can
// express, so the CSS ships as a string rather than as utility classes.
const css = `
.lava {
  position: relative;
  min-height: 300px;
  height: 100%;
  overflow: hidden;
  background: #06010f;
  isolation: isolate;
}

/* The goo layer is the whole trick. Blur smears each blob's edge into a soft
   alpha ramp; a large contrast() re-thresholds that ramp back to a hard edge.
   Where two ramps overlap they sum past the threshold BEFORE either blob's own
   edge reaches it, so the pair fuses with a concave neck. Nothing computes the
   neck — it falls out of the alpha maths. */
.lava__goo {
  position: absolute;
  inset: -14%;
  filter: blur(16px) contrast(18);
  /* contrast() pivots around mid-grey, so this layer needs an opaque black
     floor. On a transparent backdrop there is nothing to threshold against and
     the blobs keep a blurred halo instead of snapping to an edge. */
  background: #000;
}

/* White, deliberately. Thresholding saturated fills drives each one to its own
   RGB primary and they come out clashing and fringed; a white silhouette
   thresholds cleanly and takes its colour from one gradient instead, so the
   whole field stays in a single harmony. */
.lava__blob {
  position: absolute;
  border-radius: 50%;
  background: #fff;
  will-change: transform;
}

.lava__blob--a { width: 26%; aspect-ratio: 1; left: 10%; top: 24%; animation: lava-a 13s ease-in-out infinite; }
.lava__blob--b { width: 22%; aspect-ratio: 1; right: 12%; top: 34%; animation: lava-b 17s ease-in-out infinite; }
.lava__blob--c { width: 18%; aspect-ratio: 1; left: 40%; bottom: 14%; animation: lava-c 11s ease-in-out infinite; }
.lava__blob--d { width: 15%; aspect-ratio: 1; left: 26%; top: 8%;  animation: lava-d 19s ease-in-out infinite; }
.lava__blob--e { width: 12%; aspect-ratio: 1; right: 26%; bottom: 8%; animation: lava-e 23s ease-in-out infinite; }

/* Prime, mutually indivisible durations (11/13/17/19/23s): each blob loops,
   but the set never returns to the same arrangement, so the field has no
   visible period. Equal or harmonically related durations make it repeat. */
@keyframes lava-a {
  0%, 100% { transform: translate(0, 0) scale(1); }
  33%      { transform: translate(62%, 30%) scale(1.22); }
  66%      { transform: translate(18%, -28%) scale(0.84); }
}
@keyframes lava-b {
  0%, 100% { transform: translate(0, 0) scale(1); }
  40%      { transform: translate(-66%, -22%) scale(0.8); }
  72%      { transform: translate(-20%, 38%) scale(1.26); }
}
@keyframes lava-c {
  0%, 100% { transform: translate(0, 0) scale(1); }
  45%      { transform: translate(-44%, -52%) scale(1.34); }
  80%      { transform: translate(38%, -16%) scale(0.88); }
}
@keyframes lava-d {
  0%, 100% { transform: translate(0, 0) scale(1); }
  50%      { transform: translate(46%, 58%) scale(1.3); }
}
@keyframes lava-e {
  0%, 100% { transform: translate(0, 0) scale(1); }
  50%      { transform: translate(-40%, -46%) scale(1.28); }
}

/* multiply over the thresholded silhouette: gradient x white = gradient,
   gradient x black = black. So one gradient paints every blob and the ground
   stays dark, which is what keeps five shapes reading as one molten body. */
.lava__tint {
  position: absolute;
  inset: 0;
  background: linear-gradient(135deg, #ff3d81 0%, #ff7a3d 34%, #b23dff 68%, #3d7bff 100%);
  mix-blend-mode: multiply;
  pointer-events: none;
}

/* A soft bloom so the mass looks lit from within rather than pasted on. */
.lava__bloom {
  position: absolute;
  inset: 0;
  background: radial-gradient(60% 50% at 42% 42%, rgba(255, 120, 180, 0.28), transparent 70%);
  mix-blend-mode: screen;
  pointer-events: none;
}

.lava__vignette {
  position: absolute;
  inset: 0;
  background: radial-gradient(115% 85% at 50% 45%, transparent 42%, rgba(3, 0, 8, 0.8) 100%);
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .lava__blob { animation: none; }
}
`;

export function MetaballLava() {
  return (
    <>
      <style>{css}</style>
      <div className="lava">
        <div className="lava__goo">
          <div className="lava__blob lava__blob--a" />
          <div className="lava__blob lava__blob--b" />
          <div className="lava__blob lava__blob--c" />
          <div className="lava__blob lava__blob--d" />
          <div className="lava__blob lava__blob--e" />
        </div>
        <div className="lava__tint" />
        <div className="lava__bloom" />
        <div className="lava__vignette" />
      </div>
    </>
  );
}

More in Backgrounds

Similar tags