Pure CSS parallax has two reliable methods. The perspective method makes a scroll container with perspective and pushes background layers back with translateZ, then scales them up by (perspective minus z) divided by perspective. The newer method ties a translate animation to animation-timeline: view(). Both should switch off under prefers-reduced-motion.
| Method | How it moves | Watch out for |
|---|---|---|
| perspective + translateZ | Deeper layers scroll slower inside a 3D scroll container | Needs its own scroll container, not the page |
| animation-timeline: view() | A keyframe translate follows the element through the viewport | Chrome and Edge 115+, Safari 26+, Firefox 161+ |
| background-attachment: fixed | Background stays still while content moves over it | Partial support on iOS Safari; lags on many phones |
| JavaScript fallback | Scroll position sets a transform each frame | Use requestAnimationFrame and a passive listener |
Updated
Most parallax tutorials stop at background-attachment: fixed, which iPhones only partly support. This guide shows the two methods that actually animate layers, explains the scale maths so your layers do not shrink, and gives a small script fallback plus the reduced motion handling that keeps the effect from making anyone queasy.
.parallax { perspective: 1px; overflow-y: auto; overflow-x: hidden; }
.group { transform-style: preserve-3d; }
.back { transform: translateZ(-2px) scale(3); }Scroll inside the frame. The striped layer sits at translateZ(-2px) and is scaled by 3, so it moves at a third of the scroll speed.
<style>
body { margin: 0 }
.parallax { height: 240px; overflow-x: hidden; overflow-y: auto; perspective: 1px }
.group { position: relative; height: 240px; transform-style: preserve-3d }
.back { position: absolute; inset: 0; transform: translateZ(-2px) scale(3);
background: repeating-linear-gradient(45deg, #4F98FB 0 20px, #93c5fd 20px 40px) }
.front { position: absolute; inset: 0; display: grid; place-items: center;
font: 700 28px system-ui; color: #fff; text-shadow: 0 2px 6px rgb(0 0 0 / .4) }
.plain { position: relative; height: 240px; background: #fff; padding: 16px; font: 16px system-ui }
@media (prefers-reduced-motion: reduce) { .back { transform: none } }
</style>
<div class="parallax">
<div class="group"><div class="back"></div><div class="front">Scroll me</div></div>
<div class="plain">Normal content scrolls at full speed.</div>
</div>The animation only runs where animation-timeline is supported and the reader has not asked for reduced motion.
.hero { overflow: hidden; height: 60vh; }
.hero img { width: 100%; height: 130%; object-fit: cover; }
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.hero img {
animation: drift linear both;
animation-timeline: view(); /* after the shorthand */
}
}
}
@keyframes drift {
from { transform: translateY(-15%); }
to { transform: translateY(0); }
}Only used when scroll-driven animations are missing. The listener is passive and work happens once per frame.
const layers = document.querySelectorAll("[data-speed]");
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
const supported = CSS.supports("animation-timeline: view()");
if (!reduce && !supported) {
let queued = false;
const update = () => {
for (const el of layers) {
el.style.transform = `translateY(${window.scrollY * Number(el.dataset.speed)}px)`;
}
queued = false;
};
addEventListener("scroll", () => {
if (!queued) { queued = true; requestAnimationFrame(update); }
}, { passive: true });
}
The container becomes the scroller and gets a perspective value. Its children keep their 3D position with transform-style: preserve-3d. When the container scrolls, a layer sitting further back in z covers less screen distance than one at z equal to zero, so it appears to lag behind.
Because perspective on an element also creates a stacking context and a containing block for fixed children, keep the parallax wrapper self-contained. Headers that must stay fixed to the window belong outside it.
Chrome's own write-up on performant parallax notes that momentum scrolling on iOS used to flatten the 3D context, which killed the perspective trick on iPhones. Test on a real device, and keep the content readable when layers do not move.
The scroll-driven method has no such problem because it runs on the page's normal scroller. Where it is unsupported, the keyframes never run and the layer stays in its resting position, which is a clean fallback.
Pushing an element away from the viewer with a negative translateZ shrinks it, exactly like a distant object. Add scale((p - z) / p) to restore its size. With perspective: 1px and translateZ(-2px), that is scale(3).
Not reliably. caniuse lists only partial support in iOS Safari and notes that most mobile devices are slow to update a fixed background after scrolling. Treat it as a desktop-only nicety and use one of the transform methods when the effect matters.
Large motion tied to scrolling can cause dizziness or nausea for people with vestibular disorders. Honour the operating system's reduce motion setting with the prefers-reduced-motion media query and show the layers static when it is on.
As of October 2026, caniuse lists animation-timeline in Chrome and Edge from 115, Safari from 26 and Firefox from 161. Older browsers simply skip the animation, so the page still reads correctly without the effect.
Related: Scroll animations guide, prefers-reduced-motion guide, CSS transforms guide, CSS scroll snap, CSS animation generator.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.