NEMAWASHI LAB

Morph

GSAP

Six SVG shapes (circle, star, diamond, cross, heart, blob) morph into each other via GSAP MorphSVGPlugin with rotational point matching, which prevents the collapsing-into-a-line artefact you get with positional matching on shapes with very different vertex distributions. The morph runs at power2.inOut with configurable duration; a subtle translucent fill shadow follows the same tween so the shape reads as slightly volumetric.

Morph
Duration0.8s

Source

MorphShape.tsx188 lines
"use client";

import { useRef, useState, useEffect } from "react";
import gsap from "gsap";
import { MorphSVGPlugin } from "gsap/MorphSVGPlugin";
import ScrambleLink from "../ScrambleLink";

gsap.registerPlugin(MorphSVGPlugin);

const SHAPES: { label: string; d: string }[] = [
  {
    label: "Circle",
    d: "M200,50 C283,50 350,117 350,200 C350,283 283,350 200,350 C117,350 50,283 50,200 C50,117 117,50 200,50 Z",
  },
  {
    label: "Star",
    d: "M200,30 L230,140 L350,140 L255,210 L285,330 L200,255 L115,330 L145,210 L50,140 L170,140 Z",
  },
  {
    label: "Diamond",
    d: "M200,20 L350,200 L200,380 L50,200 Z",
  },
  {
    label: "Cross",
    d: "M150,50 L250,50 L250,150 L350,150 L350,250 L250,250 L250,350 L150,350 L150,250 L50,250 L50,150 L150,150 Z",
  },
  {
    label: "Heart",
    d: "M200,340 C120,280 30,220 30,150 C30,90 80,50 140,50 C170,50 195,65 200,80 C205,65 230,50 260,50 C320,50 370,90 370,150 C370,220 280,280 200,340 Z",
  },
  {
    label: "Blob",
    d: "M200,40 C280,40 340,80 360,140 C380,200 360,280 300,320 C240,360 160,370 100,330 C40,290 20,220 40,150 C60,80 120,40 200,40 Z",
  },
];

export default function MorphShape() {
  const pathRef = useRef<SVGPathElement>(null);
  const [current, setCurrent] = useState(0);
  const [dark, setDark] = useState(true);
  const [duration, setDuration] = useState(0.8);

  useEffect(() => {
    if (!pathRef.current) return;
    gsap.to(pathRef.current, {
      morphSVG: { shape: SHAPES[current].d, type: "rotational" },
      duration,
      ease: "power2.inOut",
    });
  }, [current, duration]);

  return (
    <div
      className="fixed inset-0 flex items-center justify-center select-none overflow-hidden"
      style={{
        background: dark ? "#0a0a0a" : "#f8f7f4",
        transition: "background 0.4s ease",
      }}
    >
      <svg viewBox="0 0 400 400" style={{ width: "min(50vw, 400px)", height: "min(50vw, 400px)" }}>
        <path
          ref={pathRef}
          d={SHAPES[0].d}
          fill="none"
          stroke={dark ? "#fff" : "#1a1a2e"}
          strokeWidth={2}
        />
        <path
          d={SHAPES[0].d}
          fill={dark ? "rgba(255,255,255,0.03)" : "rgba(26,26,46,0.03)"}
          stroke="none"
          ref={(el) => {
            if (el) {
              gsap.to(el, {
                morphSVG: { shape: SHAPES[current].d, type: "rotational" },
                duration,
                ease: "power2.inOut",
              });
            }
          }}
        />
      </svg>

      {/* Shape buttons */}
      <div
        className="fixed bottom-12 left-1/2 -translate-x-1/2 flex gap-2"
        style={{ zIndex: 20 }}
      >
        {SHAPES.map((s, i) => (
          <button
            key={s.label}
            onClick={() => setCurrent(i)}
            style={{
              fontFamily: "var(--font-flux), sans-serif",
              fontVariationSettings: "'wght' 300, 'SRIF' 100",
              fontSize: 11,
              letterSpacing: "0.06em",
              padding: "5px 14px",
              borderRadius: 20,
              border:
                i === current
                  ? dark
                    ? "1px solid rgba(255,255,255,0.4)"
                    : "1px solid rgba(26,26,46,0.3)"
                  : dark
                  ? "1px solid rgba(255,255,255,0.1)"
                  : "1px solid rgba(0,0,0,0.08)",
              background:
                i === current
                  ? dark
                    ? "rgba(255,255,255,0.1)"
                    : "rgba(26,26,46,0.06)"
                  : "transparent",
              color: dark ? "#fff" : "#1a1a2e",
              cursor: "pointer",
              transition: "all 0.2s ease",
            }}
          >
            {s.label}
          </button>
        ))}
      </div>

      {/* Controls */}
      <div
        style={{
          position: "fixed",
          right: 24,
          top: "50%",
          transform: "translateY(-50%)",
          zIndex: 20,
          width: 200,
          background: dark ? "rgba(20,20,20,0.92)" : "rgba(255,255,255,0.92)",
          backdropFilter: "blur(12px)",
          borderRadius: 16,
          padding: "20px 22px",
          boxShadow: dark ? "0 2px 20px rgba(0,0,0,0.3)" : "0 2px 20px rgba(0,0,0,0.06)",
          border: dark ? "1px solid rgba(255,255,255,0.08)" : "1px solid rgba(0,0,0,0.06)",
          transition: "all 0.4s ease",
        }}
      >
        <span
          style={{
            fontFamily: "var(--font-flux), sans-serif",
            fontVariationSettings: "'wght' 500, 'SRIF' 100",
            fontSize: 12,
            letterSpacing: "0.1em",
            textTransform: "uppercase",
            color: dark ? "#fff" : "#1a1a2e",
          }}
        >
          Morph
        </span>

        <div style={{ marginTop: 16 }}>
          <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4 }}>
            <span style={{ fontFamily: "var(--font-flux)", fontVariationSettings: "'wght' 400, 'SRIF' 0", fontSize: 11, color: dark ? "#fff" : "#1a1a2e" }}>
              Duration
            </span>
            <span style={{ fontFamily: "var(--font-flux)", fontVariationSettings: "'wght' 300, 'SRIF' 100", fontSize: 11, color: dark ? "rgba(255,255,255,0.4)" : "rgba(26,26,46,0.45)" }}>
              {duration.toFixed(1)}s
            </span>
          </div>
          <input type="range" min={0.2} max={3} step={0.1} value={duration} onChange={(e) => setDuration(parseFloat(e.target.value))} style={{ width: "100%", accentColor: "#1a1a2e", height: 2 }} />
        </div>

        <button
          onClick={() => setDark((v) => !v)}
          style={{
            width: "100%", padding: "8px 0", marginTop: 16, borderRadius: 8,
            border: dark ? "1px solid rgba(255,255,255,0.2)" : "1px solid rgba(26,26,46,0.1)",
            background: dark ? "rgba(255,255,255,0.1)" : "rgba(26,26,46,0.04)",
            color: dark ? "#fff" : "#1a1a2e",
            fontFamily: "var(--font-flux), sans-serif", fontVariationSettings: "'wght' 500, 'SRIF' 100",
            fontSize: 11, letterSpacing: "0.08em", textTransform: "uppercase" as const, cursor: "pointer", transition: "all 0.3s ease",
          }}
        >
          {dark ? "Dark" : "Light"}
        </button>
      </div>

      <div className="fixed bottom-6 left-6" style={{ zIndex: 20 }}>
        <ScrambleLink from="MORPH" to="← LAB" href="/lab" className="text-[11px] tracking-[0.1em] text-foreground/30 uppercase hover:text-foreground/60 transition-colors" style={{ fontVariationSettings: "'wght' 400, 'SRIF' 100" }} />
      </div>
    </div>
  );
}

NEMAWASHI — Kotaro Abe