Skip to content
ARDENT / MOTION LAB
Library

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→