Scroll Storytelling · Advanced
Pinned Scrollytelling
The stage pins to the viewport while a scrubbed timeline crossfades chapters and fills a progress hairline. Scroll distance is proportional to the number of steps, so pacing stays consistent.
Live preview
Scroll slowly; the stage pins while four chapters advance. The preview scrolls independently of this page.
Copy-ready · type-checkedThe preview above renders this exact file. Reduced-motion fallback included.
/**
* Pinned Scrollytelling
* A quiet, four-chapter narrative with a pinned editorial stage.
* 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;
}
const chapters = [
[
"Observe",
"Begin with attention. Study the small details that make a place, a person, a moment unmistakable.",
],
[
"Compose",
"Find the relationship between things. Leave enough space for the unexpected to belong.",
],
[
"Refine",
"Remove what competes. Adjust the rhythm until every remaining gesture feels inevitable.",
],
[
"Release",
"Let the work meet the world. Thoughtfully made, quietly confident, ready to become something more.",
],
];
export default function PinnedScrollytelling({
reduced: reducedProp,
intensity = 1,
}: {
reduced?: boolean;
intensity?: number;
}) {
const systemReduced = useSystemReduced();
const reduced = reducedProp ?? systemReduced;
const scopeRef = useRef<HTMLElement>(null);
const [chapter, setChapter] = useState(0);
const strength = Math.min(1.5, Math.max(0.5, intensity));
useEffect(() => {
if (reduced || !scopeRef.current) return;
gsap.registerPlugin(ScrollTrigger);
const ctx = gsap.context(() => {
const panels = gsap.utils.toArray<HTMLElement>(".chapter");
gsap.set(panels.slice(1), { autoAlpha: 0, y: 48 * strength });
const timeline = gsap.timeline({
scrollTrigger: {
trigger: ".story-stage",
start: "top top",
end: "+=280%",
pin: true,
scrub: 0.7 * strength,
invalidateOnRefresh: true,
onUpdate: (self) => setChapter(Math.min(3, Math.floor(self.progress * 4))),
},
});
panels.forEach((panel, index) => {
const previous = panels[index - 1];
if (!previous) return;
timeline
.to(previous, { autoAlpha: 0, y: -48 * strength, duration: 0.6 })
.to(panel, { autoAlpha: 1, y: 0, duration: 0.8 }, "<0.2");
});
timeline.to({}, { duration: 0.4 });
gsap.fromTo(
".story-line",
{ scaleX: 0 },
{
scaleX: 1,
ease: "none",
scrollTrigger: {
trigger: ".story-stage",
start: "top top",
end: "+=280%",
scrub: 0.7 * strength,
invalidateOnRefresh: true,
},
},
);
}, 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">
Studio notes / 001
</p>
<h1 className="mt-6 max-w-3xl font-serif text-5xl tracking-tight md:text-8xl">
A practice of paying attention.
</h1>
<p className="mt-8 text-muted-foreground">
Four chapters. One considered process. Scroll to begin.
</p>
</header>
<section
className={`story-stage flex flex-col justify-center px-6 ${reduced ? "py-20" : "min-h-[100svh]"}`}
aria-label="Our process"
>
<div className="mx-auto grid w-full max-w-6xl gap-10 md:grid-cols-[1fr_2fr]">
<div>
<p className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
The process
</p>
<p className="mt-5 font-serif text-6xl tracking-tight text-accent" aria-live="polite">
{reduced ? "04" : String(chapter + 1).padStart(2, "0")}
<span className="text-2xl text-muted-foreground"> / 04</span>
</p>
</div>
<div className={reduced ? "space-y-16" : "relative min-h-[45svh]"}>
{chapters.map(([title, copy], index) => (
<article
key={title}
className={reduced ? "" : "chapter absolute inset-0 flex flex-col justify-center"}
>
<p className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
Chapter {String(index + 1).padStart(2, "0")}
</p>
<h2 className="mt-5 font-serif text-5xl tracking-tight sm:text-7xl md:text-8xl">
{title}
</h2>
<p className="mt-7 max-w-lg text-lg leading-relaxed text-muted-foreground">
{copy}
</p>
</article>
))}
</div>
</div>
<div className="mx-auto mt-12 h-px w-full max-w-6xl bg-muted" aria-hidden="true">
<div className="story-line h-full origin-left bg-accent" />
</div>
</section>
<footer className="px-6 py-[25vh] text-center text-muted-foreground">
The next chapter is yours.
</footer>
</main>
);
}
Next patternEditorial Image Parallax→