You know those parallax scrolling effects where background images move at different speeds as you scroll? They create depth and make websites memorable. But most tutorials overcomplicate them.
Here's what might surprise you: you don't need a third-party library. No , no 30kb of dependencies. No Lenis. No GSAP. No Three.js. Not even WebGL. Just vanilla JavaScript, some refs, and a scroll listener.
I'll show you a straightforward approach that works best in 2026. Then I’ll show you how to implement that in a fraction of the time using AI.
What is parallax scrolling?
Parallax scrolling is a technique where background images move slower than foreground content as you scroll, creating an illusion of depth.
At its core, it has three components:
- Multiple visual layers stacked on top of each other
- Each layer moves at a different speed
- Movement is tied to scroll position
When done right, it creates depth. When done incorrectly, it can cause motion sickness or poor performance. The key is smoothness and subtlety.
Create your layers like an animator
The key is thinking like an animator. You are creating distinct layers of imagery.
Here's where it gets fun. Instead of finding stock images, I generated custom layers in Midjourney specifically designed to composite together.
For my space theme, I made three distinct layers with transparent backgrounds:
Background layer: Celestial gases and nebulae - deep purples, pinks, and cosmic clouds with stars scattered throughout. This provides atmosphere and sets the mood.
Midground layer: A large purple planet - detailed surface with craters and atmospheric glow. This is your focal point that draws the eye.
Foreground layer: The cresting sphere of another planet - just the top curve visible, like it's rising into view. This creates depth by partially obscuring the scene behind it.
The secret is using transparent backgrounds (PNG with alpha channel) so the layers blend naturally when stacked. In Midjourney, I used prompts like:
celestial nebula gases in purple and pink, cosmic clouds, scattered stars,
cinematic lighting, transparent background, isolated on black --ar 16:9 --v 6large purple planet with detailed surface, craters, atmospheric glow,
space photography style, transparent background, isolated --ar 1:1 --v 6cresting curve of a planet sphere, top third only, Earth-like with clouds,
view from space, transparent background, isolated --ar 16:9 --v 6Why use parallax scrolling?
Parallax makes websites memorable. In a sea of flat pages, subtle motion creates depth and emotional engagement.
Use it for:
- Hero sections that need strong first impressions
- Storytelling pages that guide users through a narrative
- Portfolio sites showcasing visual design skills
Modern browsers run transforms on the compositor thread, so parallax is smooth even on mobile. When done right, it's one of the smoothest effects you can add.
Skip it when:
- Your site is content-heavy and users need to scan quickly
- You can't properly support reduced motion preferences
- You're on a tight deadline
The best parallax is subtle. If users feel the smoothness but can't explain why, you nailed it.
Real-world examples
Parable VC uses three crisp layers (sky, cityscape, clouds) with subtle speed differences. Polished without being distracting.
Firewatch's website uses parallax mountain layers that mimic the game's aesthetic.
What do these have in common? Parallax supports the story, but doesn’t replace. The effect enhances content rather than being the content.
The best implementation approach for 2026
After testing multiple methods, the clear winner is direct scroll with transform. It's what I recommend for most projects.
This method is completely library-free. No dependencies beyond React. Just a scroll listener and some transforms. Here’s how you create a library-free parallax effect:
The layer structure
First, we set up our layers with fixed positioning and refs to manipulate them.
const backImageRef = useRef<HTMLImageElement>(null);
const midImageRef = useRef<HTMLImageElement>(null);
const frontImageRef = useRef<HTMLImageElement>(null);
return (
<div className="fixed inset-0 w-full h-full overflow-hidden">
{/* Back layer - celestial gases and nebulae */}
<img
ref={backImageRef}
src="<https://cdn.builder.io/api/v1/image/assets%2F...%2F95b922f380c94928900347b8088b0c4f>"
alt=""
className="absolute inset-0 w-full h-full object-cover"
style={{ willChange: "transform" }}
/>
{/* Middle layer - large purple planet */}
<img
ref={midImageRef}
src="<https://cdn.builder.io/api/v1/image/assets%2F...%2F1f33aa1dbcdf4d20afa9b5c701d110ef>"
alt=""
className="absolute object-contain"
style={{
willChange: "transform",
left: "calc(15% - 250px)",
top: "calc(15% + 200px)",
width: "70%",
height: "70%",
}}
/>
{/* Front layer - cresting planet sphere */}
<img
ref={frontImageRef}
src="<https://cdn.builder.io/api/v1/image/assets%2F...%2Fe495996c1879406cbd308e74b0c93814>"
alt=""
className="absolute w-full h-full object-cover"
style={{
willChange: "transform",
objectPosition: "center top",
top: "-20%",
transform: window.innerWidth < 768 ? "translateY(680px)" : "translateY(820px)",
}}
/>
</div>
);What this is doing:
fixedpositioning keeps the parallax container pinned to the viewport while content scrolls over it- Each layer gets its own ref so we can transform it independently on scroll
willChange: transformtells the browser to optimize these elements for transformation- The front layer starts with a large offset so it enters the viewport as you scroll down
- Mobile gets a different offset (680px vs 820px) because the viewport is smaller
The scroll handler with different speeds
Now we attach a scroll listener that moves each layer at a different rate.
useEffect(() => {
const handleScroll = () => {
const currentScrollY = window.scrollY;
if (backImageRef.current) {
backImageRef.current.style.transform = `translateY(${-currentScrollY * 0.3}px)`;
}
if (midImageRef.current) {
midImageRef.current.style.transform = `translateY(${-currentScrollY * 0.4}px)`;
}
if (frontImageRef.current) {
const baseOffsetMobile = 680;
const baseOffsetDesktop = 820;
const isMobile = window.innerWidth < 768;
const baseOffset = isMobile ? baseOffsetMobile : baseOffsetDesktop;
frontImageRef.current.style.transform = `translateY(${baseOffset - currentScrollY * 0.5}px)`;
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);What this is doing:
window.scrollYgives us the current scroll position in pixels- Each layer multiplies scroll by a different speed: 0.3x, 0.4x, 0.5x
- Negative values for back and mid layers move them up as you scroll down, creating the parallax lag
- The front layer subtracts scroll from its base offset, so it enters the viewport
passive: truetells the browser we won't callpreventDefault(), enabling scroll optimizations- Cleanup removes the listener on unmount
Why this method works so well
This approach is simple, direct, and performant. On every scroll event, we read the scroll position once and update three transforms. That's it. No calculations, no state updates, no re-renders. Just direct DOM manipulation.
The browser can optimize transform operations because they run on the compositor thread. This means smooth 60fps scrolling even on slower devices.
Other approaches worth knowing
While direct scroll with transform is the best all-around choice, here are three alternatives:
Scroll with easing (requestAnimationFrame)
Add lerp (linear interpolation) to make layers ease into position instead of snapping. Creates buttery smooth motion but uses more CPU and adds slight input lag. Good for high-end sites where ultra-smooth is the goal. Overkill for most projects.
Pure CSS scroll-driven animations
Use animation-timeline: scroll(root) to tie animations to scroll position. Zero JavaScript, runs entirely on the browser’s compositor thread. The future of parallax, but browser support is limited (no Firefox yet as of 2026). Less flexible for responsive behavior.
IntersectionObserver with scroll progress
Only runs parallax when the section is visible. Calculates scroll progress relative to the section, not the whole page. Great for multiple parallax sections throughout a long page. Overkill for hero sections that are always visible on load.
Bottom line: Stick with Method 1 (direct scroll with transform) unless you have a specific reason to try something else. It's simple, works everywhere, and performs well.
The fastest way: Build it with AI using Fusion
Here's the shortcut: instead of hand-coding scroll listeners and transform math, use Fusion to generate the entire implementation.
With Fusion you describe what you want, and it writes production-ready code. For parallax, this means you can iterate visually without touching JavaScript.
How to generate parallax with a prompt
The key is being specific about speeds and positioning. Here's the prompt I used:
create a parallax scrolling hero with three space-themed layers.
use fixed positioning with refs and transforms on scroll.
background should be celestial gases and nebulae, moving at 0.3x speed.
middle layer is a large purple planet, moving at 0.4x speed.
foreground is a cresting planet sphere entering from below, starting offset and moving at 0.5x speed.
make transforms use translateY for performance.
add willChange: transform to each layer.
handle mobile with different base offsets.Fusion generates the component, complete with refs, scroll listeners, and mobile handling. The first pass got 90% there. I gave feedback ("adjust the foreground offset for mobile"), and it updated the code.
Add a fade-to-black gradient
One detail that makes the Builder parallax feel polished is the bottom gradient that it added. This fades to black, and this creates a smooth transition into the content below.
{/* Bottom gradient fade to black */}
<div
className="absolute bottom-0 left-0 right-0 h-[300px] pointer-events-none"
style={{
background: "linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.8) 60%, rgb(0, 0, 0) 100%)",
}}
/>What this is doing:
- Absolutely positioned at the bottom of the parallax container
pointer-events-nonelets clicks pass through to content below- Gradient starts transparent, fades to 80% black, then fully opaque
- 300px height gives plenty of room for the fade
This subtle detail prevents the jarring transition from the parallax background to the solid content section. It is one of those tiny touches that makes the whole effect feel premium.
Best practices
Key things to get right:
- Keep it subtle: Speed differences of 0.2-0.5 feel natural. Beyond 0.7 can induce motion sickness.
- Use
transform, nottop: Transforms run on the compositor thread for smooth 60fps. - Add
willChange: transform: Tells browsers to optimize these elements. - Optimize images: Compress with WebP/AVIF, serve from a CDN.
- Test on mobile: Adjust speeds or disable on smaller screens if needed.
- Respect
prefers-reduced-motion: Disable parallax for users who need it.
Handle reduced motion preferences
Always respect user preferences for reduced motion. Add this check to your useEffect:
useEffect(() => {
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (prefersReducedMotion) {
// Set layers to their final positions
if (backImageRef.current) {
backImageRef.current.style.transform = "translateY(0)";
}
if (midImageRef.current) {
midImageRef.current.style.transform = "translateY(0)";
}
if (frontImageRef.current) {
const baseOffset = window.innerWidth < 768 ? 680 : 820;
frontImageRef.current.style.transform = `translateY(${baseOffset}px)`;
}
return; // skip scroll listener
}
const handleScroll = () => {
// ... parallax logic
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);If the user has reduced motion enabled, we position the layers statically and skip the scroll listener entirely. This respects accessibility without breaking the layout.
Full implementation reference
Here's the complete parallax implementation we analyzed, cleaned up for reference:
import { useEffect, useRef } from "react";
export default function ParallaxHero() {
const backImageRef = useRef<HTMLImageElement>(null);
const midImageRef = useRef<HTMLImageElement>(null);
const frontImageRef = useRef<HTMLImageElement>(null);
useEffect(() => {
// Check for reduced motion preference
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (prefersReducedMotion) {
if (backImageRef.current) backImageRef.current.style.transform = "translateY(0)";
if (midImageRef.current) midImageRef.current.style.transform = "translateY(0)";
if (frontImageRef.current) {
const baseOffset = window.innerWidth < 768 ? 680 : 820;
frontImageRef.current.style.transform = `translateY(${baseOffset}px)`;
}
return;
}
const handleScroll = () => {
const currentScrollY = window.scrollY;
// Background layer - slowest (0.3x)
if (backImageRef.current) {
backImageRef.current.style.transform = `translateY(${-currentScrollY * 0.3}px)`;
}
// Middle layer - medium speed (0.4x)
if (midImageRef.current) {
midImageRef.current.style.transform = `translateY(${-currentScrollY * 0.4}px)`;
}
// Foreground layer - fastest (0.5x) with base offset
if (frontImageRef.current) {
const baseOffsetMobile = 680;
const baseOffsetDesktop = 820;
const isMobile = window.innerWidth < 768;
const baseOffset = isMobile ? baseOffsetMobile : baseOffsetDesktop;
frontImageRef.current.style.transform = `translateY(${baseOffset - currentScrollY * 0.5}px)`;
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll(); // run once on mount
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<div className="fixed inset-0 w-full h-full overflow-hidden">
{/* Background layer - celestial gases and nebulae */}
<img
ref={backImageRef}
src="<https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F95b922f380c94928900347b8088b0c4f?format=webp&width=1024>"
alt=""
className="absolute inset-0 w-full h-full object-cover"
style={{ willChange: "transform" }}
/>
{/* Middle layer - large purple planet */}
<img
ref={midImageRef}
src="<https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F1f33aa1dbcdf4d20afa9b5c701d110ef?format=webp&width=1024>"
alt=""
className="absolute object-contain"
style={{
willChange: "transform",
left: "calc(15% - 250px)",
top: "calc(15% + 200px)",
width: "70%",
height: "70%",
}}
/>
{/* Foreground layer - cresting planet sphere */}
<img
ref={frontImageRef}
src="<https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2Fe495996c1879406cbd308e74b0c93814?format=webp&width=1024>"
alt=""
className="absolute w-full h-full object-cover"
style={{
willChange: "transform",
objectPosition: "center top",
top: "-20%",
transform: typeof window !== "undefined" && window.innerWidth < 768
? "translateY(680px)"
: "translateY(820px)",
}}
/>
{/* Bottom gradient fade to black */}
<div
className="absolute bottom-0 left-0 right-0 h-[300px] pointer-events-none"
style={{
background: "linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.8) 60%, rgb(0, 0, 0) 100%)",
}}
/>
</div>
);
}Tuning tips:
- Adjust speed multipliers (0.3, 0.4, 0.5) to change the parallax intensity
- Lower multipliers = more dramatic effect, but keep them under 0.7 to avoid motion sickness
- Change
baseOffsetMobileandbaseOffsetDesktopto control when the foreground enters - Add horizontal movement by including
translateXin the transforms for more depth - Use a resize handler to recalculate offsets if the viewport changes
Try out Fusion for free and we'd love to hear your feedback!
Design and code in one platform
Builder.io visually edits code, uses your design system, and sends pull requests.
Design and code in one platform
Builder.io visually edits code, uses your design system, and sends pull requests.