parallax /
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-zoomWhere 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.