Innovations

Scroll-driven Scale

The image zooms in as the user scrolls through the section. Coupled to scroll position — different feel from Ken Burns.

Preview

Source

tsx
"use client";

import { useEffect, useRef } from "react";

export interface ParallaxScaleZoomProps {
  imageSrc?: string;
  /** Max zoom multiplier reached at end of section. */
  maxScale?: number;
  eyebrow?: string;
  headline?: string;
  subhead?: string;
}

export default function ParallaxScaleZoom({
  imageSrc = "/heroes/landscape-card-bg.webp",
  maxScale = 1.35,
  eyebrow = "Parallax · 10",
  headline = "Scroll-driven scale",
  subhead =
    "The image zooms IN as you scroll the section. Reverse of Ken Burns — coupled to scroll position, so the viewer drives the motion.",
}: ParallaxScaleZoomProps) {
  const sectionRef = useRef<HTMLElement | null>(null);
  const imgRef = useRef<HTMLDivElement | null>(null);

  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 || !imgRef.current) return;
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(() => {
        const rect = sectionRef.current!.getBoundingClientRect();
        const winH = window.innerHeight;
        // progress: 0 when section top hits viewport top, 1 when section bottom hits viewport top
        const total = rect.height + winH;
        const p = Math.max(0, Math.min(1, (winH - rect.top) / total));
        const scale = 1 + (maxScale - 1) * p;
        imgRef.current!.style.transform = `scale(${scale})`;
      });
    };

    onScroll();
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      io.disconnect();
      window.removeEventListener("scroll", onScroll);
      cancelAnimationFrame(raf);
    };
  }, [maxScale]);

  return (
    <section
      ref={sectionRef}
      className="relative isolate flex min-h-[120svh] w-full items-center justify-center overflow-hidden bg-slate-900"
    >
      <div
        ref={imgRef}
        className="absolute inset-0 -z-10 will-change-transform"
        style={{
          backgroundImage: `url(${imageSrc})`,
          backgroundSize: "cover",
          backgroundPosition: "center",
          transformOrigin: "center",
        }}
      />
      <div className="absolute inset-0 -z-0 bg-gradient-to-b from-black/40 via-black/20 to-black/75" />
      <div className="relative z-10 mx-auto max-w-3xl px-6 text-center text-white">
        <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-6 max-w-xl text-base leading-relaxed text-white/80 sm:text-lg">
          {subhead}
        </p>
      </div>
    </section>
  );
}
Claude Code Instructions

CLI Install

npx innovations add scale-zoom

Where to use it

Scroll position drives scale, not time. The image starts at 1.0 and ends at maxScale (default 1.35) when the section has fully scrolled past. WHEN TO USE: - 'Falling into the photo' moments — top of long-form articles, before/after reveals - Pairs well with a translate-y on the foreground text (text moves up while bg zooms in) - Background details for hero quotes / pull quotes TUNING: - maxScale 1.2 = subtle, 1.35 = noticeable, 1.6+ = dramatic / aggressive - transformOrigin defaults to center; switch to '50% 100%' to zoom into the bottom (closer to viewer's feet) or to a hotspot for storytelling effect The scale-only transform is GPU-cheap. rAF-throttled + IntersectionObserver-gated for performance.