parallax /
Scroll-scrubbed Image Swap
Sticky section. As the user scrolls through it, the background swaps between N images. The visitor scrubs through the story.
Preview
Source
tsx
"use client";
import { useEffect, useRef, useState } from "react";
export interface ParallaxImageSwapProps {
/** 2–6 images. The section's scroll is divided evenly between them. */
images?: { src: string; caption?: string }[];
eyebrow?: string;
headline?: string;
}
export default function ParallaxImageSwap({
images = [
{ src: "/heroes/landscape-card-bg.webp", caption: "Forest" },
{ src: "/heroes/crouchingtiger/bg.webp", caption: "Outdoor" },
{ src: "/heroes/sba/bg-2.jpg", caption: "Architectural" },
{ src: "/heroes/cmillworks/walnut.webp", caption: "Texture" },
],
eyebrow = "Parallax · 12",
headline = "Scroll-scrubbed image swap",
}: ParallaxImageSwapProps) {
const sectionRef = useRef<HTMLElement | null>(null);
const [active, setActive] = useState(0);
useEffect(() => {
let raf = 0;
let visible = false;
const io = new IntersectionObserver(
(entries) => {
visible = entries[0]?.isIntersecting ?? false;
},
{ threshold: 0 }
);
if (sectionRef.current) io.observe(sectionRef.current);
const onScroll = () => {
if (!visible || !sectionRef.current) return;
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const rect = sectionRef.current!.getBoundingClientRect();
const winH = window.innerHeight;
const total = rect.height - winH;
const p = Math.max(0, Math.min(0.999, -rect.top / total));
const idx = Math.floor(p * images.length);
setActive(idx);
});
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
io.disconnect();
window.removeEventListener("scroll", onScroll);
cancelAnimationFrame(raf);
};
}, [images.length]);
return (
<section
ref={sectionRef}
className="relative isolate w-full bg-slate-950"
style={{ height: `${images.length * 100}svh` }}
>
<div className="sticky top-0 h-screen w-full overflow-hidden">
{/* All images stacked; opacity controlled by active index. */}
{images.map((img, i) => (
<div
key={img.src}
className="absolute inset-0 transition-opacity duration-700 ease-out"
style={{
backgroundImage: `url(${img.src})`,
backgroundSize: "cover",
backgroundPosition: "center",
opacity: i === active ? 1 : 0,
}}
/>
))}
<div className="absolute inset-0 bg-gradient-to-b from-black/40 via-black/15 to-black/75" />
{/* Sticky overlay content */}
<div className="relative z-10 flex h-full w-full items-center justify-center px-6 text-center text-white">
<div className="mx-auto max-w-3xl">
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.3em] text-white/70">
{eyebrow}
</p>
<h1 className="text-balance text-4xl font-semibold leading-[1.1] tracking-tight sm:text-6xl">
{headline}
</h1>
<p className="mx-auto mt-8 text-base text-white/80 sm:text-lg">
<span className="font-mono text-xs tracking-widest">
{String(active + 1).padStart(2, "0")} / {String(images.length).padStart(2, "0")}
</span>
{images[active]?.caption ? (
<>
<span className="mx-3 text-white/40">·</span>
{images[active].caption}
</>
) : null}
</p>
{/* Progress dots */}
<div className="mt-6 flex items-center justify-center gap-2">
{images.map((_, i) => (
<span
key={i}
className={`h-1 rounded-full transition-all duration-500 ${
i === active ? "w-8 bg-white" : "w-2 bg-white/40"
}`}
/>
))}
</div>
</div>
</div>
</div>
</section>
);
} Claude Code Instructions
CLI Install
npx innovations add image-swapWhere to use it
Sticky section with N viewports of scroll runway. The visible image switches based on scroll progress, with a 700ms cross-fade. The overlay (headline, counter, progress dots) stays put.
CONFIGURE:
- images: pass 2–6 images with optional captions. Section height auto-scales to images.length × 100svh.
- The overlay's caption updates with the active image so visitors get context for each.
WHEN TO USE:
- Portfolio walkthroughs (project 1, 2, 3, 4)
- Process pages (before / during / after)
- Product variants showcase
PERFORMANCE:
- All images are mounted as background-images so the browser preloads them. For 4+ large images, this is 1–4 MB of preload — consider lazy-mounting (mount only active + neighbors) if image weight matters.
- rAF-throttled + IntersectionObserver-gated.
- The 700ms transition is forgiving enough to hide a slightly delayed setState; reduce to 400ms for snappier feel.