Parallax Effects with Pure CSS
The CSS Scroll-Driven Animations Level 1 spec (shipped Chrome 115+, Safari 17.4+) lets you bind @keyframes directly to scroll position, turning the browser’s compositor into a zero-cost parallax engine. No scroll event listener, no requestAnimationFrame loop, no JavaScript bundle overhead — the compositor thread reads scroll offset and interpolates transform values independently of the main thread, so depth layers render at native frame rates even during heavy scripting.
Pages in This Section
- Smooth Parallax Scrolling Without JavaScript — multi-layer depth effects with a
--speedCSS custom property,will-changetiming strategy, and view transition integration
Syntax Reference
The shipped CSS Scroll-Driven Animations Level 1 API exposes two scroll-timeline binding functions. For parallax, scroll() is the right choice.
| Property | Value / Signature | Notes |
|---|---|---|
animation-timeline |
scroll( [scroller] [axis] ) |
root targets the viewport; self targets the element’s own scroll container |
animation-timeline |
view( [axis] [inset] ) |
Progress tied to element visibility — useful for reveal-on-scroll, not full-page parallax |
animation-range |
<phase> <pct> <phase> <pct> |
entry, exit, cover, contain phases; 0%–100% within that phase |
animation-timeline axis |
block (default), inline, x, y |
block matches vertical scroll in horizontal-writing documents |
will-change |
transform |
Hints compositor to promote to GPU layer; use selectively |
contain |
layout paint |
Restricts invalidation to the local subtree |
Removed spec syntax to avoid: the old @scroll-timeline at-rule with source: and scroll-offsets: descriptors was removed before shipping. Any code using it will silently fall back to no animation in current browsers.
Minimal Working Example
This self-contained snippet runs in Chrome 115+ and Safari 17.4+ with no build step or dependencies:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
/* Tall page so there is something to scroll */
body { margin: 0; min-height: 300vh; font-family: sans-serif; }
.scene {
position: relative;
height: 100vh;
overflow: hidden;
}
@keyframes parallax-bg {
from { transform: translateY(0); }
to { transform: translateY(-20vh); }
}
@keyframes parallax-fg {
from { transform: translateY(0); }
to { transform: translateY(-10vh); }
}
/* Background layer — moves twice as far, appears farther away */
.layer-bg {
position: absolute;
inset: -20vh 0 0;
background: linear-gradient(160deg, #2d1b4e, #1a2744);
will-change: transform;
}
/* Foreground layer — moves half as far, appears closer */
.layer-fg {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #e8d5f5;
font-size: clamp(1.5rem, 5vw, 3rem);
will-change: transform;
}
/* Bind both layers to the root scroll timeline */
@supports (animation-timeline: scroll()) {
.layer-bg {
animation: parallax-bg linear both;
animation-timeline: scroll(root block);
}
.layer-fg {
animation: parallax-fg linear both;
animation-timeline: scroll(root block);
}
}
/* Honour the user's motion preference */
@media (prefers-reduced-motion: reduce) {
.layer-bg,
.layer-fg {
animation: none;
animation-timeline: auto;
transform: none;
}
}
</style>
</head>
<body>
<div class="scene">
<div class="layer-bg" aria-hidden="true"></div>
<div class="layer-fg">
<p>Scroll down to see the depth effect</p>
</div>
</div>
</body>
</html>
The velocity differential (-20vh vs -10vh) creates perceived depth without any JavaScript. The @supports guard means non-supporting browsers simply see the static layers.
Animation-Range and Timeline Scoping
By default, scroll(root block) maps the entire document scroll height to 0%–100%. Narrowing the range with animation-range lets you activate parallax only while an element is in the viewport — useful for section-specific effects that should not run while the element is off-screen.
/* Effect active from the moment the section enters until it fully covers the viewport */
.hero-section .layer-bg {
animation: parallax-bg linear both;
animation-timeline: scroll(root block);
animation-range: entry 0% cover 100%;
}
/* Effect active from 20% entry through 80% coverage — tighter window */
.hero-section .layer-fg {
animation: parallax-fg linear both;
animation-timeline: scroll(root block);
animation-range: entry 20% cover 80%;
}
For parallax that must respond to a specific scrollable container rather than the viewport, use a named scroll timeline:
.scroll-container {
overflow-y: auto;
/* Declare a named timeline on the scroll container */
scroll-timeline-name: --section-scroll;
scroll-timeline-axis: block;
}
.parallax-layer {
/* Bind to that named timeline instead of root */
animation: parallax-bg linear both;
animation-timeline: --section-scroll;
}
Named timelines solve the most common scoping bug: a layer inside an overflow: auto ancestor that should respond to the viewport scroll but instead binds to the container’s scroll because scroll() resolves to the nearest scrollable ancestor by default.
Compositor-Safe Properties
Only transform and opacity run fully on the compositor thread. Everything else forces the browser back to the main thread for layout or paint work on every frame, eliminating the performance advantage of scroll-driven animations.
| Property | Runs on compositor | Forces main-thread work |
|---|---|---|
transform: translateY() |
Yes | No |
transform: translateX() |
Yes | No |
transform: scale() |
Yes | No |
opacity |
Yes | No |
filter: blur() |
Partial (GPU, not compositor) | Paint only |
top / left / bottom / right |
No | Layout + Paint |
margin / padding |
No | Layout + Paint |
width / height |
No | Layout + Paint |
background-color |
No | Paint |
clip-path (complex) |
No | Paint |
will-change: transform hints the browser to promote the element to a dedicated GPU layer before the animation starts, avoiding the promotion cost mid-scroll. Apply it only to elements that will actually animate — over-applying it exhausts GPU memory and can degrade performance rather than improve it.
Common Implementation Patterns
Pattern 1: Speed-Differential Depth with CSS Custom Properties
Use a --speed variable to control velocity without duplicating @keyframes. A single keyframe set drives multiple layers at different rates:
@keyframes parallax-shift {
to { transform: translateY(calc(var(--speed, 0.2) * -100vh)); }
}
/* Background: slowest, appears farthest away */
.layer-mountains {
--speed: 0.08;
animation: parallax-shift linear both;
animation-timeline: scroll(root block);
}
/* Midground */
.layer-trees {
--speed: 0.18;
animation: parallax-shift linear both;
animation-timeline: scroll(root block);
}
/* Foreground: fastest, appears closest */
.layer-rocks {
--speed: 0.35;
animation: parallax-shift linear both;
animation-timeline: scroll(root block);
}
Keep --speed values below 0.5 for body content layers. Viewport-relative offsets above that threshold push displacement past 50vh, which is a documented vestibular trigger for users with motion sensitivity — even outside a prefers-reduced-motion: reduce preference.
Pattern 2: Section-Scoped Reveal Parallax
Activate the parallax only while the section is in view, using view() instead of scroll():
@keyframes section-parallax {
from { transform: translateY(40px); opacity: 0.6; }
to { transform: translateY(0); opacity: 1; }
}
.content-section {
/* view() tracks this element's visibility within the viewport */
animation: section-parallax linear both;
animation-timeline: view(block);
animation-range: entry 0% entry 60%;
}
view() is the right choice when you want a parallax reveal tied to element entry rather than to the full document scroll position. The entry 0% entry 60% range means the effect completes before the element is fully in view, giving a gentle arrival rather than a perpetual drift.
Pattern 3: Horizontal Parallax Band
The same technique applies to translateX for horizontal scroll containers:
.h-scroll-container {
display: flex;
overflow-x: auto;
scroll-timeline-name: --h-scroll;
scroll-timeline-axis: inline;
}
@keyframes horizontal-parallax {
from { transform: translateX(0); }
to { transform: translateX(-15%); }
}
.h-layer-bg {
animation: horizontal-parallax linear both;
animation-timeline: --h-scroll;
}
Pass inline as the axis for horizontal scroll containers. The default block axis would measure vertical scroll, which is zero in a horizontal-only container.
Pattern 4: Stacking Context Isolation for Layered Cards
When stacking multiple parallax cards, each needs its own stacking context to prevent z-index conflicts during 3D transform:
.parallax-card {
contain: layout paint; /* isolate invalidation */
transform-style: preserve-3d; /* 3D context for child layers */
isolation: isolate; /* own stacking context */
}
.parallax-card .inner {
transform: translateZ(0); /* explicit compositor layer */
backface-visibility: hidden; /* prevents subpixel flicker */
will-change: transform;
}
contain: layout paint is the most impactful single declaration for parallax performance. It tells the browser that nothing outside the card’s box is affected by layout changes inside it, cutting the cost of each compositor frame.
Browser Support and @supports Guard
animation-timeline: scroll() shipped in:
- Chrome 115 (July 2023)
- Edge 115 (July 2023)
- Safari 17.4 (March 2024)
- Firefox — not yet shipped as of mid-2026 (tracked under flag
layout.css.scroll-driven-animations.enabled)
For progressive enhancement that degrades cleanly:
/* Static baseline — always renders, no animation */
.parallax-layer {
transform: none;
}
/* Enhancement layer — only browsers that support scroll timelines */
@supports (animation-timeline: scroll()) {
.parallax-layer {
animation: parallax-bg linear both;
animation-timeline: scroll(root block);
will-change: transform;
}
}
For Firefox users who need visual depth, an IntersectionObserver fallback that sets a CSS custom property is a practical middle ground. See CSS Scroll-Driven Animations vs IntersectionObserver for the trade-off analysis.
For teams targeting Safari 15–16, a ScrollTimeline polyfill is available. See how to polyfill scroll-timeline for Safari for the integration steps.
Gotchas and Failure Modes
-
Layer trapped in an overflow container.
animation-timeline: scroll()(no argument) resolves to the nearest scrollable ancestor. If a parallax layer sits insideoverflow: autooroverflow: hidden, it binds to that container’s scroll — which may be zero. Fix: always use the explicitscroll(root block)form for viewport-relative parallax. -
Animating layout properties. Using
top,margin, orwidthin@keyframestriggers layout recalculation on every compositor frame, causing jank. The Layers panel will show the element dropping off the GPU layer. Replace all position/size animations withtransform: translate(). -
animationshorthand resetsanimation-timeline. Writinganimation: parallax-bg linear bothafter a separately declaredanimation-timeline: scroll(root)resetsanimation-timelineback toauto. Always setanimation-timelinelast, or combine both in the same declaration block. -
prefers-reduced-motionpartial reset.animation: nonedoes not resetanimation-timeline. The timeline continues running at near-zero duration and can still produce visible drift. Always pair the reset:@media (prefers-reduced-motion: reduce) { .parallax-layer { animation: none; animation-timeline: auto; /* explicit reset required */ transform: none; } } -
contain: strictbreaksview().contain: strict(which impliescontain: size) preventsview()from measuring the element’s position within the scroll container. Usecontain: layout paintinstead ofcontain: stricton parallax containers. -
scroll-snapcompresses the animation range. Ascroll-snap-typeparent snaps the scroll position to discrete stops, which collapsesscroll()progress to discrete jumps rather than a smooth gradient. Either remove scroll-snap from the parallax container, or switch toview()on each snap child, which tracks entry/exit independently of the snap position.
Performance Checklist
- Animate only
transformoropacityin parallax@keyframes— never layout or paint properties - Apply
will-change: transformto each parallax layer before scroll begins; remove it onscrollendif you have many layers - Use
contain: layout painton parallax containers to scope layout invalidation - Use
scroll(root block)explicitly — never rely on the default nearest-ancestor resolution for viewport parallax - Keep
translateYoffsets below50vh(or0.5 * 100vhwith--speed) to avoid vestibular triggers - Verify zero
LayoutandPaintevents in the Performance panel during scroll - Check each layer appears on its own GPU tile in the Layers panel
- Wrap all scroll-timeline declarations in
@supports (animation-timeline: scroll()) - Reset both
animationandanimation-timelineinside@media (prefers-reduced-motion: reduce) - Test in Safari 17.4+ separately —
view()inset parsing had early bugs fixed in 17.5
Related
- Smooth Parallax Scrolling Without JavaScript —
--speedvariable layering,will-changelifecycle, and view transition integration - The Rendering Pipeline for Scroll Animations — compositor thread, 16ms frame budget, and which properties stay off the main thread
- Browser Support and Progressive Enhancement —
@supportsguard patterns and polyfill strategies - Implementing prefers-reduced-motion — accessibility reset patterns for scroll-driven animations