
Parallax scrolling can make a site feel alive. A subtle depth effect, a foreground that moves just a bit faster than the background, and suddenly your landing page has cinematic charm. But for every site that nails it, there are ten more where parallax causes scroll jank, sluggish animation, and poor Core Web Vitals. The good news: you can absolutely use parallax without slowing down your site. You just need the right approach.
This in-depth guide explains how to implement modern, GPU-friendly parallax that respects accessibility preferences, scales from mobile to desktop, and preserves performance standards like smooth scrolling and fast rendering. Whether you build with vanilla CSS and JS or integrate with frameworks like React, Next.js, or Vue, this article will show you how to execute parallax the right way.
Parallax scrolling is a visual technique where background and foreground elements move at different speeds as the user scrolls. This creates an illusion of depth. Common effects include:
Parallax is not a single technology; it is a pattern you can build with CSS transforms, scroll-driven animations, or JavaScript tied to scroll progress.
Parallax can degrade performance for a few reasons:
The result is scroll jank: stutters and micro-pauses while the browser tries to keep up, which can be measured as frame drops below 60 fps. It can also degrade Core Web Vitals like Largest Contentful Paint (LCP) and Cumulative Layout Shift (CLS) if not planned carefully.
Before writing code, adopt a few guiding principles:
If you remember one rule, make it this: animate transform and opacity, not layout or paint-heavy properties.
You have multiple options to create parallax without slowing your site. Choose the one that best suits your project and audience.
This pattern uses layered elements, each moving at a different rate via transform: translateY. Historically, developers used background-attachment: fixed for parallax, but it is poorly supported on mobile and often hurts performance. Instead, use positioned layers and transform.
Basic structure:
With modern CSS Scroll-Driven Animations, you can animate transform values based on scroll progress without JavaScript (where supported). We will cover that below. If you need wider compatibility, you can start with a simple CSS setup and then add a minimal JS controller.
Scroll-Driven Animations Level 1 (supported in Chromium-based browsers and Safari, and emerging elsewhere) lets you map scroll progress to animation progress in CSS. This is a game changer for performance because the browser can optimize the timing without your JavaScript running on every scroll event.
At the time of writing, you can use scroll timelines with @scroll-timeline or related properties and then set animation-timeline on elements. This enables animation frames to be driven by the scroll position.
We will include a snippet in the implementation section.
If you need cross-browser compatibility or custom behavior today, a small controller driven by requestAnimationFrame is a solid approach. The idea:
This approach avoids heavy continuous work on the main thread. You throttle work to the browser’s animation cadence.
The classic CSS parallax trick often used background-attachment: fixed. Unfortunately, on many mobile browsers this either does not render correctly or invokes expensive repaints. It can cause flicker, jank, or be ignored entirely. Prefer layered elements that you move with transform instead. You keep control and performance.
Below are three proven patterns, listed from most progressive to most compatible.
This pattern uses the native scroll timeline to animate a background layer. It is performant because the browser controls the timing.
HTML:
<section class='parallax-section'>
<div class='parallax-layers'>
<div class='layer layer-back'></div>
<div class='layer layer-mid'></div>
<div class='layer layer-front'>
<h1 class='hero-title'>A Lightweight Parallax Hero</h1>
</div>
</div>
<div class='content'>
<p>Scroll to see the effect. This content is regular text so LCP can be fast.</p>
</div>
</section>
CSS:
.parallax-section {
position: relative;
min-height: 120vh;
overflow-x: clip;
background: var(--bg, #0b0b0b);
color: #fff;
}
.parallax-layers {
position: sticky;
top: 0;
height: 100vh;
overflow: hidden;
}
.layer {
position: absolute;
inset: 0;
will-change: transform;
transform: translateZ(0);
}
.layer-back {
background: radial-gradient(ellipse at center, #1e1e1e 0%, #0b0b0b 70%);
}
.layer-mid {
background: url('/images/mountains.avif') center / cover no-repeat;
}
.layer-front {
display: grid;
place-items: center;
}
.hero-title {
font-size: clamp(2rem, 6vw, 5rem);
margin: 0;
}
/* Scroll timeline definition and usage (progressive enhancement) */
@supports (animation-timeline: auto) {
@scroll-timeline hero-timeline {
source: auto;
orientation: block;
}
.layer-back {
animation: backShift 1 linear both;
animation-timeline: hero-timeline;
}
.layer-mid {
animation: midShift 1 linear both;
animation-timeline: hero-timeline;
}
.layer-front {
animation: frontShift 1 linear both;
animation-timeline: hero-timeline;
}
@keyframes backShift {
0% { transform: translateY(0); }
100% { transform: translateY(12vh); }
}
@keyframes midShift {
0% { transform: translateY(0); }
100% { transform: translateY(20vh); }
}
@keyframes frontShift {
0% { transform: translateY(0); }
100% { transform: translateY(30vh); }
}
}
/* Reduced motion: disable parallax for accessibility */
@media (prefers-reduced-motion: reduce) {
.layer, .hero-title { animation: none !important; transform: none !important; }
}
/* Content section after the hero */
.parallax-section .content {
padding: 8rem 1rem 4rem;
max-width: 64rem;
margin: 0 auto;
}
/* Ensure images have intrinsic size to prevent shift */
.layer-mid { background-position: center; }
Notes:
Progressive enhancement:
If you need cross-browser behavior or custom math, here is a lightweight approach. This pattern ensures your logic runs at the browser’s animation cadence and that you only update on frames, not on every scroll event.
HTML structure:
<section id='hero' class='hero'>
<div class='hero__layer hero__layer--back' data-depth='0.2'></div>
<div class='hero__layer hero__layer--mid' data-depth='0.5'></div>
<div class='hero__layer hero__layer--front' data-depth='0.8'>
<h1 class='hero__title'>Performance First Parallax</h1>
</div>
</section>
CSS:
.hero {
position: relative;
height: 120vh;
overflow: clip;
background: #0b0b0b;
}
.hero__layer {
position: sticky;
top: 0;
height: 100vh;
will-change: transform;
transform: translateZ(0);
}
.hero__layer--back { background: linear-gradient(#0b0b0b, #1e1e1e); }
.hero__layer--mid { background: url('/images/forest.webp') center / cover no-repeat; }
.hero__layer--front { display: grid; place-items: center; }
.hero__title { color: #fff; font-size: clamp(2rem, 8vw, 6rem); }
@media (prefers-reduced-motion: reduce) {
.hero__layer { transform: none !important; }
}
JavaScript:
(() => {
const layers = Array.from(document.querySelectorAll('.hero__layer'));
if (!layers.length) return;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduceMotion) return;
let ticking = false;
let lastY = window.scrollY;
const hero = document.getElementById('hero');
const heroRect = () => hero.getBoundingClientRect();
const update = () => {
ticking = false;
const rect = heroRect();
const viewportH = window.innerHeight || document.documentElement.clientHeight;
// Calculate scroll progress through the hero section: 0 at top, 1 at bottom
const total = rect.height - viewportH;
const progress = total > 0 ? Math.min(Math.max(-rect.top / total, 0), 1) : 0;
// Move layers at different speeds
for (const layer of layers) {
const depth = parseFloat(layer.getAttribute('data-depth')) || 0.5;
const translate = progress * depth * 40; // adjust 40vh max movement if desired
layer.style.transform = `translateY(${translate}vh)`;
}
};
const onScroll = () => {
lastY = window.scrollY || window.pageYOffset;
if (!ticking) {
ticking = true;
requestAnimationFrame(update);
}
};
const onResize = () => {
requestAnimationFrame(update);
};
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onResize);
// Initial update after images decode
if ('fonts' in document) {
document.fonts.ready.then(update);
} else {
update();
}
})();
Notes:
If you prefer, you can push the transforms into CSS variables and use them in CSS for cleaner separation. Example:
.hero__layer { transform: translateY(var(--parallax-y, 0)); }
// Inside update()
layer.style.setProperty('--parallax-y', `${translate}vh`);
If you want a parallax hint without scroll-driven animation support or JavaScript, you can fake a mild effect with position: sticky and perspective-like scaling or a small transform based on scroll-snapping sections. This is more limited but nearly zero overhead.
Example using sticky and a viewport-height offset to create a soft reveal as the user scrolls:
<section class='panel'>
<div class='panel__bg'></div>
<div class='panel__content'>
<h2>Subtle Sticky Panel</h2>
<p>A minimal effect that barely touches performance.</p>
</div>
</section>
<section class='panel panel--alt'>
<div class='panel__bg'></div>
<div class='panel__content'>
<h2>Second Panel</h2>
</div>
</section>
.panel { position: relative; height: 120vh; }
.panel__bg { position: sticky; top: 0; height: 100vh; background: #111; }
.panel__content { position: relative; padding: 25vh 2rem 2rem; color: #fff; }
.panel--alt .panel__bg { background: #0d0d1f; }
This is not true parallax, but the sticky background staying while content flows can add perceived depth with minimal performance cost.
The fastest parallax is the one that loads lightweight images and does not trigger layout shifts. Treat images as first-class citizens in your performance budget.
Key tips:
Example responsive background alternative by placing an img in the layer and layering content above it:
<div class='layer layer-mid'>
<img
src='/images/mountains-1200.avif'
srcset='/images/mountains-800.avif 800w, /images/mountains-1200.avif 1200w, /images/mountains-2000.avif 2000w'
sizes='100vw'
alt='Mountain range under a night sky'
loading='eager'
decoding='async'
fetchpriority='high'
class='layer-img'
/>
</div>
.layer-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
This approach replaces background images with actual img elements, giving you better control of loading behavior and intrinsic size to curb layout shift. For purely decorative images, you can use alt='' so screen readers skip them.
Parallax can negatively impact Core Web Vitals if not implemented carefully. Here is how to avoid issues:
CSS to help prevent shifts and improve rendering:
/* Ensure offscreen content does not cause big reflows when it appears */
.parallax-section,
.hero {
content-visibility: auto;
contain-intrinsic-size: 1000px 1000px; /* pick a reasonable placeholder size */
}
/* Explicit size for images to avoid CLS */
img {
max-width: 100%;
height: auto;
}
Use content-visibility carefully: it helps when sections are far below the fold. Do not apply it to above-the-fold content.
Not everyone enjoys motion, and some users experience discomfort or motion sickness from parallax. You must detect and respect reduced motion preferences.
Example:
@media (prefers-reduced-motion: reduce) {
.layer, .hero__layer, .parallax-layers { animation: none !important; transform: none !important; }
}
Optional JavaScript opt-out:
if (location.search.includes('no-motion=1')) {
document.documentElement.classList.add('no-motion');
}
.no-motion .layer, .no-motion .hero__layer { transform: none !important; animation: none !important; }
Mobile devices vary widely. The same parallax effect that is smooth on a desktop with a dedicated GPU might stutter on a budget phone.
Consider device-aware adjustments:
Example device memory check:
const lowMemory = navigator.deviceMemory && navigator.deviceMemory <= 2;
if (lowMemory) {
document.documentElement.classList.add('parallax-lite');
}
.parallax-lite .hero__layer--mid { display: none; } /* remove non-essential middle layer */
.parallax-lite .hero__layer { transform: none !important; } /* or soften movement */
Also consider touch ergonomics: heavy motion while the user tries to read text is not ideal. Keep parallax subtle, especially on content-heavy pages.
Every layer you add can cost memory and rendering time. Keep your stack lean:
A limited set of layers makes tuning much easier and reduces the risk of accidental paint storms.
If you must use JavaScript to control parallax, follow these guidelines:
Example: enable parallax only when visible
const hero = document.getElementById('hero');
let active = false;
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
active = e.isIntersecting;
}
}, { rootMargin: '200px' });
io.observe(hero);
function rafLoop() {
if (active) {
// update transforms here
}
requestAnimationFrame(rafLoop);
}
requestAnimationFrame(rafLoop);
This reduces work when the hero is offscreen.
Small CSS choices can have a notable impact:
Frameworks add another layer of complexity. Here is how to keep them from causing jank.
React and Next.js:
Example React hook sketch:
import { useEffect, useRef } from 'react';
export function useParallax(ref, options = {}) {
useEffect(() => {
const el = ref.current;
if (!el) return;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) return;
let ticking = false;
const onScroll = () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(() => {
ticking = false;
const rect = el.getBoundingClientRect();
const vh = window.innerHeight || document.documentElement.clientHeight;
const total = rect.height - vh;
const p = total > 0 ? Math.min(Math.max(-rect.top / total, 0), 1) : 0;
el.style.setProperty('--p', p);
});
}
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
return () => window.removeEventListener('scroll', onScroll);
}, [ref, options]);
}
In your component:
function Hero() {
const ref = useRef(null);
useParallax(ref);
return (
<section ref={ref} className='hero'>
<div className='layer layer-1' />
<div className='layer layer-2' />
</section>
);
}
Then in CSS:
.layer-1 { transform: translateY(calc(var(--p, 0) * 10vh)); }
.layer-2 { transform: translateY(calc(var(--p, 0) * 20vh)); }
Vue and Svelte follow the same pattern: keep animation state out of reactivity for frame-to-frame transforms. Use refs or bind:this to elements and set CSS variables or inline styles directly within a requestAnimationFrame loop.
Avoid these pitfalls:
Performance is not a feeling; it is measured. Validate your parallax implementation.
Tools:
Checklist:
If you see stutters:
For long storytelling pages with many parallax scenes, consider virtualization:
Pseudocode:
class ParallaxScene {
constructor(root) {
this.root = root;
this.layers = root.querySelectorAll('[data-depth]');
this.active = false;
}
update(progress) {
for (const layer of this.layers) {
const d = parseFloat(layer.dataset.depth) || 0.5;
layer.style.transform = `translateY(${progress * d * 40}vh)`;
}
}
}
const scenes = [...document.querySelectorAll('[data-parallax]')].map(el => new ParallaxScene(el));
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
const scene = scenes.find(s => s.root === e.target);
if (scene) scene.active = e.isIntersecting;
}
}, { rootMargin: '200px' });
scenes.forEach(s => io.observe(s.root));
function frame() {
for (const s of scenes) {
if (!s.active) continue;
const rect = s.root.getBoundingClientRect();
const vh = window.innerHeight || document.documentElement.clientHeight;
const total = rect.height - vh;
const p = total > 0 ? Math.min(Math.max(-rect.top / total, 0), 1) : 0;
s.update(p);
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
This architecture scales nicely across multiple sections without pounding the main thread.
Parallax is a visual enhancement. Keep SEO fundamentals intact:
If you want a blueprint to follow, here is a sane default stack:
Example solution outline:
Q: Does parallax always hurt performance?
A: No. The problems come from poor implementation. Parallax built with transform-based animations, minimal layers, and optimized assets can be smooth and light.
Q: Should I use background-attachment: fixed for parallax?
A: Generally no. It is unreliable on mobile and often triggers repainting. Prefer positioned layers and transform-based movement.
Q: What is the easiest way to add parallax without JavaScript?
A: Use native CSS scroll-driven animations where supported. Combine position: sticky for scene pinning and animate transform via animation-timeline.
Q: How do I support older browsers?
A: Add a minimal JavaScript fallback using requestAnimationFrame to drive transforms. Keep the code small and use CSS variables for simplicity.
Q: Can parallax break Core Web Vitals?
A: Yes, if you let it. Avoid massive hero images, reserve space for media to prevent CLS, and keep your LCP candidate lightweight. Use the checklist above.
Q: How do I reduce motion for sensitive users?
A: Use prefers-reduced-motion to disable or significantly reduce parallax. Offer a manual toggle or URL flag as well.
Q: Are parallax libraries a bad idea?
A: Not necessarily, but many are overkill and add overhead. If you choose a library, pick one that animates transforms, is small, and actively maintained. A custom 20–40 line controller often suffices.
Q: Can I use video backgrounds for parallax?
A: You can, but be cautious. Videos increase page weight, may affect LCP, and can be heavy on mobile. If you try this, defer loading, offer a poster, and allow reduced motion to disable it.
Q: What about using transform: translateZ and perspective for 3D depth?
A: You can use perspective and transform: translateZ for more dramatic effects, but it can increase compositing complexity. Use sparingly and test performance.
Q: How do I know if my parallax is too heavy?
A: Profile it. If you drop frames during scroll, see long tasks in DevTools, or get poor Lighthouse scores, simplify: fewer layers, smaller images, less distance moved, or disable on mobile.
Imagine a marketing landing page with a parallax hero that was stuttering on mid-tier phones. The original implementation animated background-position across a large 4K JPEG with a JavaScript scroll handler doing math on every scroll event. The page shipped multiple large images without responsive variants.
Optimization steps:
This is a typical outcome when you adopt the performance-first parallax mindset.
Do:
Do not:
Parallax is a design flourish, not a feature. When done right, it brings delight without cost. When done wrong, it punishes everyone with stutters and poor Web Vitals. The difference lies in engineering discipline: animate the right properties, keep layers lean, optimize assets, and lean on modern platform features like scroll-driven animations. Test on real devices, respect accessibility preferences, and let performance guide your creative decisions.
Build motion that feels natural and looks great, while keeping your site fast for every user.
If you want help implementing a performance-first parallax hero or auditing an existing page, reach out to our team. We can prototype a CSS-native solution, integrate fallbacks, and validate performance across browsers and devices.
Ship motion that is as fast as it is beautiful.
Loading comments...