Smooth Parallax Scrolling Without JavaScript: CSS-Only
The specific challenge here is replacing requestAnimationFrame-driven parallax — where a scroll listener reads window.scrollY and writes transform values on every frame — with a purely declarative CSS approach that runs entirely on the compositor thread. This technique is part of the broader parallax effects with pure CSS approach enabled by the Scroll-Driven & View Transition Implementation Patterns in Chrome 115+ and Safari 17.4+.
When to use this approach
Use pure-CSS scroll-driven parallax when all of the following are true:
- Static speed ratios. Each layer’s parallax speed is fixed and does not change based on user interaction or application state. If speeds must change dynamically (e.g., on route change or theme switch), CSS custom properties can still carry the value — a single
setPropertycall is acceptable; a per-scrollsetPropertyis not. - Compositor-safe properties only. The motion is expressible as
transformoropacitychanges. Animatingtop,left,margin, orbackground-positionforces the browser back to the rendering pipeline’s main thread and eliminates the performance benefit. - Root scroll or a known scroll container. The parallax tracks the root viewport or a single identified scroll container. Complex nested scroll arrangements where the timeline source changes at runtime are harder to manage declaratively.
- You need
prefers-reduced-motioncompliance at zero cost. The CSS media query disables the motion with two lines; a JS implementation requires explicit event listener teardown.
When JS is still appropriate:
- Parallax speed is calculated from physics (velocity, inertia, spring models).
- The element’s target position depends on cursor coordinates, not scroll offset.
- You target browsers below Chrome 115 and cannot ship the scroll-timeline polyfill for Safari and older engines.
Implementation
Step 1: Define the keyframe with a speed variable
Use a CSS custom property --speed as the only variable in the keyframe. This gives you one keyframe definition that drives every layer at a different rate:
@keyframes parallax-shift {
to {
transform: translateY(calc(var(--speed, 0.2) * -100vh));
}
}
The from state is implicit (translateY(0)). A --speed of 0.2 produces −20vh of vertical travel over the full scroll range. Keep values below 0.5 for content layers — displacement above ~10–15vh crosses thresholds associated with vestibular discomfort, which is addressed in implementing prefers-reduced-motion.
Step 2: Bind each layer to the scroll timeline
/* Distant background: moves slowest */
.layer-bg {
--speed: 0.1;
animation: parallax-shift linear both;
animation-timeline: scroll(root);
}
/* Mid-ground objects */
.layer-mid {
--speed: 0.22;
animation: parallax-shift linear both;
animation-timeline: scroll(root);
}
/* Foreground elements: moves fastest */
.layer-fg {
--speed: 0.38;
animation: parallax-shift linear both;
animation-timeline: scroll(root);
}
animation-timeline: scroll(root) binds the animation progress to the root viewport scroll position. Setting animation-fill-mode: both (via the both shorthand keyword) locks the element at its final keyframe position when the page is scrolled to the bottom, preventing a snap-back.
Step 3: Wrap in a @supports guard and a reduced-motion block
/* Fallback: layers render at natural position, no animation */
.layer-bg,
.layer-mid,
.layer-fg {
transform: none;
}
@supports (animation-timeline: scroll()) {
@media (prefers-reduced-motion: no-preference) {
.layer-bg {
--speed: 0.1;
animation: parallax-shift linear both;
animation-timeline: scroll(root);
}
.layer-mid {
--speed: 0.22;
animation: parallax-shift linear both;
animation-timeline: scroll(root);
}
.layer-fg {
--speed: 0.38;
animation: parallax-shift linear both;
animation-timeline: scroll(root);
}
}
}
Both guards are needed: @supports for browsers that do not implement the API at all, and @media (prefers-reduced-motion: no-preference) to exclude users who have requested reduced motion. Note that @supports (animation-timeline: scroll()) does not gate on scroll(root) — Safari 17.4 supports both, but older patched builds had narrower support, so testing on device remains important.
Step 4: Layer the HTML in stacking order
<div class="parallax-scene">
<div class="layer-bg" aria-hidden="true">
<!-- Background imagery -->
</div>
<div class="layer-mid" aria-hidden="true">
<!-- Mid-ground decoration -->
</div>
<div class="layer-fg" aria-hidden="true">
<!-- Foreground elements -->
</div>
<div class="parallax-content">
<!-- Readable text lives here, outside the animated layers -->
</div>
</div>
.parallax-scene {
position: relative;
/* Do NOT set overflow: hidden here — see Edge cases below */
}
.layer-bg,
.layer-mid,
.layer-fg {
position: absolute;
inset: 0;
/* Prevent layout pollution from animated transforms */
contain: layout style paint;
}
.parallax-content {
position: relative;
z-index: 1;
}
Mark decorative parallax layers aria-hidden="true" — they carry no semantic content and should not be announced by screen readers.
Step 5: Handle dynamic speed changes from JavaScript
If a route change or user preference requires a different speed, update the custom property once — not per scroll frame:
// Run once after route change, never inside a scroll handler
function setParallaxSpeed(layerSelector, speed) {
const layer = document.querySelector(layerSelector);
if (layer) {
layer.style.setProperty('--speed', String(speed));
}
}
Verification
DevTools compositor check
- Open Chrome DevTools → Performance panel → tick Screenshots → start recording.
- Scroll the page manually for two seconds, then stop recording.
- Expand the Main thread row. During your scroll, there should be no
Evaluate Script,Layout, orRecalculate Styleevents triggered by scroll. Any such events indicate a JS scroll listener or an animated layout property is still active. - Open More Tools → Layers. Each
.layer-*element should appear as a separate GPU layer tile. If all layers share one tile, the browser has not promoted them to the compositor — check that you are only animatingtransformoropacity.
CSS API availability check
Run this in the DevTools console on the target page:
console.log(CSS.supports('animation-timeline', 'scroll()'));
// Expected: true in Chrome 115+, Safari 17.4+, Firefox 110+ (behind flag until 128)
Visual frame-rate check
Open Rendering → Frame Rendering Stats (Chrome DevTools). The frames-per-second counter should hold at the display’s native refresh rate (60 or 120 fps) throughout the scroll. Drops indicate either a non-composited property in the keyframe or a JavaScript scroll listener interfering.
Edge cases and gotchas
1. overflow: hidden on an ancestor breaks timeline binding.
Placing a parallax layer inside an ancestor with overflow: hidden or overflow: auto creates a new scroll container. animation-timeline: scroll(root) ignores nested scroll containers and always binds to the document root, but the contain: layout rule on the layer can interact unexpectedly with clipping. Solution: set overflow: clip instead of overflow: hidden on the scene container — clip prevents scrolling without creating a scroll container, so the root timeline binding is unaffected.
/* Prefer this over overflow: hidden for parallax scenes */
.parallax-scene {
overflow: clip;
}
2. Both animation and animation-timeline must be reset for reduced-motion.
The animation shorthand resets named-animation properties but does not reset animation-timeline — that property was added in a later spec revision and is excluded from the shorthand. Failing to reset it leaves the timeline binding active even when animation: none is applied:
@media (prefers-reduced-motion: reduce) {
.layer-bg,
.layer-mid,
.layer-fg {
animation: none;
animation-timeline: auto; /* must be explicit */
transform: none;
}
}
3. scroll-snap on the root compresses the animation range.
If the root scroll container uses scroll-snap-type, snap momentum overrides the natural scroll offset, compressing the range of positions the browser ever reaches. This makes the parallax motion appear jerky or incomplete. The CSS scroll-driven animation still runs, but it runs over a narrowed set of scroll positions. Parallax and root-level snap do not compose cleanly — consider confining snap to inner containers, not the root.
4. will-change: transform on all layers simultaneously causes GPU memory pressure.
Applying will-change: transform statically to every parallax layer promotes all of them to the GPU at page load and holds them there regardless of scroll position. For pages with many large layer images, this can exhaust GPU memory on mobile. Apply it only for the active scroll duration:
const layers = document.querySelectorAll('.layer-bg, .layer-mid, .layer-fg');
document.addEventListener('scroll', () => {
layers.forEach(l => { l.style.willChange = 'transform'; });
}, { passive: true, once: true });
document.addEventListener('scrollend', () => {
layers.forEach(l => { l.style.willChange = 'auto'; });
});
5. Raster-based background images stutter on low-memory devices.
The compositor interpolates transform smoothly, but if the layer’s background image requires re-rasterization at a new resolution during the scroll (e.g., due to a CSS scale change), frames can drop. Use translateY only (no scale), and keep layer images at their intrinsic pixel size or larger to avoid upscaling.
Browser-specific notes
Chrome 115+: Full support for animation-timeline: scroll(root) and animation-range. The Layers panel in DevTools accurately shows GPU promotion state. The scrollend event is available from Chrome 114, so the will-change cleanup pattern in Step 4 works cleanly.
Safari 17.4+: animation-timeline: scroll(root) shipped in Safari 17.4 (March 2024). Safari does not fire scrollend as of Safari 18.0 — the cleanup listener will never trigger. Work around this with a debounced scroll handler as a fallback:
// Safari-safe will-change cleanup (scrollend not supported)
let scrollTimer;
document.addEventListener('scroll', () => {
layers.forEach(l => { l.style.willChange = 'transform'; });
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
layers.forEach(l => { l.style.willChange = 'auto'; });
}, 200);
}, { passive: true });
Firefox 128+: Firefox enabled animation-timeline by default in Firefox 128 (July 2024). Earlier versions required the layout.css.scroll-driven-animations.enabled flag in about:config. The scroll(root) form behaves identically to Chrome. Firefox does implement scrollend, so the cleanup pattern works without modification.
Firefox < 128 and all browsers without support: The @supports (animation-timeline: scroll()) guard ensures layers render at their static positions with no transform applied. No visible breakage; only the depth effect is absent.
Related
- Parallax Effects with Pure CSS — parent page covering multi-layer synchronisation, containment strategies, and the full parallax pattern library
- Understanding the CSS Scroll Timeline API —
scroll()andview()function syntax, named timelines, and timeline scoping - The Rendering Pipeline for Scroll Animations — how style, layout, paint, and composite phases interact with
animation-timeline - How to Polyfill scroll-timeline for Safari — ship to pre-17.4 Safari with the WICG polyfill
- Implementing
prefers-reduced-motion— accessible motion standards and reset patterns for vestibular safety