Parallax Image for React
Scroll-linked parallax effect for images or content. Moves children on scroll for depth.
Component
↓ scroll
















"use client";
import { motion, useMotionValue } from "framer-motion";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
interface ParallaxImageProps {
children: React.ReactNode;
className?: string;
childrenClassName?: string;
intensity?: number;
containerRef?: React.RefObject<HTMLElement | null>;
}
export function ParallaxImage({
children,
className,
childrenClassName,
intensity = 50,
containerRef,
}: ParallaxImageProps) {
const ref = useRef<HTMLDivElement>(null);
const y = useMotionValue(0);
useEffect(() => {
const el = ref.current;
if (!el) {
return;
}
const scrollEl: HTMLElement | Window =
containerRef?.current ?? window;
const update = () => {
const containerTop = containerRef?.current
? containerRef.current.getBoundingClientRect().top
: 0;
const containerHeight = containerRef?.current
? containerRef.current.clientHeight
: window.innerHeight;
const rect = el.getBoundingClientRect();
const elCenter =
rect.top + rect.height / 2 - containerTop;
const normalized =
(elCenter / containerHeight - 0.5) * 2;
y.set(normalized * intensity);
};
scrollEl.addEventListener("scroll", update, {
passive: true,
});
update();
return () =>
scrollEl.removeEventListener("scroll", update);
}, [containerRef, intensity, y]);
return (
<motion.div
ref={ref}
className={cn("relative overflow-hidden", className)}
>
<motion.div
style={{
bottom: -intensity,
left: 0,
position: "absolute",
right: 0,
top: -intensity,
y,
}}
className={cn(
"[&_img]:size-full [&_img]:object-cover",
childrenClassName,
)}
>
{children}
</motion.div>
</motion.div>
);
}Installation
1. Install dependencies
pnpm add framer-motion2. Copy the component file
"use client";
import { motion, useMotionValue } from "framer-motion";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
interface ParallaxImageProps {
children: React.ReactNode;
className?: string;
childrenClassName?: string;
intensity?: number;
containerRef?: React.RefObject<HTMLElement | null>;
}
export function ParallaxImage({
children,
className,
childrenClassName,
intensity = 50,
containerRef,
}: ParallaxImageProps) {
const ref = useRef<HTMLDivElement>(null);
const y = useMotionValue(0);
useEffect(() => {
const el = ref.current;
if (!el) {
return;
}
const scrollEl: HTMLElement | Window =
containerRef?.current ?? window;
const update = () => {
const containerTop = containerRef?.current
? containerRef.current.getBoundingClientRect().top
: 0;
const containerHeight = containerRef?.current
? containerRef.current.clientHeight
: window.innerHeight;
const rect = el.getBoundingClientRect();
const elCenter =
rect.top + rect.height / 2 - containerTop;
const normalized =
(elCenter / containerHeight - 0.5) * 2;
y.set(normalized * intensity);
};
scrollEl.addEventListener("scroll", update, {
passive: true,
});
update();
return () =>
scrollEl.removeEventListener("scroll", update);
}, [containerRef, intensity, y]);
return (
<motion.div
ref={ref}
className={cn("relative overflow-hidden", className)}
>
<motion.div
style={{
bottom: -intensity,
left: 0,
position: "absolute",
right: 0,
top: -intensity,
y,
}}
className={cn(
"[&_img]:size-full [&_img]:object-cover",
childrenClassName,
)}
>
{children}
</motion.div>
</motion.div>
);
}3. Import and use
import { ParallaxImage } from "@/components/parallax-image";
<ParallaxImage>
<img src="/hero.jpg" alt="Hero" />
</ParallaxImage>;Usage
Import
Add the ParallaxImage import.
import { ParallaxImage } from "@/components/parallax-image";Use
Wrap your image or content.
<ParallaxImage>
<img src="/hero.jpg" alt="Hero" />
</ParallaxImage>;Guidelines
- Give the container a height (e.g. min-h-48, h-64) for the effect to be visible.
- Use intensity to control how far the content moves (default 50px).
- Works with images or any content; images get object-cover by default.
Props
All props are optional unless marked required. Use these to customize every aspect of the component.
| Prop | Type | Default | Description |
|---|---|---|---|
| children | React.ReactNode | - | Content to apply parallax to (typically an image). |
| className | string | - | Additional CSS classes for the container. |
| childrenClassName | string | - | Classes for the inner content wrapper (the moving layer). |
| intensity | number | 50 | Parallax movement in pixels. Higher = more movement. |
| containerRef | React.RefObject<HTMLElement | null> | undefined | Ref to a scrollable overflow container. Omit to track window scroll instead. |
Accessibility
- Decorative visual effect with no interactive controls, no focusable elements, and no keyboard handlers of its own.
- Adds no ARIA roles or labels; any accessible name or alt text must come from the children passed in, such as the wrapped img.
- Does not check prefers-reduced-motion or matchMedia, so the scroll-linked motion runs for everyone; it should be guarded to disable or reduce the transform for users who prefer reduced motion.
- Because the moving content is absolutely positioned and over-sized, ensure wrapped images keep meaningful alt text since the wrapper itself conveys no semantics.
Performance
- Scroll movement is driven entirely by a Framer Motion y motion value rendered as a translateY transform, which is GPU-compositable and avoids layout and paint work.
- The static top, bottom, left, and right offsets are written once as inline styles to over-size the layer and are not animated, so only the cheap transform changes during scroll.
- A single scroll listener is registered as passive and is cleaned up on unmount or when containerRef, intensity, or y change, preventing leaked listeners.
- The update handler runs synchronously on every scroll event and calls getBoundingClientRect, a forced layout read, without requestAnimationFrame throttling, which can be costly on rapid scrolling.
- There is no IntersectionObserver or visibility gating and no will-change hint, so the handler keeps running and reading layout even when the element is off-screen.
Examples
Single image
Wrap a single image for a scroll-driven parallax effect.
↓ scroll

import { ParallaxImage } from "@/components/parallax-image";
export function ParallaxBasic() {
return (
<ParallaxImage className="h-56 w-full">
<img
src="/hero.jpg"
alt="Hero"
className="size-full object-cover"
/>
</ParallaxImage>
);
}With container ref
Pass containerRef when scrolling inside an overflow container instead of the window.
↓ scroll




import { useRef } from "react";
import { ParallaxImage } from "@/components/parallax-image";
const IMAGES = [
{ src: "/img-1.jpg", alt: "Mountain lake" },
{ src: "/img-2.jpg", alt: "Forest path" },
{ src: "/img-3.jpg", alt: "Desert dunes" },
{ src: "/img-4.jpg", alt: "Ocean waves" },
];
export function ParallaxGrid() {
const containerRef = useRef<HTMLDivElement>(null);
return (
<div
ref={containerRef}
className="h-96 overflow-y-auto"
>
<div className="grid grid-cols-2 gap-2 p-2">
{IMAGES.map((img) => (
<ParallaxImage
key={img.alt}
className="h-36 rounded-md"
intensity={40}
containerRef={containerRef}
>
<img
src={img.src}
alt={img.alt}
className="size-full object-cover"
/>
</ParallaxImage>
))}
</div>
</div>
);
}Related reading
- Moving Things Without Moving Them — Parallax via transforms, not scroll jank
- Offloading Motion to the GPU — Keeping parallax smooth on the GPU
- Smooth Scroll in React — Pairing parallax with smooth scrolling
Last updated on Jun 19