"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(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(".loop-glyph__path"); if (!morph) return; const steps = gsap.utils.toArray(".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(".loop__count-cur"); const node = el.querySelector(".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 (

The Feedback Loop

How it works

{/* morphing glyph */} {/* steps + progress rail */}
    {processSteps.map((p) => (
  1. {p.n}

    {p.name}

    {p.desc}

  2. ))}
); }