Build a Smooth Parallax Scroll Effect in React (From Scratch + Library Options)
Build a smooth, accessible React parallax scroll effect using CSS, a custom hook, and a library option. Includes performance and a11y tips.
Image used for representation purposes only.
Overview
Parallax is a depth illusion: foreground elements move faster than the background as you scroll, creating a sense of 3D space. In React, you can implement this effect with pure CSS, a tiny custom hook, or a dedicated library. This tutorial walks you through all three, with performance and accessibility in mind.
What you’ll build:
- A simple background parallax hero with CSS only
- A reusable React hook for per-element parallax with requestAnimationFrame
- A layered scene (foreground, midground, background)
- A library-based solution for production-ready features
Requirements:
- React 18+
- Basic CSS
Project setup
Use Vite for a fast React setup.
# Node 18+
npm create vite@latest react-parallax -- --template react
cd react-parallax
npm install
npm run dev
Create src/components for our parallax pieces.
Approach 1: CSS-only background parallax (quick win)
This approach uses background-attachment and background-position to create depth on a fixed background image. It’s zero-JS and great for hero sections. Note: iOS Safari historically limits background-attachment: fixed; we’ll include a fallback.
// src/components/HeroParallax.jsx
export default function HeroParallax() {
return (
<section className="hero">
<div className="hero__content">
<h1>Parallax in React</h1>
<p>Smooth, accessible depth effects on scroll.</p>
</div>
</section>
);
}
/* src/index.css */
.hero {
min-height: 100vh;
display: grid;
place-items: center;
color: white;
text-align: center;
background-image: url('/mountains.jpg');
background-size: cover;
background-position: center;
background-attachment: fixed; /* key for parallax */
}
/* iOS fallback: when fixed is ignored, soften the effect with a subtle overlay */
@supports (-webkit-touch-callout: none) {
.hero { background-attachment: scroll; }
}
Pros
- No JavaScript, minimal layout cost
Cons
- Limited to background images
- Mobile Safari quirks; not ideal for per-element effects
Approach 2: A reusable React parallax hook (from scratch)
We’ll build a hook that:
- Tracks scroll with requestAnimationFrame
- Computes a translateY based on element’s position and a speed factor
- Respects reduced motion preferences
Concept
- On mount, read the element’s absolute top position (startTop)
- On every scroll frame, translate the element by (scrollY - startTop) * speed
- Negative speed moves opposite to scroll; small magnitudes (e.g., 0.1–0.5) are subtle and smooth
Basic version (declarative)
This version updates React state per frame—fine for a few elements.
// src/hooks/useParallax.js
import { useEffect, useRef, useState } from 'react';
export function useParallax(speed = 0.3) {
const ref = useRef(null);
const [y, setY] = useState(0);
const reducedMotion = typeof window !== 'undefined' &&
window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
useEffect(() => {
if (reducedMotion) return; // disable animation for accessibility
const el = ref.current;
if (!el) return;
let startTop = 0;
let rafId = 0;
const measure = () => {
const rect = el.getBoundingClientRect();
startTop = rect.top + window.scrollY;
};
const update = () => {
const yPos = (window.scrollY - startTop) * speed;
setY(yPos);
rafId = requestAnimationFrame(update);
};
const onResize = () => {
cancelAnimationFrame(rafId);
measure();
rafId = requestAnimationFrame(update);
};
measure();
rafId = requestAnimationFrame(update);
window.addEventListener('resize', onResize, { passive: true });
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener('resize', onResize);
};
}, [speed, reducedMotion]);
const style = reducedMotion ? {} : { transform: `translate3d(0, ${y}px, 0)` };
return { ref, style };
}
Use it in a component:
// src/components/ParallaxCard.jsx
import { useParallax } from '../hooks/useParallax';
export default function ParallaxCard({ speed = 0.3, children }) {
const { ref, style } = useParallax(speed);
return (
<div ref={ref} style={style} className="parallax-card">
{children}
</div>
);
}
/* src/index.css */
.parallax-card {
will-change: transform;
transition: transform 0.06s linear; /* tiny smoothing, optional */
}
Optimized version (imperative + CSS variable)
Avoid re-rendering every frame by writing directly to a CSS variable. This scales better when many elements animate.
// src/hooks/useParallaxVar.js
import { useEffect, useRef } from 'react';
export function useParallaxVar(speed = 0.3) {
const ref = useRef(null);
const reducedMotion = typeof window !== 'undefined' &&
window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
useEffect(() => {
const el = ref.current;
if (!el || reducedMotion) return;
let startTop = 0;
let rafId = 0;
let ticking = false;
const measure = () => {
const rect = el.getBoundingClientRect();
startTop = rect.top + window.scrollY;
};
const update = () => {
ticking = false;
const yPos = (window.scrollY - startTop) * speed;
el.style.setProperty('--parallax-y', `${yPos.toFixed(2)}px`);
rafId = requestAnimationFrame(update);
};
const onScroll = () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(() => (ticking = false));
}
};
const onResize = () => {
measure();
};
measure();
rafId = requestAnimationFrame(update);
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onResize, { passive: true });
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onResize);
};
}, [speed, reducedMotion]);
return { ref };
}
// src/components/ParallaxLayer.jsx
import { useParallaxVar } from '../hooks/useParallaxVar';
export default function ParallaxLayer({ speed = 0.2, className = '', children }) {
const { ref } = useParallaxVar(speed);
return (
<div ref={ref} className={`parallax-layer ${className}`}>
{children}
</div>
);
}
/* src/index.css */
.parallax-layer {
--parallax-y: 0px;
transform: translate3d(0, var(--parallax-y), 0);
will-change: transform;
}
Build a layered parallax scene
We’ll stack three layers with different speeds. Negative speeds move opposite to scroll for stronger depth.
// src/App.jsx
import HeroParallax from './components/HeroParallax';
import ParallaxLayer from './components/ParallaxLayer';
export default function App() {
return (
<main>
<HeroParallax />
<section style={{ height: '120vh', display: 'grid', placeItems: 'center' }}>
<h2>Scroll down to see layers</h2>
</section>
<section className="scene">
<ParallaxLayer speed={-0.15} className="layer layer--back">Background mountains</ParallaxLayer>
<ParallaxLayer speed={0.25} className="layer layer--mid">Midground forest</ParallaxLayer>
<ParallaxLayer speed={0.45} className="layer layer--front">Foreground rocks</ParallaxLayer>
</section>
<section style={{ height: '120vh' }} />
</main>
);
}
/* src/index.css */
.scene {
position: relative;
height: 120vh;
overflow: hidden;
}
.layer {
position: absolute;
left: 0; right: 0;
display: grid;
place-items: center;
font-weight: 700;
font-size: clamp(1.25rem, 2vw + 1rem, 3rem);
color: white;
text-shadow: 0 2px 10px rgba(0,0,0,.5);
}
.layer--back { top: 5%; }
.layer--mid { top: 30%; }
.layer--front { top: 55%; }
Tips
- Use images/SVGs per layer, absolutely positioned
- Apply subtle opacity differences and blur (e.g., background slightly blurrier) for stronger depth
Visibility optimization (optional)
Pause work when a layer is far off-screen to save battery. Use IntersectionObserver to toggle animation.
// src/hooks/useInView.js
import { useEffect, useState } from 'react';
export function useInView(ref, rootMargin = '200px') {
const [inView, setInView] = useState(true);
useEffect(() => {
const el = ref.current;
if (!el || !('IntersectionObserver' in window)) return;
const io = new IntersectionObserver(
([entry]) => setInView(entry.isIntersecting),
{ root: null, rootMargin }
);
io.observe(el);
return () => io.disconnect();
}, [ref, rootMargin]);
return inView;
}
Integrate with the imperative hook by early-returning when not in view or by swapping speed to 0.
Respect reduced motion
Always respect user preferences.
@media (prefers-reduced-motion: reduce) {
.parallax-layer, .parallax-card, .hero {
transition: none !important;
transform: none !important;
background-attachment: scroll !important;
}
}
Common pitfalls and fixes
- Jank on scroll: ensure you use transform: translate3d(…); avoid changing top/left.
- Reflow thrash: read layout (getBoundingClientRect) outside tight loops; cache startTop; recompute on resize only.
- Too much re-rendering: prefer the CSS variable/imperative hook for many elements.
- Mobile Safari: fixed backgrounds may not work—use the hook approach for hero images on iOS.
- Content overlap: set pointer-events and z-index carefully when stacking layers.
Testing your parallax
- Throttle CPU in your browser devtools; verify it stays smooth at 60fps on modern laptops
- Test on low-end devices; reduce speeds and layer count if needed
- Check prefers-reduced-motion and keyboard navigation (Tab focus rings must remain visible)
Production-ready libraries
If you need advanced features (e.g., providers, group effects, easing):
- react-scroll-parallax
wraps your app adds effects without custom math
- framer-motion
- Use useScroll and useTransform to map scroll progress to transforms/easing
- react-spring
- Animate parallax with springs for natural, damped motion
Example (react-scroll-parallax):
import { ParallaxProvider, Parallax } from 'react-scroll-parallax';
export default function App() {
return (
<ParallaxProvider>
<section style={{ height: '120vh' }} />
<Parallax speed={-20}>
<h2 style={{ textAlign: 'center' }}>Library-based Parallax</h2>
</Parallax>
<section style={{ height: '120vh' }} />
</ParallaxProvider>
);
}
Tuning checklist
- Speeds: keep |speed| between 0.1 and 0.6 for subtle, non-nauseating motion
- Use will-change: transform sparingly; remove it when not animating
- Combine with opacity/blur for depth cues instead of extreme movement
- Clamp motion if needed: Math.max(min, Math.min(max, value))
- Always provide sufficient color contrast and readable text
Wrap-up
You’ve built:
- A CSS-only hero parallax
- A reusable React hook (simple and optimized variants)
- A layered scene with multiple depths
- A library-based alternative
Use the simple CSS method for quick wins, the custom hook for flexible per-element effects, and a library when you need advanced, battle-tested features. Above all, prioritize performance and accessibility—your users will feel the difference.
Related Posts
Build a Polished React Animated Counter in React (Hook + Component, A11y-First)
Build an accessible React animated counter with a reusable hook and component—easing, formatting, reduced motion, viewport start, and full control.
React Loading Spinner Variants Collection: Accessible, Themeable Patterns
A curated, accessible collection of React loading spinner variants with code, theming, performance tips, and patterns for buttons, overlays, and Suspense.
Building an Accessible React Floating Action Button (FAB) in React
Build an accessible React Floating Action Button (FAB) with portals, speed dial, theming, keyboard support, and tests—production-ready patterns included.