Lava lamp component
components/lava-lamp.tsx
import "./lava-lamp.css";
const BLOBS = [
{ size: 38, left: 4, duration: 18, delay: -2 },
{ size: 26, left: 52, duration: 22, delay: -9 },
{ size: 32, left: 30, duration: 16, delay: -5 },
{ size: 22, left: 70, duration: 25, delay: -14 },
{ size: 28, left: 16, duration: 20, delay: -11 },
];
// Blurred circles + an SVG "goo" filter: the blur makes close blobs overlap, the colour matrix
// turns the soft edge back into a hard one, so touching blobs melt into each other.
// No state, no effects: it works as a Server Component.
export function LavaLamp({
colors = ["#ff5f6d", "#ffc371"],
className = "",
filterId = "lava-goo",
}: {
colors?: [string, string];
className?: string;
filterId?: string;
}) {
return (
<div aria-hidden className={`pointer-events-none absolute inset-0 overflow-hidden ${className}`}>
<svg width="0" height="0" className="absolute">
<filter id={filterId}>
<feGaussianBlur in="SourceGraphic" stdDeviation="14" result="blur" />
<feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 22 -9" />
</filter>
</svg>
{/* container-type: size lets the keyframes move blobs by the box's own height (cqh) */}
<div className="absolute inset-0" style={{ filter: `url(#${filterId})`, containerType: "size" }}>
{BLOBS.map((b, i) => (
<span
key={i}
className="lava-blob absolute rounded-full"
style={{
width: `${b.size}vmin`,
aspectRatio: "1",
left: `${b.left}%`,
background: colors[i % 2],
animationDuration: `${b.duration}s`,
animationDelay: `${b.delay}s`,
}}
/>
))}
</div>
</div>
);
}
components/lava-lamp.css
/* components/lava-lamp.css */
.lava-blob {
bottom: 0;
animation-name: lava-rise;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-direction: alternate;
will-change: transform;
}
/* From just below the box to just above it, measured in the box's height (cqh). */
@keyframes lava-rise {
0% { transform: translateY(100%) scale(1); }
50% { transform: translateY(-50cqh) scale(1.15, 0.9); }
100% { transform: translateY(-100cqh) scale(0.9, 1.1); }
}
/* Reduce Motion: no movement. The blobs stay parked halfway up, still merged. */
@media (prefers-reduced-motion: reduce) {
.lava-blob { animation: none; transform: translateY(-40cqh); }
}
app/page.tsx (a hero with the lava lamp behind it)
import { LavaLamp } from "@/components/lava-lamp";
export default function Home() {
return (
<section className="relative isolate flex min-h-[80vh] items-center justify-center overflow-hidden bg-neutral-950 px-6">
<LavaLamp colors={["#ff5f6d", "#ffc371"]} className="opacity-80" />
<div className="relative text-center text-white">
<h1 className="text-4xl font-semibold tracking-tight sm:text-6xl">Ship the weird idea</h1>
<p className="mt-4 text-neutral-300">A lava lamp background in plain CSS and SVG.</p>
</div>
</section>
);
}