NEMAWASHI LAB

Motion Path

GSAP

Eight coloured dots ride along a preset SVG path (figure-8, elliptical orbit, zigzag, or spiral) using GSAP MotionPathPlugin with autoRotate and infinite repeat. Each dot enters at an evenly spaced phase offset -- delay of (i / 8) * duration -- so they form a train rather than a clump. The path itself is drawn as a faint dashed stroke so you can see the rail, and the dots pick up hue-rotated colours at 45-degree increments.

Motion Path
Speed3.0s

Source

MotionPath.tsx161 lines
"use client";

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

gsap.registerPlugin(MotionPathPlugin);

const PATHS: { label: string; d: string; viewBox: string }[] = [
  {
    label: "Figure 8",
    viewBox: "0 0 600 400",
    d: "M300,200 C300,100 450,50 450,150 C450,250 300,300 300,200 C300,100 150,50 150,150 C150,250 300,300 300,200",
  },
  {
    label: "Orbit",
    viewBox: "0 0 600 400",
    d: "M300,80 C450,80 520,200 520,200 C520,200 450,320 300,320 C150,320 80,200 80,200 C80,200 150,80 300,80",
  },
  {
    label: "Zigzag",
    viewBox: "0 0 600 400",
    d: "M50,350 L150,50 L250,350 L350,50 L450,350 L550,50",
  },
  {
    label: "Spiral",
    viewBox: "0 0 600 400",
    d: "M300,200 C300,150 350,120 370,160 C390,200 370,260 320,270 C270,280 210,250 200,200 C190,150 220,80 300,60 C380,40 460,100 480,200 C500,300 430,380 300,400",
  },
];

const DOT_COUNT = 8;

export default function MotionPathDemo() {
  const containerRef = useRef<HTMLDivElement>(null);
  const [pathIdx, setPathIdx] = useState(0);
  const [dark, setDark] = useState(true);
  const [duration, setDuration] = useState(3);
  const [key, setKey] = useState(0);

  useEffect(() => {
    if (!containerRef.current) return;

    const ctx = gsap.context(() => {
      const dots = gsap.utils.toArray<HTMLElement>(".mp-dot");
      const path = PATHS[pathIdx];

      dots.forEach((dot, i) => {
        gsap.to(dot, {
          motionPath: {
            path: `#mp-path`,
            align: `#mp-path`,
            alignOrigin: [0.5, 0.5],
            autoRotate: true,
          },
          duration,
          ease: "none",
          repeat: -1,
          delay: (i / DOT_COUNT) * duration,
        });
      });
    }, containerRef);

    return () => ctx.revert();
  }, [pathIdx, duration, key]);

  return (
    <div
      ref={containerRef}
      className="fixed inset-0 flex items-center justify-center select-none overflow-hidden"
      style={{
        background: dark ? "#0a0a0a" : "#f8f7f4",
        transition: "background 0.4s ease",
      }}
    >
      <svg
        key={`${pathIdx}-${key}`}
        viewBox={PATHS[pathIdx].viewBox}
        style={{ width: "min(80vw, 600px)", height: "min(55vw, 400px)", position: "absolute" }}
        fill="none"
      >
        <path
          id="mp-path"
          d={PATHS[pathIdx].d}
          stroke={dark ? "rgba(255,255,255,0.1)" : "rgba(26,26,46,0.08)"}
          strokeWidth={1}
          strokeDasharray="4 4"
        />
      </svg>

      {/* Dots */}
      {Array.from({ length: DOT_COUNT }, (_, i) => {
        const hue = (i * 45) % 360;
        return (
          <div
            key={`${pathIdx}-${key}-${i}`}
            className="mp-dot"
            style={{
              position: "absolute",
              width: 12 + (i % 3) * 4,
              height: 12 + (i % 3) * 4,
              borderRadius: "50%",
              background: `hsl(${hue}, 60%, ${dark ? 60 : 45}%)`,
              boxShadow: `0 0 12px hsla(${hue}, 60%, 50%, 0.4)`,
            }}
          />
        );
      })}

      {/* Path buttons */}
      <div className="fixed bottom-12 left-1/2 -translate-x-1/2 flex gap-2" style={{ zIndex: 20 }}>
        {PATHS.map((p, i) => (
          <button
            key={p.label}
            onClick={() => { setPathIdx(i); setKey((k) => k + 1); }}
            style={{
              fontFamily: "var(--font-flux), sans-serif", fontVariationSettings: "'wght' 300, 'SRIF' 100",
              fontSize: 11, letterSpacing: "0.06em", padding: "5px 14px", borderRadius: 20,
              border: i === pathIdx ? (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 === pathIdx ? (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",
            }}
          >
            {p.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" }}>
          Motion Path
        </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" }}>Speed</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.5} max={8} step={0.1} value={duration} onChange={(e) => { setDuration(parseFloat(e.target.value)); setKey((k) => k + 1); }} 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="MOTION PATH" 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