Innovations

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-swap

Where 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.