agency-web/app/components/ProcessLoop.tsx

156 lines
5.9 KiB
TypeScript

"use client";
/**
* PROCESS — "The Feedback Loop" as pinned scroll storytelling.
* - GSAP ScrollTrigger pins the panel while the user scrolls through 4 steps.
* - A single SVG glyph MORPHS between 4 shapes (MorphSVGPlugin) — magnifier
* (Audit) -> route/plan -> bolt (Execute) -> chart (Report) — the animated-SVG
* moment for this section.
* - A progress rail + numbered steps light up in sync (scrub).
* - prefers-reduced-motion: no pin, no morph; steps render as a static list.
*/
import { useEffect, useRef } from "react";
// Side-effect import registers ScrollTrigger + MorphSVGPlugin; gsap exposes both.
import { gsap } from "./gsap";
import { processSteps } from "../content";
// Four target paths for the morph (drawn on a 100x100 canvas).
const SHAPES = [
// Audit — magnifying glass
"M44 20a24 24 0 1 0 15 43l18 18 7-7-18-18A24 24 0 0 0 44 20Zm0 10a14 14 0 1 1 0 28 14 14 0 0 1 0-28Z",
// Plan — connected route / nodes
"M22 30a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm40 40a8 8 0 1 1 16 0 8 8 0 0 1-16 0ZM30 38v8a16 16 0 0 0 16 16h8a16 16 0 0 1 16 16v-2 2h-8a26 26 0 0 1-26-26v-8Z",
// Execute — lightning bolt
"M55 14 26 58h20l-6 30 32-46H50l9-28Z",
// Report — bar chart trending up
"M22 78V52h12v26Zm22 0V36h12v42Zm22 0V20h12v58ZM20 30 40 22l16 8 26-14",
];
export default function ProcessLoop() {
const root = useRef<HTMLElement>(null);
useEffect(() => {
const el = root.current;
if (!el) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
const ctx = gsap.context(() => {
const morph = el.querySelector<SVGPathElement>(".loop-glyph__path");
if (!morph) return;
const steps = gsap.utils.toArray<HTMLElement>(".loop-step");
const total = SHAPES.length;
// One pinned, scrubbed timeline drives the morph + rotation + rail fill.
const tl = gsap.timeline({
scrollTrigger: {
trigger: ".loop-pin",
start: "top top",
end: () => `+=${window.innerHeight * (total - 0.2)}`,
pin: true,
scrub: 0.6,
anticipatePin: 1,
onUpdate: (self) => {
// Robust, independent active-step highlight tied to raw progress.
const idx = Math.min(
total - 1,
Math.floor(self.progress * total + 0.0001)
);
steps.forEach((s, j) => s.classList.toggle("is-active", j === idx));
},
},
});
steps[0]?.classList.add("is-active");
const counter = el.querySelector<HTMLElement>(".loop__count-cur");
const node = el.querySelector<HTMLElement>(".loop-rail__node");
SHAPES.forEach((shape, i) => {
if (i === 0) return; // start shape already in markup
const at = i - 1;
// morph the glyph + a counter-rotation so it tumbles as it transforms,
// and grow the rail fill (+ a node that rides the fill edge).
tl.to(morph, { morphSVG: shape, duration: 1, ease: "power2.inOut" }, at)
.to(".loop-glyph", { rotate: i * 5, duration: 1, ease: "power2.inOut" }, at)
.to(
".loop-rail__fill",
{ scaleY: i / (total - 1), duration: 1, ease: "none" },
at
)
.to(
node,
{ top: `${(i / (total - 1)) * 100}%`, duration: 1, ease: "none" },
at
);
});
// separate light update so the counter is robust at every scroll position
tl.eventCallback("onUpdate", () => {
const idx = Math.min(total, Math.floor(tl.progress() * total) + 1);
if (counter) counter.textContent = String(idx).padStart(2, "0");
});
}, el);
return () => ctx.revert();
}, []);
return (
<section ref={root} id="process" className="loop frame" data-invert aria-labelledby="loop-h">
<div className="loop-pin">
<div className="wrap loop__inner">
<header className="sec-head loop__head">
<div>
<p className="kicker">
<span className="kicker__dot" />
The Feedback Loop
</p>
<h2 id="loop-h" className="display sec-head__title">
How it works
</h2>
</div>
<p className="loop__count" aria-hidden="true">
<span className="loop__count-cur">01</span>
<span className="loop__count-sep">/</span>
<span className="loop__count-tot">0{processSteps.length}</span>
</p>
</header>
<div className="loop__stage">
{/* morphing glyph */}
<div className="loop-glyph" aria-hidden="true">
<svg viewBox="0 0 100 100">
<path className="loop-glyph__path" d={SHAPES[0]} fill="url(#loopGrad)" />
<defs>
<linearGradient id="loopGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="50%" stopColor="#8b5cf6" />
<stop offset="100%" stopColor="#10b981" />
</linearGradient>
</defs>
</svg>
</div>
{/* steps + progress rail */}
<div className="loop__steps">
<div className="loop-rail" aria-hidden="true">
<span className="loop-rail__fill" />
<span className="loop-rail__node" />
</div>
<ol>
{processSteps.map((p) => (
<li className="loop-step" key={p.n}>
<span className="loop-step__n">{p.n}</span>
<div>
<h3 className="loop-step__name">{p.name}</h3>
<p className="loop-step__desc">{p.desc}</p>
</div>
</li>
))}
</ol>
</div>
</div>
</div>
</div>
</section>
);
}