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>
  );
}