Parallax Scrolling in React: useScroll, GSAP and Pure CSS
Master parallax scrolling in React using Framer Motion's useScroll, GSAP ScrollTrigger, and pure CSS — with real code you can drop in today.
Why Parallax Still Matters in 2026
Parallax has had its share of backlash. Designers called it overdone circa 2014, and honestly, they weren't wrong — every marketing page was drowning in slowly drifting background images that added latency and zero meaning. But that was bad parallax. Used deliberately, with restraint, it's one of the most effective depth cues you have in a flat browser viewport.
The thing is, the tooling has caught up. Framer Motion 11, GSAP 3.12, and modern CSS scroll-timeline mean you're not hacking window.scrollY in a useEffect anymore. Performance is no longer the excuse to avoid it.
In practice, the best parallax effects you'll see today are subtle — a background layer moving at 0.6x scroll speed, a heading that fades in from 20px below, a floating UI card that drifts 40px on scroll. Nothing that screams "look at me." If you want inspiration for how motion layering can enhance component design, check out what Empire UI's aurora and glassmorphism components are doing with layered transparency and depth.
The CSS-Only Approach: `perspective` and `translateZ`
Before you reach for any library, try pure CSS. It's zero JavaScript, zero bundle cost, and works surprisingly well for hero sections.
The trick is perspective on a scrolling container and translateZ on child layers. Elements pushed back in Z-space appear to move slower than the viewport scroll — real parallax, handled entirely by the compositor thread.
.parallax-container {
height: 100vh;
overflow-y: scroll;
perspective: 1px;
perspective-origin: center top;
}
.parallax-bg {
transform: translateZ(-1px) scale(2);
/* scale(2) compensates for the shrinkage from Z offset */
}
.parallax-fg {
transform: translateZ(0);
}Worth noting: the scale() compensation is necessary because pulling a layer back in Z-space makes it appear smaller. The formula is scale = 1 + (translateZ * -1) / perspective. For translateZ(-1px) and perspective: 1px, that's scale(2). Tweak both values together or your layers won't align.
The downside? This approach breaks sticky positioning on child elements and can clip content unpredictably in Firefox. For anything beyond a static hero, you'll want JavaScript control.
Framer Motion's useScroll and useTransform
Framer Motion's useScroll hook is the cleanest React-native solution. It gives you reactive scroll progress values — no event listeners to clean up, no jank from forced reflows, and it integrates directly with motion components via useTransform.
Here's a minimal parallax layer setup you can drop straight into a Next.js page component:
import { useRef } from 'react';
import { motion, useScroll, useTransform } from 'framer-motion';
export function ParallaxHero() {
const ref = useRef(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start start', 'end start'],
});
const bgY = useTransform(scrollYProgress, [0, 1], ['0%', '40%']);
const textY = useTransform(scrollYProgress, [0, 1], ['0%', '20%']);
const opacity = useTransform(scrollYProgress, [0, 0.7], [1, 0]);
return (
<section ref={ref} className="relative h-screen overflow-hidden">
<motion.div
className="absolute inset-0 bg-layer"
style={{ y: bgY }}
/>
<motion.div
className="relative z-10 flex items-center justify-center h-full"
style={{ y: textY, opacity }}
>
<h1 className="text-6xl font-bold">Ship depth.</h1>
</motion.div>
</section>
);
}The offset option is what most tutorials skip. ['start start', 'end start'] means: start tracking when the element's top hits the viewport top, stop tracking when the element's bottom hits the viewport top. That's usually what you want for a full-bleed hero. Change it to ['start end', 'end start'] if you want tracking across the full scroll-through.
One more thing — useTransform is lazy by default. It won't recalculate until the motion value actually changes, so you're not paying for transforms on frames where nothing moves. That's a meaningful win for 60fps on mid-range Android devices.
GSAP ScrollTrigger: When You Need Sequenced Animations
GSAP's ScrollTrigger plugin is the power tool here. Where Framer Motion is great for smooth interpolation, GSAP excels when you need scroll-linked animation timelines — things like pinning a section while a sequence plays, staggering multiple elements across a defined scroll range, or scrubbing a complex animation by hand.
Quick aside: GSAP 3.12 requires you to register ScrollTrigger explicitly and it must run in a useLayoutEffect (or useGSAP from the official @gsap/react package) to avoid SSR mismatch errors in Next.js.
import { useRef } from 'react';
import { useGSAP } from '@gsap/react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
export function ScrollSequence() {
const container = useRef(null);
useGSAP(() => {
const layers = gsap.utils.toArray('.layer');
gsap.fromTo(
layers,
{ y: 80, opacity: 0 },
{
y: 0,
opacity: 1,
stagger: 0.15,
scrollTrigger: {
trigger: container.current,
start: 'top 80%',
end: 'bottom 20%',
scrub: 1, // 1s lag for smoothing
},
}
);
}, { scope: container });
return (
<div ref={container} className="scroll-sequence">
{['Layer A', 'Layer B', 'Layer C'].map((l) => (
<div key={l} className="layer p-8 text-2xl">{l}</div>
))}
</div>
);
}The scrub: 1 value is a smoothing duration in seconds, not a boolean. scrub: true gives you direct 1:1 scrubbing which can feel mechanical. scrub: 1 adds 1 second of lag, which reads as natural inertia. For UI components that are already visually heavy — think glassmorphism cards or layered aurora effects — a bit of scrub lag helps prevent the page from feeling chaotic.
Look, GSAP is overkill if all you need is a background that moves at 60% scroll speed. But the moment you're orchestrating more than two or three animated elements across a scroll range, you'll feel why its timeline API exists.
Performance: What Actually Tanks Your FPS
Parallax has a reputation for being slow. Here's the real culprit: triggering layout or paint on scroll. If your transform reads or writes anything that causes a reflow — like top, left, height, width — the browser can't offload the work to the compositor thread. You'll get jank every time.
Stick to transform: translateY() and opacity. These are the only two CSS properties that run on the compositor without triggering layout. Everything else is a trap.
Also watch your layer count. Each will-change: transform hint creates a new GPU layer. Pile up 20 of them and you'll blow past the GPU memory budget on mobile. Honestly, most parallax UIs only need 2-3 promoted layers: the background, the midground, and the foreground content.
That said, will-change is still worth using on the 1-2 elements that are *always* animating. Add it in a useEffect just before the animation starts and remove it when done — don't bake it into your static CSS.
Accessibility and Reduced Motion
This is where a lot of parallax implementations fall down. The prefers-reduced-motion media query exists specifically for users who experience motion sickness or vestibular disorders. Ignoring it isn't just bad practice — it's an accessibility failure.
In Framer Motion, there's a useReducedMotion hook that returns true when the user has that preference set. Wrap your transform values conditionally:
import { useReducedMotion } from 'framer-motion';
const prefersReduced = useReducedMotion();
const bgY = useTransform(
scrollYProgress,
[0, 1],
prefersReduced ? ['0%', '0%'] : ['0%', '40%']
);In GSAP, check window.matchMedia('(prefers-reduced-motion: reduce)').matches before creating your ScrollTrigger. If it matches, skip the parallax and just show the final state. Two lines of code, major accessibility win.
You can browse how Empire UI's motion-heavy components handle this — reduced motion variants are built into the component API so you don't have to add it yourself.
Choosing the Right Tool for Your Project
So which approach should you actually use? Here's the honest breakdown.
Pure CSS perspective parallax works great for static hero sections where you control the markup. No deps, no JS. If you're building a landing page with a single parallax layer, start here.
Framer Motion useScroll is the right call for React apps where you're already using the library for other animations. The API is ergonomic, TypeScript types are solid, and the performance characteristics are predictable. It plays well with the kind of UI components you'd find browsing component collections like Empire UI's templates.
GSAP ScrollTrigger wins when you need complex, sequenced scroll animations — pinned sections, multi-step timelines, scroll-synced video scrubbing, or anything where you'd otherwise be managing a hand-rolled animation state machine. The bundle weight (~45kB gzipped for core + ScrollTrigger) is justified at that point.
One more thing — don't mix all three on the same page. Pick one scroll driver per scroll container or you'll get competing event listeners and unpredictable priority conflicts. Keep it boring and consistent.
FAQ
Yes, but use useGSAP from @gsap/react instead of useLayoutEffect directly — it handles the SSR guard for you. Also make sure ScrollTrigger is registered inside the hook, not at module level.
scrollY gives you raw pixel offset from the top of the page. scrollYProgress gives you a 0-1 normalized value relative to the scroll range of your target element — almost always what you want for useTransform.
No, not inherently. The content is still in the DOM and crawlable. The only risk is if you lazy-load critical content behind scroll triggers without a proper fallback — then Googlebot may miss it.
On iOS, overflow: scroll with perspective doesn't behave like desktop — use JS-based parallax instead and clamp your translate values to ±30px max so it doesn't look broken on small screens.