Parallax Scroll Sections in React: Performance-First Approach
Build smooth parallax scroll sections in React without tanking your Core Web Vitals. Learn the CSS and JS techniques that actually hold up under real scroll load.
Why Most Parallax Implementations Are a Jank Trap
Honestly, parallax scroll is one of those effects that looks incredible in a Dribbble shot and then destroys your Lighthouse score the second someone opens it on a mid-range Android. The technique itself isn't the problem — how most developers implement it is.
The typical mistake is reading window.scrollY on every scroll event, recalculating positions, and writing those values directly into element.style.transform inside the event listener. That runs synchronously on the main thread, competes with everything else the browser is doing, and produces jank that users feel as a stutter even if they can't name it.
We're going to skip that path entirely. This article covers two approaches: a pure CSS method for simple depth effects and a requestAnimationFrame-gated JS method for when you genuinely need scroll-linked JavaScript. Both are safe for production. Both stay off the main thread where it matters.
The CSS-Only Parallax: `perspective` and `translateZ`
Pure CSS parallax has been possible for years and it still surprises developers that it exists. The trick is to set a perspective value on a scroll container, then push child layers along the Z axis with translateZ. Elements pushed back (translateZ(-1px)) move more slowly relative to the scroll container than elements at translateZ(0). That's parallax — no JavaScript, no scroll listener.
Here's the minimal setup. You need exactly three things: a scroll container with perspective, overflow-y: scroll, and transform-style: preserve-3d. Then each layer needs a translateZ value and a compensating scale() to keep apparent sizes consistent.
// ParallaxSection.tsx
import React from 'react';
const ParallaxSection = ({ children }: { children: React.ReactNode }) => (
<div
style={{
height: '100vh',
overflowY: 'scroll',
perspective: '1px',
perspectiveOrigin: '50% 50%',
}}
>
{/* Background layer — moves at ~0.5x scroll speed */}
<div
style={{
position: 'absolute',
inset: 0,
transform: 'translateZ(-1px) scale(2)',
backgroundImage: 'url(/hero-bg.jpg)',
backgroundSize: 'cover',
zIndex: -1,
}}
/>
{/* Foreground content — scrolls at normal 1x speed */}
<div style={{ position: 'relative', zIndex: 1 }}>{children}</div>
</div>
);
export default ParallaxSection;The scale(2) on the background compensates for the shrink caused by translateZ(-1px) — at 1px perspective, a layer at -1px on Z appears at half size, so scale(2) brings it back to full. Change the translateZ value and you'll need to recalculate the scale: scale = (perspective - translateZ) / perspective.
Tailwind v4.0.2 and the `perspective` Utility Gap
If you're on Tailwind v4.0.2, you'll notice there's no built-in perspective-* utility yet in the stable core set. You have two options: drop a style prop for the one-off container property, or extend your config with a custom plugin.
For most projects the style prop is fine. You're setting perspective exactly once on the outer scroll container, not sprinkled across dozens of components. Don't reach for a config plugin just to avoid one inline style — that's over-engineering for nothing.
The rest of the markup is fine with Tailwind. Classes like absolute inset-0 bg-cover bg-center -z-10 map cleanly, and translate-z-* is available in Tailwind v4 under the transform utilities. For the foreground layer, relative z-10 px-6 py-24 handles spacing without any custom CSS.
JavaScript Parallax with `requestAnimationFrame` and `will-change`
Sometimes CSS parallax isn't expressive enough. You want different layers moving at independent speeds, opacity fading with scroll position, or text that slides in from the side as the section enters the viewport. That's when you bring in JavaScript — but you do it carefully.
The pattern that works: track scroll position with a passive event listener, store the value in a ref, then read that ref inside a requestAnimationFrame loop. Never write to the DOM directly in an event listener. requestAnimationFrame syncs your writes with the browser's paint cycle and the browser can batch them properly.
import { useEffect, useRef } from 'react';
export function useParallax(speed = 0.4) {
const ref = useRef<HTMLDivElement>(null);
const scrollY = useRef(0);
const rafId = useRef<number>(0);
useEffect(() => {
const onScroll = () => {
// passive: true — browser can scroll without waiting for JS
scrollY.current = window.scrollY;
};
const tick = () => {
if (ref.current) {
const offset = scrollY.current * speed;
ref.current.style.transform = `translateY(${offset}px)`;
}
rafId.current = requestAnimationFrame(tick);
};
window.addEventListener('scroll', onScroll, { passive: true });
rafId.current = requestAnimationFrame(tick);
return () => {
window.removeEventListener('scroll', onScroll);
cancelAnimationFrame(rafId.current);
};
}, [speed]);
return ref;
}Two things matter here. { passive: true } on the scroll listener tells the browser you won't call event.preventDefault(), which unlocks off-thread scrolling on mobile. And will-change: transform on the target element (set it once in CSS, not in JS) promotes it to its own compositor layer so the GPU handles the rasterisation independently from the main content.
When to Use Framer Motion's `useScroll` Instead
If you're already pulling in Framer Motion for other animations — say, the animated tabs or button micro-interactions on your page — you might as well use useScroll and useTransform instead of rolling your own RAF loop. Framer Motion's scroll system is already optimised, compositor-friendly, and handles cleanup automatically.
The API is clean: useScroll returns a scrollYProgress motion value (0 to 1 for the full page, or scoped to a specific element with { target: ref }). useTransform maps that progress to whatever output range you want — translateY, opacity, scale, anything.
What you don't want to do is mix Framer Motion's motion values with raw style mutations. Pick one approach per element and stick with it. Framer Motion and manual element.style.transform writes will fight each other and produce exactly the kind of jitter you were trying to avoid. Are you seeing flicker at the end of an animation? That's almost always the cause.
Reducing Layout Thrash: `transform` Only, Never `top`/`left`
This one sounds obvious but it's the most common performance bug in parallax code in the wild. Animating top, left, margin-top, or height to create scroll offset forces the browser into layout recalculation on every frame. The browser has to measure everything that might be affected, then repaint, then composite. That's three pipeline stages instead of one.
transform: translateY() skips layout and paint entirely. The compositor handles it. It's the only property (alongside opacity) that can run purely on the GPU thread without involving the main thread. If you're animating anything else to produce a parallax-like movement, you're doing it wrong and you'll feel it on lower-end devices.
The same logic applies to clip-path animations and filter changes — technically compositor-friendly in modern browsers, but transform remains the safest bet across the broadest device range. Stick to it for parallax. For layout-driven reveals where position matters, look at Intersection Observer instead — it's specifically built for viewport detection without forcing a scroll listener.
Combining Parallax with Empire UI Components
Parallax sections work really well as wrappers around content-heavy components. A bento grid sitting inside a parallax container picks up the depth effect without any changes to the grid component itself — the outer scroll layer handles all the motion. Same story with a card stack: wrap it in a useParallax ref and the whole stack drifts at your configured speed.
One thing to watch: parallax and position: sticky interact in unexpected ways. If a child element inside your parallax container uses position: sticky, the sticky behaviour calculates offset relative to the parallax scroll container, not the viewport. This is usually what you want — but if it's not, you'll need to pull the sticky element out of the parallax container and layer it on top with position: fixed and a high z-index.
Also worth mentioning: the CSS perspective method and the JS RAF method don't play well together in the same scroll container. Pick one per section. If you need both techniques on the same page, divide them into separate full-height sections so each scroll context is independent.
Testing Parallax Performance Before You Ship
The Chrome DevTools Performance panel is your best tool here. Record a scroll session, look at the Frames row, and flag any frames that took longer than 16ms. Those are dropped frames — visible to users as stutter. A solid parallax implementation should show consistent ~16ms frames with GPU layer rendering in the Layers panel.
Also run Lighthouse with "Performance" selected before and after adding parallax. Watch your Cumulative Layout Shift score specifically. If CLS went up, something in your parallax layer is causing unexpected layout shifts — usually because a layer without an explicit height is jumping as images load. Fixed-height containers or aspect-ratio CSS on image layers prevents this.
One last check: disable JavaScript and reload. Does the page still look reasonable? The CSS perspective method survives this fine. A JS-only implementation might show your background image at a weird static offset. Progressive enhancement matters here — don't build a broken layout that only works with JS running perfectly.
FAQ
Yes. The perspective, transform-style: preserve-3d, and translateZ properties have had full support across Chrome, Firefox, Safari, and Edge for several years. Safari on iOS has occasional edge cases with overflow: scroll + perspective — test it on a real device, not just the simulator.
The gap appears because the background layer doesn't cover enough area. When you use translateZ(-1px) scale(2), make sure the background element's dimensions are larger than 100% of the viewport — use min-height: 200vh or equivalent. The exact amount needed depends on your perspective value and how far down the page the section sits.
Yes, but only in Client Components (with 'use client' at the top). useScroll uses browser APIs that don't exist during server-side rendering. Wrap your parallax section component with 'use client' and import it into your Server Component page — Next.js handles the boundary correctly.
Values between 0.2 and 0.5 are the sweet spot for most backgrounds. Below 0.2 the effect is barely visible. Above 0.6 it starts to look unnatural on typical scroll depths. For foreground elements moving faster than the content (e.g., floating decorative shapes), you can go negative — a speed of -0.2 moves the element upward as the user scrolls down.
No. Apply it only to elements that are actively animating. Applying will-change: transform to too many elements — especially large background images — consumes extra GPU memory and can actually hurt performance on low-memory mobile devices. Set it only on the layers that need their own compositor layer, and remove it after animation is complete if possible.
CLS from parallax is almost always caused by images without explicit dimensions loading after the layout is calculated. Add explicit width and height attributes or use aspect-ratio CSS on your image containers inside the parallax section. Also make sure your parallax container has an explicit height — avoid relying on content to define the height of the scroll context.