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→