"use client";
import * as React from "react";
export type MorphingBlobFill = "solid" | "linear" | "radial" | "outline";
export interface MorphingBlobProps extends React.ComponentPropsWithoutRef<"div"> {
/** Number of control points around the outline. More points = more lobes. */
complexity?: number;
/** Morph speed multiplier. 0 holds the current shape. */
speed?: number;
/** Radius variation as a fraction of the base radius (0–0.45). */
amplitude?: number;
/** solid colour · linear gradient · radial gradient · outline only. */
fill?: MorphingBlobFill;
/** Main colour (any CSS colour). */
color?: string;
/** Gradient end colour (any CSS colour). */
secondaryColor?: string;
/** A second, orbiting blob that merges and splits via an SVG goo filter (filled modes). */
gooey?: boolean;
/** Bulge toward a mouse pointer and squish away from it. */
interactive?: boolean;
}
/* ---------------------------------- Noise ---------------------------------- */
/** Compact seeded 3D simplex noise (after Stefan Gustavson). Returns values in roughly [-1, 1]. */
function createNoise3D(seed = 7) {
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
let s = seed >>> 0 || 1;
for (let i = 255; i > 0; i--) {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
const j = s % (i + 1);
const t = p[i];
p[i] = p[j];
p[j] = t;
}
const perm = new Uint8Array(512);
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
const grad = [1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1];
const F3 = 1 / 3;
const G3 = 1 / 6;
const corner = (hash: number, x: number, y: number, z: number) => {
let t = 0.6 - x * x - y * y - z * z;
if (t < 0) return 0;
const g = (hash % 12) * 3;
t *= t;
return t * t * (grad[g] * x + grad[g + 1] * y + grad[g + 2] * z);
};
return (x: number, y: number, z: number) => {
const sk = (x + y + z) * F3;
const i = Math.floor(x + sk);
const j = Math.floor(y + sk);
const k = Math.floor(z + sk);
const t = (i + j + k) * G3;
const x0 = x - (i - t);
const y0 = y - (j - t);
const z0 = z - (k - t);
let i1, j1, k1, i2, j2, k2;
if (x0 >= y0) {
if (y0 >= z0) [i1, j1, k1, i2, j2, k2] = [1, 0, 0, 1, 1, 0];
else if (x0 >= z0) [i1, j1, k1, i2, j2, k2] = [1, 0, 0, 1, 0, 1];
else [i1, j1, k1, i2, j2, k2] = [0, 0, 1, 1, 0, 1];
} else if (y0 < z0) [i1, j1, k1, i2, j2, k2] = [0, 0, 1, 0, 1, 1];
else if (x0 < z0) [i1, j1, k1, i2, j2, k2] = [0, 1, 0, 0, 1, 1];
else [i1, j1, k1, i2, j2, k2] = [0, 1, 0, 1, 1, 0];
const ii = i & 255;
const jj = j & 255;
const kk = k & 255;
const n0 = corner(perm[ii + perm[jj + perm[kk]]], x0, y0, z0);
const n1 = corner(perm[ii + i1 + perm[jj + j1 + perm[kk + k1]]], x0 - i1 + G3, y0 - j1 + G3, z0 - k1 + G3);
const n2 = corner(perm[ii + i2 + perm[jj + j2 + perm[kk + k2]]], x0 - i2 + 2 * G3, y0 - j2 + 2 * G3, z0 - k2 + 2 * G3);
const n3 = corner(perm[ii + 1 + perm[jj + 1 + perm[kk + 1]]], x0 - 0.5, y0 - 0.5, z0 - 0.5);
return 32 * (n0 + n1 + n2 + n3);
};
}
// Deterministic (seeded), so the server-rendered shape matches the first client frame.
const noise = createNoise3D(3);
/* ---------------------------------- Shape ---------------------------------- */
const VIEW = 240;
const CENTER = VIEW / 2;
const BASE_RADIUS = 64;
const SATELLITE_RADIUS = 19;
interface Pull {
/** Pointer position in viewBox units. */
x: number;
y: number;
/** 0–1 eased presence of the pointer. */
strength: number;
}
const round = (n: number) => Math.round(n * 100) / 100;
/** Satellite centre for a given orbit angle: the orbit breathes so it repeatedly merges and splits. */
function satelliteCentre(orbit: number) {
const distance = 78 + 18 * Math.sin(orbit * 1.7);
return [CENTER + Math.cos(orbit) * distance, CENTER + Math.sin(orbit) * distance] as const;
}
const ORBIT_START = -0.25;
/** Closed Catmull-Rom spline through the points, as cubic Bézier segments. */
function smoothPath(xs: number[], ys: number[]) {
const n = xs.length;
let d = `M${round(xs[0])} ${round(ys[0])}`;
for (let i = 0; i < n; i++) {
const p0 = (i - 1 + n) % n;
const p2 = (i + 1) % n;
const p3 = (i + 2) % n;
const c1x = xs[i] + (xs[p2] - xs[p0]) / 6;
const c1y = ys[i] + (ys[p2] - ys[p0]) / 6;
const c2x = xs[p2] - (xs[p3] - xs[i]) / 6;
const c2y = ys[p2] - (ys[p3] - ys[i]) / 6;
d += `C${round(c1x)} ${round(c1y)} ${round(c2x)} ${round(c2y)} ${round(xs[p2])} ${round(ys[p2])}`;
}
return `${d}Z`;
}
/**
* Noise-driven blob outline. Radii are sampled on a circle in noise space (so the loop is
* seamless), then bulged toward the pointer and squished on the opposite side.
*/
function blobPath(points: number, amplitude: number, time: number, cx: number, cy: number, radius: number, seed: number, pull?: Pull) {
const n = Math.max(3, Math.round(points));
const xs: number[] = [];
const ys: number[] = [];
let pointerAngle = 0;
let pointerReach = 0;
if (pull && pull.strength > 0.001) {
const dx = pull.x - cx;
const dy = pull.y - cy;
pointerAngle = Math.atan2(dy, dx);
// Full effect near the edge, fading out over ~1.6 radii beyond it.
const dist = Math.hypot(dx, dy);
pointerReach = pull.strength * Math.max(0, Math.min(1, 1 - (dist - radius) / (radius * 1.6)));
}
for (let i = 0; i < n; i++) {
const angle = (i / n) * Math.PI * 2 - Math.PI / 2;
const c = Math.cos(angle);
const s = Math.sin(angle);
let r = radius * (1 + amplitude * noise(c * 0.85 + seed, s * 0.85, time));
if (pointerReach > 0) {
const facing = Math.cos(angle - pointerAngle);
r += radius * pointerReach * (facing > 0 ? 0.24 * facing ** 3 : -0.07 * facing * facing);
}
xs.push(cx + c * r);
ys.push(cy + s * r);
}
return smoothPath(xs, ys);
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* -------------------------------- Component -------------------------------- */
/**
* An organic SVG blob whose outline morphs continuously. The path is rebuilt each frame from
* N noise-driven radii joined by a Catmull-Rom spline and written straight to the DOM (no
* React re-renders). Children are centred on top — ideal behind a call to action.
*/
export function MorphingBlob({
complexity = 8,
speed = 1,
amplitude = 0.18,
fill = "linear",
color = "#FF7448",
secondaryColor = "#B6F3D1",
gooey = false,
interactive = true,
className = "",
style,
children,
...props
}: MorphingBlobProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const blobRef = React.useRef<SVGPathElement>(null);
const satelliteRef = React.useRef<SVGPathElement>(null);
// The morph clock survives prop changes, so dragging a control does not restart the shape.
const clockRef = React.useRef({ time: 0, orbit: ORBIT_START });
const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
const gradientId = `blob-fill-${uid}`;
const gooId = `blob-goo-${uid}`;
const amp = Math.max(0, Math.min(0.45, amplitude));
const outline = fill === "outline";
const satellite = gooey;
// Initial (and reduced-motion) shape, computed purely from props.
const initialBlob = blobPath(complexity, amp, 0, CENTER, CENTER, BASE_RADIUS, 0);
const [sx0, sy0] = satelliteCentre(ORBIT_START);
const initialSatellite = blobPath(6, amp * 0.6, 0, sx0, sy0, SATELLITE_RADIUS, 4.2);
React.useEffect(() => {
const root = rootRef.current;
const blob = blobRef.current;
if (!root || !blob || reduceMotion) return;
const clock = clockRef.current;
let raf = 0;
let last = 0;
let inView = false;
const pull = { clientX: 0, clientY: 0, x: CENTER, y: CENTER, inside: false, strength: 0 };
const frame = (now: number) => {
raf = 0;
const dt = Math.min(64, last ? now - last : 16.7);
last = now;
const f = dt / 16.7;
clock.time += dt * 0.00028 * speed;
clock.orbit += dt * 0.00042 * speed;
if (pull.inside) {
// Map client coordinates into the square viewBox (meet = fitted to the smaller side).
const rect = root.getBoundingClientRect();
const scale = VIEW / Math.max(1, Math.min(rect.width, rect.height));
pull.x = CENTER + (pull.clientX - (rect.left + rect.width / 2)) * scale;
pull.y = CENTER + (pull.clientY - (rect.top + rect.height / 2)) * scale;
}
pull.strength += ((pull.inside ? 1 : 0) - pull.strength) * (1 - Math.pow(0.9, f));
// The whole blob leans a little toward the pointer.
const lean = 5 * pull.strength;
const lx = pull.strength > 0.001 ? Math.cos(Math.atan2(pull.y - CENTER, pull.x - CENTER)) * lean : 0;
const ly = pull.strength > 0.001 ? Math.sin(Math.atan2(pull.y - CENTER, pull.x - CENTER)) * lean : 0;
blob.setAttribute("d", blobPath(complexity, amp, clock.time, CENTER + lx, CENTER + ly, BASE_RADIUS, 0, pull));
const sat = satelliteRef.current;
if (sat) {
const [sx, sy] = satelliteCentre(clock.orbit);
sat.setAttribute("d", blobPath(6, amp * 0.6, clock.time * 1.3, sx, sy, SATELLITE_RADIUS, 4.2));
}
schedule();
};
const schedule = () => {
if (!raf && inView && !document.hidden) raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType === "touch") return;
pull.clientX = e.clientX;
pull.clientY = e.clientY;
pull.inside = true;
};
const onPointerLeave = () => {
pull.inside = false;
};
const onVisibility = () => (document.hidden ? stop() : schedule());
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
if (inView) schedule();
else stop();
});
io.observe(root);
document.addEventListener("visibilitychange", onVisibility);
if (interactive) {
root.addEventListener("pointermove", onPointerMove, { passive: true });
root.addEventListener("pointerleave", onPointerLeave);
}
return () => {
stop();
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
root.removeEventListener("pointermove", onPointerMove);
root.removeEventListener("pointerleave", onPointerLeave);
};
}, [complexity, amp, speed, interactive, reduceMotion, satellite]);
const paint = outline ? "none" : fill === "solid" ? color : `url(#${gradientId})`;
return (
<div
ref={rootRef}
className={`relative flex h-full w-full items-center justify-center ${className}`}
style={style}
{...props}
>
<svg
aria-hidden="true"
focusable="false"
viewBox={`0 0 ${VIEW} ${VIEW}`}
preserveAspectRatio="xMidYMid meet"
className="pointer-events-none absolute inset-0 h-full w-full overflow-visible"
>
<defs>
{fill === "radial" ? (
<radialGradient id={gradientId} cx="42%" cy="38%" r="70%">
<stop offset="0%" style={{ stopColor: secondaryColor }} />
<stop offset="100%" style={{ stopColor: color }} />
</radialGradient>
) : (
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style={{ stopColor: color }} />
<stop offset="100%" style={{ stopColor: secondaryColor }} />
</linearGradient>
)}
{satellite && !outline ? (
<filter id={gooId} x="-20%" y="-20%" width="140%" height="140%" colorInterpolationFilters="sRGB">
<feGaussianBlur in="SourceGraphic" stdDeviation="6" result="blur" />
{/* Sharpen the blurred alpha back into a crisp, merged edge. */}
<feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 24 -10" />
</filter>
) : null}
</defs>
<g
filter={satellite && !outline ? `url(#${gooId})` : undefined}
style={{ fill: paint, stroke: outline ? color : "none" }}
strokeWidth={outline ? 1.5 : undefined}
vectorEffect={outline ? "non-scaling-stroke" : undefined}
>
<path ref={blobRef} d={initialBlob} vectorEffect={outline ? "non-scaling-stroke" : undefined} />
{satellite ? <path ref={satelliteRef} d={initialSatellite} vectorEffect={outline ? "non-scaling-stroke" : undefined} /> : null}
</g>
</svg>
{children != null && <div className="relative z-10">{children}</div>}
</div>
);
}