AnimationsIntermediateApril 8, 2026
Parallax Scroll
A section with large headings, letter-by-letter random parallax offsets, and three images drifting upward at different speeds (0, -150px, -250px) as you scroll. Uses Framer Motion useScroll and useTransform.
View Full Demo →Preview
Parallax
Scroll
smooth motion



Source
index.jsx
"use client";
import { useRef, useMemo } from "react";
import Image from "next/image";
import { motion, useScroll, useTransform } from "framer-motion";
import styles from "./styles.module.css";
// Each letter needs its own useTransform — extracted to avoid hooks-in-map
function Letter({ char, progress, yOffset }) {
const y = useTransform(progress, [0, 1], [0, yOffset]);
return (
<motion.span className={styles.letter} style={{ top: y }}>
{char === " " ? "\u00A0" : char}
</motion.span>
);
}
// Each image needs its own y transform
function ParallaxImage({ src, progress, yRange }) {
const y = useTransform(progress, [0, 1], yRange);
return (
<motion.div className={styles.imageContainer} style={{ y }}>
<Image src={src} alt="" fill className={styles.img} sizes="28vw" />
</motion.div>
);
}
export default function ParallaxScroll({ word = "smooth motion", images = [] }) {
const containerRef = useRef(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start end", "end start"],
});
// Pre-compute stable random offsets — computed once, not on every render
const letterOffsets = useMemo(
() => word.split("").map(() => Math.floor(Math.random() * -75) - 25),
// eslint-disable-next-line react-hooks/exhaustive-deps
[word]
);
// Three speed tiers for images
const Y_RANGES = [[0, -50], [0, -150], [0, -250]];
return (
<div ref={containerRef} className={styles.container}>
{/* ── Headings ── */}
<div className={styles.body}>
<motion.h1
className={styles.heading}
style={{ y: useTransform(scrollYProgress, [0, 1], [0, -50]) }}
>
Parallax
</motion.h1>
<h1 className={styles.heading}>Scroll</h1>
{/* ── Letter-by-letter random parallax ── */}
<div className={styles.word}>
<p>
{word.split("").map((char, i) => (
<Letter
key={i}
char={char}
progress={scrollYProgress}
yOffset={letterOffsets[i]}
/>
))}
</p>
</div>
</div>
{/* ── Images at three different parallax speeds ── */}
<div className={styles.images}>
{images.slice(0, 3).map((src, i) => (
<ParallaxImage
key={i}
src={src}
progress={scrollYProgress}
yRange={Y_RANGES[i] ?? [0, -50]}
/>
))}
</div>
</div>
);
}
styles.module.css
.container {
display: flex;
flex-direction: column;
gap: 5vh;
padding: 10vh 5vw;
min-height: 200vh; /* scroll space for the parallax */
background: #0d0d0d;
color: #f5f5f5;
}
/* ── Left column: headings + word ── */
.body {
display: flex;
flex-direction: column;
gap: 0.25em;
}
.heading {
font-size: clamp(3rem, 10vw, 9rem);
font-weight: 700;
letter-spacing: -0.04em;
margin: 0;
line-height: 0.9;
}
.word {
margin-top: 1rem;
}
.word p {
font-size: clamp(1.5rem, 4vw, 3rem);
letter-spacing: 0.05em;
text-transform: uppercase;
margin: 0;
display: flex;
flex-wrap: wrap;
}
/* Each letter uses position: relative so `top` (set via Framer Motion) works */
.letter {
position: relative;
display: inline-block;
}
/* ── Right/bottom: images ── */
.images {
display: flex;
gap: 1.5rem;
align-items: flex-start;
padding-top: 5vh;
}
.imageContainer {
position: relative;
flex: 1;
aspect-ratio: 3 / 4;
overflow: hidden;
border-radius: 0.5rem;
}
.img {
object-fit: cover;
}
@media (max-width: 768px) {
.images {
flex-direction: column;
}
.imageContainer {
width: 100%;
aspect-ratio: 4 / 3;
}
}
Dependencies
framer-motion