Skip to content
ARDENT / MOTION LAB
Library

Scroll Storytelling · Intermediate

Scroll-Scrub Line Draw

The path's dash offset is scrubbed against a pinned section. ScrollTrigger's onUpdate feeds a live percentage readout.

Live preview

Scroll to draw the line; scroll back to unwind it. The preview scrolls independently of this page.

Copy-ready · type-checkedThe preview above renders this exact file. Reduced-motion fallback included.
/**
 * Scroll-Scrub Line Draw
 * A continuous SVG journey draws in sync with a pinned percentage readout.
 * Requires: gsap, Tailwind CSS with shadcn semantic tokens.
 */
import { useEffect, useRef, useState } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

function useSystemReduced() {
  const [value, setValue] = useState(true);
  useEffect(() => {
    const query = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => setValue(query.matches);
    update();
    query.addEventListener("change", update);
    return () => query.removeEventListener("change", update);
  }, []);
  return value;
}

export default function ScrollScrubProgress({
  reduced: reducedProp,
  intensity = 1,
}: {
  reduced?: boolean;
  intensity?: number;
}) {
  const systemReduced = useSystemReduced();
  const reduced = reducedProp ?? systemReduced;
  const scopeRef = useRef<HTMLElement>(null);
  const [percentage, setPercentage] = useState(0);
  const strength = Math.min(1.5, Math.max(0.5, intensity));
  const displayed = reduced ? 100 : percentage;

  useEffect(() => {
    if (reduced || !scopeRef.current) return;
    gsap.registerPlugin(ScrollTrigger);
    const ctx = gsap.context(() => {
      const path = scopeRef.current?.querySelector<SVGPathElement>(".journey-path");
      if (!path) return;
      const length = path.getTotalLength();
      const timeline = gsap.timeline({
        scrollTrigger: {
          trigger: ".journey-stage",
          start: "top top",
          end: "+=180%",
          pin: true,
          scrub: 0.6 * strength,
          invalidateOnRefresh: true,
        },
        onUpdate: () => setPercentage(Math.round(timeline.progress() * 100)),
      });
      timeline.fromTo(
        path,
        { strokeDasharray: length, strokeDashoffset: length },
        { strokeDashoffset: 0, duration: 1, ease: "none" },
      );
      timeline.fromTo(
        ".journey-number",
        { y: 20 * strength },
        { y: -20 * strength, duration: 1, ease: "none" },
        0,
      );
    }, scopeRef);
    const timer = window.setTimeout(() => ScrollTrigger.refresh(), 200);
    return () => {
      window.clearTimeout(timer);
      ctx.revert();
    };
  }, [reduced, strength]);

  return (
    <main ref={scopeRef} className="bg-background text-foreground">
      <header className="mx-auto max-w-6xl px-6 py-[18vh]">
        <p className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
          A study in continuity
        </p>
        <h1 className="mt-6 max-w-3xl font-serif text-5xl tracking-tight md:text-8xl">
          Every journey begins with a line.
        </h1>
        <p className="mt-7 text-muted-foreground">
          Scroll to trace the passage from possibility to completion.
        </p>
      </header>
      <section className="journey-stage flex min-h-[100svh] flex-col justify-center px-6 py-12">
        <div className="mx-auto w-full max-w-6xl">
          <div className="flex items-end justify-between gap-6 border-b border-border pb-6">
            <h2 className="font-serif text-3xl tracking-tight md:text-5xl">A work in motion</h2>
            <span className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
              01 — 100
            </span>
          </div>
          <svg viewBox="0 0 800 260" className="my-10 w-full text-accent" aria-hidden="true">
            <path
              d="M10 210 C100 210 110 35 225 45 S350 240 450 155 S610 5 790 55"
              fill="none"
              stroke="currentColor"
              strokeWidth="1"
              className="text-muted-foreground"
              opacity="0.25"
            />
            <path
              className="journey-path"
              d="M10 210 C100 210 110 35 225 45 S350 240 450 155 S610 5 790 55"
              fill="none"
              stroke="currentColor"
              strokeWidth="2"
            />
          </svg>
          <div className="journey-number flex items-end justify-between gap-4">
            <p
              className="font-serif text-8xl tracking-tight tabular-nums md:text-[10rem]"
              aria-live="polite"
              aria-atomic="true"
            >
              {displayed}
              <span className="text-4xl text-muted-foreground">%</span>
            </p>
            <p className="pb-4 font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
              Keep moving
            </p>
          </div>
          <div
            role="progressbar"
            aria-label="Journey completion"
            aria-valuemin={0}
            aria-valuemax={100}
            aria-valuenow={displayed}
            className="mt-8 h-px bg-muted"
          >
            <div
              className="h-full origin-left bg-accent"
              style={{ transform: `scaleX(${displayed / 100})` }}
            />
          </div>
        </div>
      </section>
      <footer className="px-6 py-[25vh] text-center text-muted-foreground">
        A line becomes a direction.
      </footer>
    </main>
  );
}
Next patternSplit Text Reveal→