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
requestAnimationFrameto 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.