Skip to content
ARDENT / MOTION LAB
Library

Scroll Storytelling · Intermediate

Sticky Image Sequence

CSS sticky holds the stage; ScrollTrigger maps progress to a frame index. Frames crossfade with a 500ms opacity transition.

Live preview

Scroll through five stages; the sticky frame crossfades. The preview scrolls independently of this page.

Copy-ready · type-checkedThe preview above renders this exact file. Reduced-motion fallback included.
/**
 * Sticky Image Sequence
 * Five photographic studies unfold inside a sticky editorial composition.
 * 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 frames = [
  {
    seed: "material",
    title: "Raw material",
    copy: "Every finished thing begins as an open question.",
  },
  {
    seed: "gesture",
    title: "First gesture",
    copy: "An instinct becomes a mark. A mark becomes a direction.",
  },
  {
    seed: "structure",
    title: "Finding structure",
    copy: "Relationships emerge. The idea learns how to stand.",
  },
  {
    seed: "surface",
    title: "Surface & detail",
    copy: "The smallest decisions give the whole its character.",
  },
  {
    seed: "object",
    title: "A finished thought",
    copy: "Nothing more to add. Nothing left to take away.",
  },
];

export default function StickyImageSequence({
  reduced: reducedProp,
  intensity = 1,
}: {
  reduced?: boolean;
  intensity?: number;
}) {
  const systemReduced = useSystemReduced();
  const reduced = reducedProp ?? systemReduced;
  const scopeRef = useRef<HTMLElement>(null);
  const [frame, setFrame] = useState(0);
  const strength = Math.min(1.5, Math.max(0.5, intensity));
  const active = frames[frame] ?? frames[0];

  useEffect(() => {
    if (reduced || !scopeRef.current) return;
    gsap.registerPlugin(ScrollTrigger);
    const ctx = gsap.context(() => {
      const images = gsap.utils.toArray<HTMLElement>(".sequence-image");
      gsap.set(images, { autoAlpha: 0 });
      if (images[0]) gsap.set(images[0], { autoAlpha: 1 });
      const timeline = gsap.timeline({
        scrollTrigger: {
          trigger: ".sequence-runway",
          start: "top top",
          end: "bottom bottom",
          scrub: 0.5 * strength,
          invalidateOnRefresh: true,
        },
        onUpdate: () =>
          setFrame(Math.min(frames.length - 1, Math.floor(timeline.progress() * frames.length))),
      });
      images.forEach((image, index) => {
        if (index === 0) return;
        const previous = images[index - 1];
        const position = index - 0.2;
        if (previous) timeline.to(previous, { autoAlpha: 0, duration: 0.4 }, position);
        timeline.fromTo(
          image,
          { autoAlpha: 0, scale: 1 + 0.04 * strength },
          { autoAlpha: 1, scale: 1, duration: 0.6, ease: "none" },
          position,
        );
      });
      timeline.to({}, { duration: 0.8 });
    }, scopeRef);
    const timer = window.setTimeout(() => ScrollTrigger.refresh(), 250);
    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">
          Anatomy of an idea
        </p>
        <h1 className="mt-6 font-serif text-6xl tracking-tight md:text-8xl">Becoming, slowly.</h1>
        <p className="mt-7 max-w-md text-lg text-muted-foreground">
          Five moments from the space between an intention and an object.
        </p>
      </header>
      <section
        className={`sequence-runway ${reduced ? "px-6" : "relative h-[420svh]"}`}
        aria-label="Making an object"
      >
        {reduced ? (
          <div className="mx-auto grid max-w-6xl gap-12 md:grid-cols-2">
            {frames.map((item, index) => (
              <figure key={item.seed}>
                <img
                  src={`https://picsum.photos/seed/ardent-${item.seed}/1000/1000`}
                  alt={`Visual study of ${item.title.toLowerCase()}`}
                  className="aspect-square w-full object-cover"
                  width={1000}
                  height={1000}
                  loading="lazy"
                />
                <figcaption className="mt-5">
                  <p className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
                    Study 0{index + 1}
                  </p>
                  <h2 className="mt-3 font-serif text-3xl tracking-tight">{item.title}</h2>
                  <p className="mt-3 text-muted-foreground">{item.copy}</p>
                </figcaption>
              </figure>
            ))}
          </div>
        ) : (
          <div className="sticky top-0 flex min-h-[100svh] items-center px-6 py-8">
            <div className="mx-auto grid w-full max-w-6xl items-center gap-8 md:grid-cols-2 md:gap-16">
              <div className="relative mx-auto aspect-square max-h-[48svh] w-full overflow-hidden bg-muted md:max-h-none">
                {frames.map((item) => (
                  <img
                    key={item.seed}
                    src={`https://picsum.photos/seed/ardent-${item.seed}/1000/1000`}
                    alt={`Visual study of ${item.title.toLowerCase()}`}
                    className="sequence-image absolute inset-0 h-full w-full object-cover"
                    width={1000}
                    height={1000}
                    onLoad={() => ScrollTrigger.refresh()}
                  />
                ))}
              </div>
              <div aria-live="polite" aria-atomic="true">
                <p className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
                  Study {String(frame + 1).padStart(2, "0")} / 05
                </p>
                <h2 className="mt-5 font-serif text-4xl tracking-tight md:text-7xl">
                  {active?.title}
                </h2>
                <p className="mt-5 max-w-md leading-relaxed text-muted-foreground">
                  {active?.copy}
                </p>
                <div
                  role="progressbar"
                  aria-label="Making progress"
                  aria-valuemin={0}
                  aria-valuemax={5}
                  aria-valuenow={frame + 1}
                  className="mt-8 flex gap-2"
                >
                  {frames.map((item, index) => (
                    <span
                      key={item.seed}
                      className={`h-px flex-1 ${index <= frame ? "bg-accent" : "bg-muted"}`}
                    />
                  ))}
                </div>
              </div>
            </div>
          </div>
        )}
      </section>
      <footer className="px-6 py-[25vh] text-center text-muted-foreground">
        Made with time. Made to last.
      </footer>
    </main>
  );
}
Next patternBefore / After Reveal→