Skip to main content

React Parallax Scrolling Guide

Parallax scrolling creates the illusion of depth by moving different layers at different speeds as the user scrolls. Background layers move slower than foreground layers, producing a 3D effect that makes pages feel more immersive. In React, parallax is achieved by calculating the scroll offset, then applying that offset as a transform (usually translateY) to each layer with a speed multiplier. This is a foundational technique for engaging landing pages and storytelling interfaces.

Understanding Parallax Math

Parallax works by applying a fraction of the scroll distance to each layer. If the user scrolls 100px, a background with a 0.5 speed multiplier moves only 50px, creating the illusion of depth. Here's the core calculation: transform: translateY(scrollY * speedFactor). A speed factor of 0 is stationary, 0.5 moves half as fast, and negative values move opposite (counter-parallax).

import { useEffect, useState } from 'react';

export default function SimpleParallax() {
const [scrollY, setScrollY] = useState(0);

useEffect(() => {
const handleScroll = () => {
setScrollY(window.scrollY);
};

window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);

return (
<div style={{ overflow: 'hidden' }}>
{/* Fixed background layer (slowest) */}
<div
style={{
position: 'relative',
height: '600px',
overflow: 'hidden',
backgroundColor: '#1e293b',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: '#0f172a',
transform: `translateY(${scrollY * 0.3}px)`,
backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.1) 1px, transparent 1px)',
backgroundSize: '50px 50px',
}}
/>

{/* Mid layer */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
transform: `translateY(${scrollY * 0.6}px)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<h1 style={{ color: '#fff', fontSize: '48px', textShadow: '0 2px 8px rgba(0,0,0,0.5)' }}>
Parallax Effect
</h1>
</div>

{/* Fast foreground layer */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
transform: `translateY(${scrollY * 0.9}px)`,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
paddingBottom: '60px',
}}
>
<div
style={{
width: '100px',
height: '100px',
backgroundColor: '#3b82f6',
borderRadius: '50%',
opacity: 0.8,
}}
/>
</div>
</div>

{/* Content section */}
<div style={{ padding: '60px 20px', backgroundColor: '#fff', minHeight: '1000px' }}>
<h2>Scroll down to see the parallax effect</h2>
<p>Each layer moves at a different speed, creating depth.</p>
</div>
</div>
);
}

In this example, the background moves at 30% of scroll speed, the title at 60%, and the foreground circle at 90%, creating distinct depth layers.

Parallax with useRef for Performance

For performance, avoid state updates on every scroll. Use a ref to store the scroll position and calculate transforms in a continuous loop with requestAnimationFrame:

import { useEffect, useRef } from 'react';

export default function OptimizedParallax() {
const parallaxRef = useRef(null);
const scrollYRef = useRef(0);
const rafRef = useRef(null);

useEffect(() => {
const handleScroll = () => {
scrollYRef.current = window.scrollY;

if (!rafRef.current) {
rafRef.current = requestAnimationFrame(() => {
updateParallax();
rafRef.current = null;
});
}
};

const updateParallax = () => {
if (!parallaxRef.current) return;

const layers = parallaxRef.current.querySelectorAll('[data-parallax-speed]');
layers.forEach((layer) => {
const speed = parseFloat(layer.dataset.parallaxSpeed);
const offset = scrollYRef.current * speed;
layer.style.transform = `translateY(${offset}px)`;
});
};

window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, []);

return (
<div ref={parallaxRef} style={{ overflow: 'hidden' }}>
<div
style={{
position: 'relative',
height: '500px',
backgroundColor: '#1e293b',
overflow: 'hidden',
}}
>
<div
data-parallax-speed="0.2"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: '#0f172a',
}}
/>
<div
data-parallax-speed="0.5"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '32px',
}}
>
Optimized Parallax
</div>
</div>

<div style={{ padding: '60px 20px', backgroundColor: '#fff', minHeight: '1000px' }}>
<p>Scroll to see parallax with requestAnimationFrame optimization.</p>
</div>
</div>
);
}

This version batches DOM updates with requestAnimationFrame, preventing jank and keeping the main thread responsive. Use data-* attributes to specify parallax speeds, making the code flexible and reusable.

Parallax with Intersection Observer for Viewport-Relative Effects

For parallax limited to specific sections, combine IntersectionObserver with scroll position to create viewport-relative parallax:

import { useEffect, useRef } from 'react';

export default function ViewportParallax() {
const sectionRef = useRef(null);
const parallaxRef = useRef(null);

useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const rect = entry.boundingClientRect;
const viewportProgress = 1 - rect.top / window.innerHeight;
const offset = viewportProgress * 100;

if (parallaxRef.current) {
parallaxRef.current.style.transform = `translateY(${offset}px)`;
}
}
});
},
{ threshold: 0 }
);

if (sectionRef.current) {
observer.observe(sectionRef.current);
}

return () => {
if (sectionRef.current) {
observer.unobserve(sectionRef.current);
}
};
}, []);

return (
<div
ref={sectionRef}
style={{
position: 'relative',
height: '400px',
overflow: 'hidden',
backgroundColor: '#f3f4f6',
}}
>
<div
ref={parallaxRef}
style={{
position: 'absolute',
top: '-100px',
left: 0,
width: '100%',
height: '600px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '28px',
}}
>
Viewport-Relative Parallax
</div>
</div>
);
}

This approach triggers parallax only when the section is in view, reducing unnecessary calculations and improving performance on long pages.

Key Takeaways

  • Parallax creates depth by moving layers at different speeds; use transform: translateY(scrollY * speedFactor) where speed is typically 0.2–0.9.
  • Optimize with refs and requestAnimationFrame to batch DOM updates and avoid state bloat on high-frequency scroll events.
  • Use data attributes (e.g., data-parallax-speed) to decouple speed values from component code, enabling easy tweaking and reusability.
  • Combine IntersectionObserver with parallax for viewport-relative effects that only calculate when the section is visible.

Frequently Asked Questions

How do I prevent parallax from causing layout shift?

Ensure the parallax container has explicit dimensions and overflow is handled. Use will-change: transform in CSS (not in style props) to hint the browser to optimize the layer. Avoid parallax on critical content width calculations.

Can I use parallax with images instead of solid colors?

Absolutely. Set the background-image on the parallax layer, or use an <img> tag inside it. Set object-fit: cover to ensure the image fills the container. Parallax works identically with images and gradients.

What are typical parallax speed values?

Background: 0.2–0.4 (slowest), mid-layer: 0.5–0.7, foreground: 0.8–1.0 (fastest). Use negative values (e.g., -0.3) for counter-parallax (moves opposite to scroll). Start with these ranges and tweak based on your design.

How do I make parallax mobile-friendly?

Mobile scrolling is often smoother and supports ::-webkit-scrollbar, but parallax still works. Reduce parallax speeds on mobile (e.g., 0.5 becomes 0.2) to prevent the effect from feeling too fast. Use media queries: @media (max-width: 768px).

Should I use parallax for accessibility-sensitive content?

Avoid parallax on critical information or for users with vestibular disorders (motion sensitivity). Provide a prefers-reduced-motion media query override that disables parallax entirely for users who opt in.

Further Reading