NEMAWASHI LAB

SVG Draw

GSAP

GSAP's DrawSVGPlugin reveals strokes from 0% to 100% with power2.inOut easing, staggered 0.15 seconds apart when a shape has multiple paths (the circuit board has six). Four presets -- spiral, star, sine wave, and circuit -- let you compare single-stroke continuity against multi-path orchestration. Duration is adjustable down to 0.3 seconds, where the draw feels like a whip-crack.

SVG Draw
Duration2.0s

Source

SvgDraw.tsx291 lines
"use client";

import { useRef, useState } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
import ScrambleLink from "../ScrambleLink";

gsap.registerPlugin(useGSAP, DrawSVGPlugin);

const SHAPES = [
  {
    label: "Spiral",
    viewBox: "0 0 400 400",
    paths: [
      "M200,200 C200,140 260,100 300,140 C340,180 320,260 260,280 C200,300 120,260 120,200 C120,120 180,60 260,60 C360,60 380,180 380,240 C380,340 280,400 200,400 C80,400 20,300 20,200 C20,60 120,0 200,0",
    ],
  },
  {
    label: "Star",
    viewBox: "0 0 400 400",
    paths: [
      "M200,20 L240,150 L380,150 L265,225 L305,360 L200,280 L95,360 L135,225 L20,150 L160,150 Z",
    ],
  },
  {
    label: "Wave",
    viewBox: "0 0 400 200",
    paths: [
      "M0,100 C50,20 100,20 150,100 C200,180 250,180 300,100 C350,20 400,20 400,100",
      "M0,130 C50,50 100,50 150,130 C200,210 250,210 300,130 C350,50 400,50 400,130",
      "M0,70 C50,-10 100,-10 150,70 C200,150 250,150 300,70 C350,-10 400,-10 400,70",
    ],
  },
  {
    label: "Circuit",
    viewBox: "0 0 400 400",
    paths: [
      "M50,200 L150,200 L150,100 L250,100 L250,200 L350,200",
      "M200,50 L200,150 L100,150 L100,250 L200,250 L200,350",
      "M50,100 L100,100 L100,50",
      "M300,300 L350,300 L350,350",
      "M300,50 L350,50 L350,100 L300,100",
      "M50,300 L100,300 L100,350 L50,350",
    ],
  },
];

export default function SvgDraw() {
  const containerRef = useRef<HTMLDivElement>(null);
  const [shapeIdx, setShapeIdx] = useState(0);
  const [dark, setDark] = useState(true);
  const [duration, setDuration] = useState(2);
  const [key, setKey] = useState(0);

  const shape = SHAPES[shapeIdx];

  useGSAP(
    () => {
      const paths = gsap.utils.toArray<SVGPathElement>(".draw-path");

      gsap.set(paths, { drawSVG: "0%" });

      const tl = gsap.timeline({ delay: 0.3 });

      paths.forEach((path, i) => {
        tl.to(
          path,
          {
            drawSVG: "0% 100%",
            duration,
            ease: "power2.inOut",
          },
          i * 0.15
        );
      });
    },
    { scope: containerRef, dependencies: [key, shapeIdx, duration] }
  );

  const replay = () => setKey((k) => k + 1);

  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 */}
      <svg
        key={`${shapeIdx}-${key}`}
        viewBox={shape.viewBox}
        style={{
          width: "min(60vw, 500px)",
          height: "min(60vw, 500px)",
        }}
        fill="none"
      >
        {shape.paths.map((d, i) => (
          <path
            key={i}
            className="draw-path"
            d={d}
            stroke={dark ? "#fff" : "#1a1a2e"}
            strokeWidth={2}
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        ))}
      </svg>

      {/* 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",
          }}
        >
          SVG Draw
        </span>

        <div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 6 }}>
          {SHAPES.map((s, i) => (
            <button
              key={s.label}
              onClick={() => {
                setShapeIdx(i);
                setKey((k) => k + 1);
              }}
              style={{
                fontFamily: "var(--font-flux), sans-serif",
                fontVariationSettings: "'wght' 300, 'SRIF' 100",
                fontSize: 11,
                letterSpacing: "0.06em",
                textAlign: "left",
                padding: "6px 10px",
                borderRadius: 8,
                border:
                  i === shapeIdx
                    ? dark
                      ? "1px solid rgba(255,255,255,0.3)"
                      : "1px solid rgba(26,26,46,0.2)"
                    : dark
                    ? "1px solid rgba(255,255,255,0.08)"
                    : "1px solid rgba(0,0,0,0.06)",
                background:
                  i === shapeIdx
                    ? 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>

        {/* Duration slider */}
        <div style={{ marginTop: 16 }}>
          <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4 }}>
            <span
              style={{
                fontFamily: "var(--font-flux), sans-serif",
                fontVariationSettings: "'wght' 400, 'SRIF' 0",
                fontSize: 11,
                color: dark ? "#fff" : "#1a1a2e",
              }}
            >
              Duration
            </span>
            <span
              style={{
                fontFamily: "var(--font-flux), sans-serif",
                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.3}
            max={5}
            step={0.1}
            value={duration}
            onChange={(e) => {
              setDuration(parseFloat(e.target.value));
              replay();
            }}
            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>

        <button
          onClick={replay}
          style={{
            width: "100%",
            padding: "8px 0",
            marginTop: 8,
            borderRadius: 8,
            border: dark
              ? "1px solid rgba(255,255,255,0.2)"
              : "1px solid rgba(26,26,46,0.1)",
            background: "transparent",
            color: dark ? "rgba(255,255,255,0.4)" : "rgba(26,26,46,0.4)",
            fontFamily: "var(--font-flux), sans-serif",
            fontVariationSettings: "'wght' 300, 'SRIF' 100",
            fontSize: 11,
            letterSpacing: "0.08em",
            textTransform: "uppercase" as const,
            cursor: "pointer",
          }}
        >
          Replay
        </button>
      </div>

      {/* Nav */}
      <div className="fixed bottom-6 left-6" style={{ zIndex: 20 }}>
        <ScrambleLink
          from="SVG DRAW"
          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