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.

ASOasis
7 min read
Build a Smooth Parallax Scroll Effect in React (From Scratch + Library Options)

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