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.
Source
"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>
);
}