Morphing Blob
Organic SVG blob background that slowly morphs between multiple shapes on a loop
backgroundsvgblobmorphorganicinteractive
Preview
Source Code
"use client";
import { useEffect, useId, useRef } from "react";
interface MorphingBlobProps {
/** Gradient stops (2 or 3). Default: 3-stop vibrant */
colors?: string[];
/** Animation speed multiplier. Default: 1 */
speed?: number;
/** Number of control points around the blob — higher = more detail. Default: 8 */
complexity?: number;
/** Size in px. Default: 400 */
size?: number;
/** Ambient glow blur in px. Default: 40 */
blur?: number;
/** Bulge toward the cursor when it moves over the parent. Default: true */
interactive?: boolean;
className?: string;
}
interface Seed {
angle: number;
phaseA: number;
phaseB: number;
speedA: number;
speedB: number;
}
function seedPoints(n: number, offset = 0): Seed[] {
return Array.from({ length: n }, (_, i) => ({
angle: (i / n) * Math.PI * 2,
phaseA: (offset + i * 1.7) % (Math.PI * 2),
phaseB: (offset + i * 2.9) % (Math.PI * 2),
speedA: 0.35 + ((i * 0.13 + offset) % 0.3),
speedB: 0.2 + ((i * 0.19 + offset) % 0.35),
}));
}
function buildSmoothClosedPath(pts: { x: number; y: number }[]) {
const n = pts.length;
if (n < 3) return "";
const tension = 1 / 6;
const f = (v: number) => v.toFixed(2);
let d = `M${f(pts[0].x)},${f(pts[0].y)}`;
for (let i = 0; i < n; i++) {
const p0 = pts[(i - 1 + n) % n];
const p1 = pts[i];
const p2 = pts[(i + 1) % n];
const p3 = pts[(i + 2) % n];
const c1x = p1.x + (p2.x - p0.x) * tension;
const c1y = p1.y + (p2.y - p0.y) * tension;
const c2x = p2.x - (p3.x - p1.x) * tension;
const c2y = p2.y - (p3.y - p1.y) * tension;
d += ` C${f(c1x)},${f(c1y)} ${f(c2x)},${f(c2y)} ${f(p2.x)},${f(p2.y)}`;
}
return d + " Z";
}
export default function MorphingBlob({
colors = ["#8b5cf6", "#ec4899", "#06b6d4"],
speed = 1,
complexity = 8,
size = 400,
blur = 40,
interactive = true,
className = "",
}: MorphingBlobProps) {
const containerRef = useRef<HTMLDivElement>(null);
const primaryRef = useRef<SVGPathElement>(null);
const secondaryRef = useRef<SVGPathElement>(null);
const coreRef = useRef<SVGPathElement>(null);
// target: what the mouse events say we want; value: what is currently applied (lerped toward target)
const mouseRef = useRef({
targetX: 0,
targetY: 0,
targetStrength: 0,
x: 0,
y: 0,
strength: 0,
});
const rawId = useId();
const reactId = rawId.replace(/[^a-zA-Z0-9]/g, "");
const primaryGradId = `blob-p-${reactId}`;
const secondaryGradId = `blob-s-${reactId}`;
const coreGradId = `blob-c-${reactId}`;
useEffect(() => {
const prefersReduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const primarySeeds = seedPoints(complexity, 0);
const secondarySeeds = seedPoints(complexity, 2.5);
const coreSeeds = seedPoints(Math.max(6, complexity - 2), 5.1);
function compute(seeds: Seed[], t: number, radius: number, bulge: number) {
const m = mouseRef.current;
const mouseMag = Math.hypot(m.x, m.y);
const angleToMouse = mouseMag > 0 ? Math.atan2(m.y, m.x) : 0;
const raw = seeds.map((p) => {
const osc =
Math.sin(t * p.speedA + p.phaseA) * 3 +
Math.cos(t * p.speedB + p.phaseB) * 2;
let r = radius + osc;
if (m.strength > 0.01) {
// (1 + cos) / 2 is smoother than max(0, cos); squared makes the bulge localized
const falloff = ((1 + Math.cos(p.angle - angleToMouse)) / 2) ** 2;
r += falloff * bulge * m.strength;
}
return {
x: 100 + Math.cos(p.angle) * r,
y: 100 + Math.sin(p.angle) * r,
};
});
// Hold centroid at (100,100) so the blob morphs in place
let cx = 0;
let cy = 0;
for (const p of raw) {
cx += p.x;
cy += p.y;
}
cx = cx / raw.length - 100;
cy = cy / raw.length - 100;
for (const p of raw) {
p.x -= cx;
p.y -= cy;
}
return raw;
}
if (prefersReduced) {
primaryRef.current?.setAttribute(
"d",
buildSmoothClosedPath(compute(primarySeeds, 0, 70, 0))
);
secondaryRef.current?.setAttribute(
"d",
buildSmoothClosedPath(compute(secondarySeeds, 1.3, 66, 0))
);
coreRef.current?.setAttribute(
"d",
buildSmoothClosedPath(compute(coreSeeds, 0.5, 38, 0))
);
return;
}
const start = performance.now();
let raf = 0;
function tick(now: number) {
const t = ((now - start) / 1000) * speed;
// Smooth the mouse state — lerp current value toward the event-driven target
const m = mouseRef.current;
m.x += (m.targetX - m.x) * 0.15;
m.y += (m.targetY - m.y) * 0.15;
m.strength += (m.targetStrength - m.strength) * 0.08;
// Naturally decay the target when no events arrive
m.targetStrength *= 0.985;
if (primaryRef.current) {
primaryRef.current.setAttribute(
"d",
buildSmoothClosedPath(compute(primarySeeds, t, 70, 14))
);
}
if (secondaryRef.current) {
secondaryRef.current.setAttribute(
"d",
buildSmoothClosedPath(compute(secondarySeeds, t * 0.8 + 1.3, 66, 16))
);
}
if (coreRef.current) {
coreRef.current.setAttribute(
"d",
buildSmoothClosedPath(compute(coreSeeds, t * 1.2 + 0.7, 38, 8))
);
}
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [complexity, speed]);
useEffect(() => {
if (!interactive) return;
const el = containerRef.current;
const parent = el?.parentElement;
const target = parent ?? el;
if (!el || !target) return;
const onMove = (e: MouseEvent) => {
const rect = el.getBoundingClientRect();
const nx = (e.clientX - rect.left) / rect.width - 0.5;
const ny = (e.clientY - rect.top) / rect.height - 0.5;
mouseRef.current.targetX = nx * 2;
mouseRef.current.targetY = ny * 2;
mouseRef.current.targetStrength = 1;
};
target.addEventListener("mousemove", onMove);
return () => {
target.removeEventListener("mousemove", onMove);
};
}, [interactive]);
const c0 = colors[0] ?? "#8b5cf6";
const c1 = colors[1] ?? c0;
const c2 = colors[2] ?? c1;
return (
<div
ref={containerRef}
className={`pointer-events-none relative ${className}`}
style={{ width: size, height: size }}
>
<div
className="absolute inset-0 opacity-60"
style={{ filter: `blur(${blur * 1.5}px)` }}
>
<svg
viewBox="0 0 200 200"
width="100%"
height="100%"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<linearGradient
id={secondaryGradId}
x1="100%"
y1="0%"
x2="0%"
y2="100%"
>
<stop offset="0%" stopColor={c2} />
<stop offset="100%" stopColor={c0} />
</linearGradient>
</defs>
<path ref={secondaryRef} fill={`url(#${secondaryGradId})`} />
</svg>
</div>
<div
className="absolute inset-0"
style={{ filter: `blur(${blur}px)` }}
>
<svg
viewBox="0 0 200 200"
width="100%"
height="100%"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<linearGradient
id={primaryGradId}
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop offset="0%" stopColor={c0} />
<stop offset="50%" stopColor={c1} />
<stop offset="100%" stopColor={c2} />
</linearGradient>
</defs>
<path ref={primaryRef} fill={`url(#${primaryGradId})`} />
</svg>
</div>
{/* Brighter core layer — less blur, tighter shape, gives the blob a luminous center */}
<div
className="absolute inset-0 mix-blend-screen"
style={{ filter: `blur(${blur * 0.4}px)`, opacity: 0.35 }}
>
<svg
viewBox="0 0 200 200"
width="100%"
height="100%"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<radialGradient id={coreGradId} cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor={c1} stopOpacity="1" />
<stop offset="70%" stopColor={c1} stopOpacity="0.2" />
<stop offset="100%" stopColor={c1} stopOpacity="0" />
</radialGradient>
</defs>
<path ref={coreRef} fill={`url(#${coreGradId})`} />
</svg>
</div>
</div>
);
}