April 8, 2026IntermediateScroll & Parallax
Zoom Parallax
Five images sit in a sticky viewport. As you scroll through 300vh, each image scales up at a different rate. The center image scales 1→4 to fill the screen; outer images scale faster (up to 1→9), flying past the frame.
Preview





Source
"use client";
import { useRef } from "react";
import Image from "next/image";
import { motion, useScroll, useTransform } from "framer-motion";
import styles from "./styles.module.css";
// Each image element needs its own hook call — extracted to sub-component
function ZoomImage({ src, progress, scaleRange, offsetX, offsetY }) {
const scale = useTransform(progress, [0, 1], scaleRange);
return (
<div className={styles.elWrapper} style={{ marginLeft: offsetX, marginTop: offsetY }}>
<motion.div className={styles.el} style={{ scale }}>
<div className={styles.imageContainer}>
<Image src={src} alt="" fill className={styles.img} sizes="25vw" />
</div>
</motion.div>
</div>
);
}
// 5 images: scale ranges + offsets from sticky center
const PICTURES = [
{ scaleRange: [1, 4], offsetX: "0", offsetY: "0" },
{ scaleRange: [1, 5], offsetX: "-27.5vw", offsetY: "-25vh" },
{ scaleRange: [1, 6], offsetX: "27.5vw", offsetY: "-30vh" },
{ scaleRange: [1, 8], offsetX: "-22.5vw", offsetY: "20vh" },
{ scaleRange: [1, 9], offsetX: "25vw", offsetY: "15vh" },
];
export default function ZoomParallax({ images = [] }) {
const containerRef = useRef(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start start", "end end"],
});
const srcs = images.length >= 5 ? images : [...images, ...images, ...images].slice(0, 5);
return (
<div ref={containerRef} className={styles.container}>
<div className={styles.sticky}>
{PICTURES.map((pic, i) => (
<ZoomImage
key={i}
src={srcs[i]}
progress={scrollYProgress}
scaleRange={pic.scaleRange}
offsetX={pic.offsetX}
offsetY={pic.offsetY}
/>
))}
</div>
</div>
);
}
Dependencies
framer-motion