Backgrounds
Metaball Lava
Molten blobs that fuse and part as they drift, thresholded out of a blur by one contrast filter on the parent.
Original by Plinth · MIT
Report a problem or suggest an improvementInstall
npx shadcn@latest add https://plinthui.com/r/metaball-lava.json// 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>
</>
);
}