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→