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
Frequently Asked Questions
How much travel is too much?
For decorative background layers, keep total displacement at or below roughly 8 pixels per viewport of scroll, and keep the speed ratio between any two moving planes at 0.85 or higher. Those numbers come from vestibular-safety guidance rather than from taste, and they are considerably more restrained than most parallax designs assume.
The number that matters is not any single layer’s travel but the difference between simultaneously moving layers, because relative motion is what the eye tracks. Two layers each travelling a modest 20 pixels in opposite directions produce 40 pixels of relative displacement, and an audit that checks each layer alone will pass that composition.
Why does a gap appear at the top or bottom of the section?
Because the layer is exactly as tall as its container and then moves. Any translation pulls one edge into view. Size the layer larger than its container by the travel distance and offset it by half that amount, so both edges stay outside the visible area across the whole range — then clip the container, or the oversized layer bleeds into the section above. Clipping also caps the painted area, which keeps the layer’s texture small.
Should each layer get its own timeline?
Usually one shared timeline with different travel distances is both simpler and more correct. The layers describe depth relative to a single scroll, so they should read the same progress and differ only in how far they move. Separate view() timelines per layer give each one its own progress, which produces depth that changes as the section crosses the viewport — occasionally the intent, more often a bug that only shows up on short viewports.
Does parallax hurt performance?
Not inherently, if it is built from transforms on promoted layers: the frames cost almost nothing. What does hurt is the surrounding decisions — a full-viewport layer at a high device pixel ratio reserves a large texture, several such layers exhaust GPU memory on mid-range phones, and a layer that animates background-position instead of transform repaints on every frame.
Profile the layer count and texture memory rather than the animation itself; the animation is rarely where the cost is. The Layers panel answers this faster than any performance recording.
How does parallax interact with a reduced-motion preference?
It is one of the few effects where the honest reduced state is “off”, not “smaller”. Depth simulation is the strongest vection trigger in common use, and a reduced version of it is still depth simulation. Pin every layer, keep the composition — the overlap, the shadows, the scale relationships all still read as depth without any movement — and the section loses very little.
Where the site offers a motion scale rather than a binary preference, parallax is the effect that should reach zero first as the scale comes down.
Does parallax work inside a nested scroll container?
Yes, and it is often the better structure. A section that scrolls internally gives the effect a bounded, predictable range instead of tying it to the whole document, which makes the travel distances easier to reason about and stops the composition changing when content is added elsewhere on the page.
The one thing to watch is scroller resolution: scroll(nearest) picks the closest scrollable ancestor, and inside a nested container that is the container rather than the document. When both are in play — a layer that should follow the page inside a section that scrolls — name the timeline explicitly rather than relying on the default.
What does parallax look like when the timeline never resolves?
Exactly like a page with no parallax: every layer sits at its initial transform and the composition reads as flat. That is the correct failure mode, and it is worth confirming deliberately rather than assuming it. Write the layers’ resting state as the default, put the movement inside the feature query, and the unsupported rendering is a static composition that was designed rather than one that happened.
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