A parallax effect moves layers at different speeds as the page scrolls. The background drifts slowly, the foreground moves faster, and the difference reads as depth. There are three practical ways to build one today: a pure CSS 3D perspective trick, CSS scroll-driven animations, and reading scroll progress in JavaScript. This guide covers each with code you can paste, then goes through performance, reduced motion and mobile issues, and ends with the parallax and scroll components available in WebberUI.
Option 1: CSS perspective and translateZ
This is the original CSS-only parallax. The scroll container gets a perspective, layers are pushed back with translateZ, and anything further from the camera moves less as you scroll.
.parallax {
height: 100vh;
overflow-x: hidden;
overflow-y: auto;
perspective: 1px;
}
.parallax__layer {
position: absolute;
inset: 0;
}
.parallax__layer--back {
/* push back 1px, then scale up 2x to cancel the perspective shrink */
transform: translateZ(-1px) scale(2);
}
.parallax__layer--base {
transform: translateZ(0);
}The scale factor is 1 + (-translateZ) / perspective, so 1 + 1 / 1 = 2 here.
No JavaScript is involved, but there are two trade-offs:
- The
.parallaxelement has to be the scroller instead of the document. You end up with a nested scroll container, and things like scroll restoration and anchor links need extra handling. - An element with
perspectiveset becomes the containing block forposition: fixeddescendants, so a fixed header inside it will scroll with the container.
Option 2: CSS scroll-driven animations
Scroll-driven animations let you attach ordinary @keyframes to scroll position. scroll() tracks the overall progress of a scroll container, and view() tracks an element's progress through the visible area. For parallax you usually want view().
@keyframes parallax-shift {
from { transform: translateY(-15%); }
to { transform: translateY(15%); }
}
@supports (animation-timeline: view()) {
.parallax-image {
animation: parallax-shift linear both;
/* must come after the shorthand, which resets it */
animation-timeline: view();
animation-range: cover 0% cover 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.parallax-image {
animation: none;
}
}animation-range: cover 0% cover 100% runs the animation from the moment the element's top edge enters the viewport until its bottom edge leaves.
Browser support according to caniuse, checked on October 14, 2026:
| Browser | Status |
|---|---|
| Chrome, Edge | Supported since 115 |
| Safari (macOS and iOS) | Supported since 26.0 |
| Firefox | Not supported in the current release or on Android; caniuse first marks it supported in version 160 |
MDN still labels the feature as limited availability rather than Baseline. If Firefox traffic matters to you, keep the @supports wrapper so unsupported browsers render a static layout. Parallax is decoration, so nobody loses content when it's missing.
Option 3: Motion's useScroll and useTransform
In React, the most common choice is Motion (the motion package, imported from motion/react). useScroll gives you scroll progress as a 0 to 1 motion value, and useTransform maps it to an offset.
"use client";
import { useRef } from "react";
import { motion, useReducedMotion, useScroll, useTransform } from "motion/react";
export function ParallaxImage({ src, alt }: { src: string; alt: string }) {
const ref = useRef<HTMLDivElement>(null);
const reduceMotion = useReducedMotion();
// 0 when the bottom of the frame enters the viewport, 1 when its top leaves
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"],
});
// The image is 140% tall (20% spare on each side), so ±10% never shows a gap
const y = useTransform(
scrollYProgress,
[0, 1],
reduceMotion ? ["0%", "0%"] : ["-10%", "10%"],
);
return (
<div ref={ref} className="relative h-[60vh] overflow-hidden">
<motion.img
src={src}
alt={alt}
style={{ y }}
className="absolute inset-x-0 -top-[20%] h-[140%] w-full object-cover"
/>
</div>
);
}Each offset entry reads as "target edge, container edge", so "start end" means the target's top meets the viewport's bottom. According to the Motion docs, when progress is passed through useTransform to transform, opacity, clipPath or filter, useScroll will use the browser's native ScrollTimeline where it can, which takes scroll measurement off the main thread.
The percentages matter more than they look. A y of 10% is relative to the image, not the frame, so if the image only had 10% of spare height you would see empty space at the extremes. Size the layer first, then pick the range.
Performance
- Animate
transformandopacityonly. Driving parallax throughtop,marginorbackground-positionforces layout or paint on every frame. - Don't call
setStatefrom a scroll listener. That re-renders the subtree on every scroll event. Motion values update outside React's render cycle, which is why they're the safer tool here. - Go easy on
will-change. MDN describes it as a last resort. Each element withwill-change: transformmay get its own compositor layer, and a page with a dozen full-bleed images all promoted at once can exhaust memory on a phone. - Compress the images. Parallax layers are usually 30 to 40 percent larger than their frame. Account for that size, but don't ship the original export.
Reduced motion
Parallax can cause dizziness or nausea for people with vestibular disorders. The operating system's reduce motion setting is exposed in CSS as prefers-reduced-motion, and in React you can read it with Motion's useReducedMotion(). When it's on, set offsets to zero or swap in a static layout. WCAG 2.3.3, Animation from Interactions (level AAA), asks for exactly this: non-essential motion triggered by interaction should be possible to turn off.
Mobile caveats
- Don't rely on
background-attachment: fixed. caniuse lists iOS Safari as only partially supporting the property, and fixed-background parallax often doesn't stay fixed on iPhones. - The address bar changes
100vh. As mobile browser toolbars collapse and expand,vh-based sections resize mid-scroll. Usesvhordvhfor parallax section heights. - Use smaller offsets. Narrow screens and fast flick scrolling make the same displacement look much stronger than on desktop. Scale the range down with a media query or based on viewport width.
WebberUI parallax and scroll components
If you'd rather not handle all of the above yourself, WebberUI has several ready-made React components for this. They're built on Motion and all of them respect reduced motion. WebberUI is source-available: installing a component copies its file into your components/ui/ directory, where you can edit it.
Parallax Section
parallax-section wraps several ParallaxLayer elements in a ParallaxSection. Each layer's speed sets its displacement multiplier: positive values act as foreground, negative values as background. It accepts a container ref for nested scroll containers, and layers stay in place when reduced motion is on.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add parallax-section.ennpx webberui@latest add https://webberui.com/r/parallax-section.en.jsonimport {
ParallaxLayer,
ParallaxSection,
} from "@/components/ui/parallax-section";
<ParallaxSection className="h-[420px] bg-neutral-950">
{/* Background: sinks slowly */}
<ParallaxLayer decorative speed={-0.6} className="absolute inset-0">
<div className="absolute inset-0 bg-gradient-to-b from-indigo-500/30 to-transparent" />
</ParallaxLayer>
{/* Foreground: floats up quickly and fades out */}
<ParallaxLayer
speed={1.2}
fade
className="absolute inset-0 flex items-center justify-center"
>
<h2 className="text-4xl font-bold text-white">Multi-layer parallax</h2>
</ParallaxLayer>
</ParallaxSection>;Zoom Parallax
zoom-parallax creates a scroll track (h-[300vh] by default) with a sticky stage pinned in the middle. As you scroll, the images scale up from the center, and outer images scale faster, so it feels like flying through the group. With reduced motion on, it renders a static grid instead.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add zoom-parallax.ennpx webberui@latest add https://webberui.com/r/zoom-parallax.en.jsonRelated components
| Component | What it does |
|---|---|
| Parallax Depth Cards | Layered cards that shift, scale and blur at different rates, for portfolio or feature sections |
| Hero Parallax Grid | A product or portfolio wall where alternating rows move in opposite directions, with a 3D tilt on entry |
| Horizontal Scroll Section | A pinned section that turns vertical scrolling into horizontal movement |
| Sticky Reveal | Text scrolls on one side while a pinned visual cross-fades on the other |
| Image Sequence Scrub | Scrubs through a frame sequence with scroll progress, the way product launch pages do |
| Velocity Skew | Skews and blurs content based on scroll speed, then springs back when scrolling stops |
The full list is on the scroll effects category page.
Which one to use
- One background image with a gentle shift, and Firefox can live without it: CSS scroll-driven animations, with no JavaScript to ship.
- Consistent behavior across browsers, or effects tied to React state: Motion's
useScroll. - Multiple depth layers, nested scroll containers and reduced motion already handled: install
parallax-sectionorzoom-parallaxand adjust from there.