CSS Parallax Scrolling
Background and foreground layers move at different speeds using scroll-driven animations.
A CSS parallax effect moves layers by different amounts as the page scrolls, and it now runs without a scroll event listener. animation-timeline binds a set of @keyframes to a scroll position instead of to elapsed time. Because the browser reads the scroll offset itself, the layers land exactly where the scrollbar is rather than a frame or two behind it.
The scene below is a 300px window onto a 600px panel. Four layers share one timeline and differ only in how far they travel. Two more builds follow: a horizontal scroller that binds its timeline to the x axis, and a position: sticky version that produces a similar depth cue in browsers that have no scroll-driven animation support.
Scroll inside the box
Scroll to explore
Each layer moves at a different speed
↑ scroll inside the box ↑
HTML
<div class="parallax-scene">
<div class="parallax-inner">
<div class="parallax-layer layer-bg">
<div class="parallax-stars"></div>
</div>
<div class="parallax-layer layer-mid">
<div class="parallax-moon"></div>
<div class="parallax-text">
<h2>Scroll to explore</h2>
<p>Each layer moves at a different speed</p>
</div>
</div>
<div class="parallax-layer layer-front">
<div class="parallax-hills"></div>
</div>
</div>
<p class="scroll-hint">↑ scroll inside the box ↑</p>
</div>
CSS
/* The scroller. Every layer inside is absolutely positioned,
so this needs a real width and height of its own. */
.parallax-scene {
width: 100%;
position: relative;
height: 300px;
overflow-y: scroll;
overflow-x: hidden;
/* Named rather than scroll(nearest): .parallax-inner below uses
overflow: hidden, which makes it a scroll container too, so "nearest"
would bind to that instead of this scene. It has nothing to scroll, so
the timeline would sit inactive and no layer would ever move. A named
timeline is looked up on ancestors, so it always finds this element. */
scroll-timeline-name: --parallax;
border-radius: 12px;
scroll-behavior: smooth;
}
/* Taller than the scene, which is what gives you scroll range */
.parallax-inner {
height: 600px;
position: relative;
overflow: hidden;
}
.parallax-layer {
position: absolute;
inset: 0;
will-change: transform;
}
.layer-bg {
background:
radial-gradient(ellipse at 50% 20%, rgba(184, 255, 87, .08) 0%, transparent 60%),
linear-gradient(180deg, #0c0c0d 0%, #0f1a0a 100%);
animation: parallax-bg linear both;
animation-timeline: --parallax;
}
.layer-mid {
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 100px;
animation: parallax-mid linear both;
animation-timeline: --parallax;
}
.layer-front {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: 2rem;
animation: parallax-front linear both;
animation-timeline: --parallax;
}
.parallax-stars {
position: absolute;
inset: 0;
background-image:
radial-gradient(circle 1px at 15% 20%, #88888f 0%, transparent 100%),
radial-gradient(circle 1px at 35% 45%, #88888f 0%, transparent 100%),
radial-gradient(circle 2px at 55% 15%, #b8ff57 0%, transparent 100%),
radial-gradient(circle 1px at 70% 60%, #88888f 0%, transparent 100%),
radial-gradient(circle 1px at 85% 30%, #88888f 0%, transparent 100%),
radial-gradient(circle 2px at 25% 70%, #38bdf8 0%, transparent 100%),
radial-gradient(circle 1px at 90% 75%, #88888f 0%, transparent 100%);
animation: parallax-stars linear both;
animation-timeline: --parallax;
}
.parallax-moon {
width: 80px;
height: 80px;
background: radial-gradient(circle at 35% 35%, #ffd444, #ff9040);
border-radius: 50%;
box-shadow: 0 0 30px rgba(255, 212, 68, .3);
position: absolute;
top: 60px;
right: 80px;
}
.parallax-hills {
width: 120%;
height: 120px;
background: #141415;
border-radius: 50% 50% 0 0;
margin-bottom: -20px;
}
.parallax-text {
position: relative;
text-align: center;
color: #f0f0f0;
}
.parallax-text h2 {
font-size: 1.4rem;
margin: 0 0 .25rem;
}
.parallax-text p {
margin: 0;
font-size: .85rem;
color: #88888f;
}
.scroll-hint {
position: sticky;
bottom: .5rem;
margin: 0;
text-align: center;
font-size: .72rem;
color: #88888f;
pointer-events: none;
}
/* Same timeline, different distances. That difference is the depth. */
@keyframes parallax-bg {
from { transform: translateY(0); }
to { transform: translateY(-15%); }
}
@keyframes parallax-stars {
from { transform: translateY(0); }
to { transform: translateY(-30%); }
}
@keyframes parallax-mid {
from { transform: translateY(0); }
to { transform: translateY(-20%); }
}
@keyframes parallax-front {
from { transform: translateY(0); }
to { transform: translateY(-5%); }
}
Other ways to build it
Horizontal parallax on the x axis
A scroll timeline defaults to the block axis, which is vertical in a normal writing mode, so a sideways strip needs scroll-timeline-axis: x alongside the name. Everything else is unchanged: three absolutely positioned layers, one shared timeline, and a different translateX distance each. Note the declaration order in the CSS below. The animation shorthand resets animation-timeline to auto, so the timeline has to be declared after it rather than on the shared .px-h-layer rule. Get that backwards and the layers run on the document timeline at a duration of zero, which parks each one at its end offset and makes the whole strip look like a badly aligned static image. A prefers-reduced-motion block turns the offsets off, leaving an ordinary horizontal scroller.
← scroll sideways inside the strip →
HTML
<div class="px-h-scene">
<div class="px-h-track">
<div class="px-h-layer px-h-back"></div>
<div class="px-h-layer px-h-mid"></div>
<div class="px-h-layer px-h-front"></div>
</div>
</div>
CSS
.px-h-scene {
width: 100%;
height: 170px;
overflow-x: scroll;
overflow-y: hidden;
border-radius: 12px;
background: #141415;
scroll-timeline-name: --parallax-x;
/* without this the timeline listens on the block axis and never advances */
scroll-timeline-axis: x;
}
/* twice the scene width, which is where the scroll range comes from */
.px-h-track {
position: relative;
width: 200%;
height: 100%;
}
.px-h-layer {
position: absolute;
inset: 0;
will-change: transform;
}
/* animation-timeline has to come AFTER the shorthand. The `animation`
shorthand resets animation-timeline to auto, so declaring the timeline
first throws it away: the layer then runs on the document timeline with a
duration of 0s and snaps straight to its end offset. */
.px-h-back {
animation: px-back-x linear both;
animation-timeline: --parallax-x;
}
.px-h-mid {
animation: px-mid-x linear both;
animation-timeline: --parallax-x;
}
.px-h-front {
animation: px-front-x linear both;
animation-timeline: --parallax-x;
}
@keyframes px-back-x { to { transform: translateX(18%); } }
@keyframes px-mid-x { to { transform: translateX(8%); } }
@keyframes px-front-x { to { transform: translateX(3%); } }
@media (prefers-reduced-motion: reduce) {
.px-h-layer { animation: none; }
}
Sticky layers, with no scroll timeline at all
A pinned background and scrolling content in front of it gives most of the same depth cue for a fraction of the machinery. The artwork sits inside a position: sticky box of zero height, so it sticks at the top of the scroller and stays there while the cards move over it. The background travels at speed zero and the foreground at speed one, which is the limiting case of parallax rather than an imitation of it. This works wherever position: sticky does, so it is the version to reach for when the effect has to be present rather than optional.
HTML
<div class="px-s-scene">
<div class="px-s-pin"><div class="px-s-art"></div></div>
<div class="px-s-content">
<div class="px-s-card">First card</div>
<div class="px-s-card">Second card</div>
<div class="px-s-card">Third card</div>
</div>
</div>
CSS
.px-s-scene {
position: relative;
width: 100%;
height: 240px;
overflow-y: scroll;
overflow-x: hidden;
border-radius: 12px;
background: #141415;
}
/* height: 0 so the sticky box takes no space; the art overflows it */
.px-s-pin {
position: sticky;
top: 0;
height: 0;
z-index: 0;
}
.px-s-art {
height: 240px;
background:
radial-gradient(circle 60px at 78% 30%, #ffd444 98%, transparent 100%),
linear-gradient(180deg, #0a0e14 0%, #16233a 100%);
}
/* z-index puts the cards in front of the pinned artwork */
.px-s-content {
position: relative;
z-index: 1;
padding: 150px 1.25rem 1.25rem;
}
.px-s-card {
padding: 1rem;
margin-bottom: .75rem;
border: 1px solid #2a2a2d;
border-radius: 8px;
background: #1c1c1e;
font-size: .85rem;
line-height: 1.7;
color: #88888f;
}
How it works
CSS scroll-driven animations use a scroll timeline to link animation progress to scroll position instead of time. Each layer gets its own @keyframes with a different translateY range, and background elements move less (-15%), which is what creates the depth. This example names its timeline with scroll-timeline-name on the scroll container and references it by name, because the inner wrapper uses overflow: hidden and would otherwise capture scroll(nearest) as the nearest scroll container. will-change: transform promotes each layer for GPU compositing.
Two declarations connect a layer to a scroller. scroll-timeline-name: --parallax on the scrolling element publishes a timeline under that name, and animation-timeline: --parallax on any descendant subscribes to it. Progress runs from 0 at the top of the scroll range to 1 at the bottom, and every animation bound to the timeline maps its keyframes across that range.
scroll(nearest) looks like a shorter way to write the same thing, and here it fails outright. .parallax-inner sets overflow: hidden, which makes it a scroll container in its own right even though it has nothing to scroll. nearest resolves to that element, the timeline has a scroll range of zero, and no layer ever moves. A named timeline is resolved by walking up the ancestor chain until the name matches, so it passes straight through the clipped wrapper and binds to the scene. The symptom of getting this wrong is a scene that sits perfectly still with nothing in the console to explain it, and any layout that clips an intermediate wrapper can trigger it.
animation-duration has no effect once a scroll timeline is attached, so the shorthand is only parallax-bg linear both. Timing functions still apply, which is the reason every layer uses linear here. An eased curve on a scroll timeline makes a layer accelerate and decelerate while the reader drags the scrollbar at a steady rate, and that reads as a stutter rather than as easing. The both fill keeps each layer pinned at its from value before the timeline is active and at its to value after.
Depth is a ratio, not a formula. All four layers scroll with the panel; the extra translateY on top of that is the only thing separating them. Stars take the largest extra offset at -30% of the panel height, the mid layer -20%, the background gradient -15%, and the hills only -5%. Those percentages resolve against a 600px panel, so -30% is 180px of extra travel over a 300px scroll range. Nothing in the set changes layout. Each layer animates transform alone, which the compositor can handle without a reflow, and will-change: transform promotes the layer before the first frame rather than during it.
In a browser without scroll-driven animation support the animation-timeline declaration is dropped, and the animation has no timeline to run on. Because the fill mode is both, each layer holds its from state and the scene renders as an ordinary stacked composition that still scrolls. The depth cue is missing and nothing else changes, which makes the technique safe to ship with no feature query at all. If you want to serve a different layout to browsers that do support it, @supports (animation-timeline: scroll()) is the test.
CSS properties used
scroll-timeline-name- Publishes a named timeline on a scroll container. Descendants reference the name, so a clipped wrapper in between does not break the link the way
scroll(nearest)does. scroll-timeline-axis- Picks which axis drives the timeline. Defaults to the block axis, so a horizontally scrolling strip needs
xset explicitly. animation-timeline- Replaces the document time source for the animation. Takes a timeline name,
scroll(), orview(). Setting it makesanimation-durationirrelevant. animation-fill-modebothholds the first keyframe before the timeline is active and the last one after. It is also what keeps the scene sensible in browsers that ignore the timeline entirely.transformtranslateYon each layer. Transforms are composited, so moving a layer costs no layout or paint work.will-changetransformasks the browser to promote the layer up front. Useful on a handful of known-moving layers, wasteful if applied broadly.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
animation | 4 | 5 | 5.1 | 12 |
transform | 4 | 3.5 | 3.1 | 12 |
will-change | 36 | 36 | 9.1 | 79 |
scroll-behavior | 61 | 36 | 15.4 | 79 |
position:sticky | 91 | 59 | 7.1 | 91 |
Figures come from Can I Use. Scroll-driven animations have no entry in that dataset, so scroll-timeline-name and animation-timeline get no row above rather than a guessed number. Check the current status before you rely on the effect, and treat it as an enhancement either way: the fallback is a static composition, which is the same thing a reader gets if they turn motion down. The sticky build in the last variant needs none of it.
Accessibility notes
Parallax is one of the most reliable motion triggers there is. Layers that slide at different rates against each other break the expectation that a page moves as one piece, and for a reader with a vestibular disorder that mismatch can produce nausea or dizziness rather than mild irritation. Wrap the layer animations in a prefers-reduced-motion check and let the scene fall back to a flat, still composition. The horizontal variant below includes that block.
The scene is a scroll container, which browsers make keyboard focusable when its content overflows, so arrow keys reach the content inside it. That stops being true if you ever set tabindex="-1" on the scroller or move the overflow to a child. Any text a reader has to read should sit in the markup as real text, not in a background image, because a layer painted with background-image is invisible to a screen reader and to text search.
Nesting a scroll container inside the page introduces a scroll trap on a trackpad: once the pointer is over the scene, wheel gestures go to the scene until it reaches its end. That is acceptable for a small demo panel and irritating for a full width section. Give the reader an obvious edge, keep the panel short, and avoid stacking two scrollers inside one another.
What you can build with it
- Hero sections. A background photograph that drifts more slowly than the headline over it, which adds depth without a library.
- Long form articles. A pinned illustration that stays put while paragraphs scroll over it, the pattern the sticky variant below uses.
- Product pages. Feature panels that move at slightly different rates as the reader scrolls between them, so each one reads as a separate plane.
- Horizontal galleries. A sideways scrolling strip where a far background shifts less than the cards in front of it, using
scroll-timeline-axis: x. - Progress indicators. The same timeline drives a bar that fills as the reader moves through the article, which is a scroll-driven animation with a single layer.
Mistakes worth avoiding
- Using
scroll(nearest)when a wrapper between the layer and the scroller setsoverflow: hidden. That wrapper counts as a scroll container,nearestbinds to it, and because it has no scroll range the whole scene freezes. Naming the timeline solves it. - Leaving the animation eased. On a time based animation
ease-in-outreads as polish. On a scroll timeline it makes layers speed up and slow down while the reader scrolls at a constant rate, which looks like dropped frames. - Animating
top,margin-toporbackground-positioninstead oftransform. All three force layout or paint on every scroll frame, and the effect turns into a stutter on any device that is not a desktop. - Giving the scroller no height. Every layer is absolutely positioned, so the scene contributes nothing to its own height and collapses to zero unless you set one.
- Forgetting
animation-fill-mode. Withoutboththe layers snap back to their unanimated position outside the active range, and in a browser with no support they never take the animated state at all. - Declaring
animation-timelinebefore theanimationshorthand. The shorthand resets the timeline toauto, so the layer falls back to the document timeline with a duration of zero and jumps straight to its last keyframe. The scene looks misaligned rather than broken, which is what makes it hard to spot.
Frequently asked questions
Does CSS parallax work without JavaScript?
scroll-timeline-name on the scroller and animation-timeline on each layer replace the scroll listener completely. The sticky variant on this page needs neither, and works in every browser that supports position: sticky.Why is my scroll-driven animation not moving at all?
scroll(nearest) binding to the wrong element. Anything between the layer and the intended scroller that sets overflow: hidden, auto or scroll counts as a scroll container, and nearest picks it. Give the real scroller a scroll-timeline-name and reference that name instead.Can I use a scroll timeline on the whole page?
animation-timeline: scroll(root block) binds to the document scroller, which is the usual choice for a reading progress bar. The named form only matters when the scroller is a specific element.How do I make parallax respect reduced motion?
@media (prefers-reduced-motion: no-preference), or override animation: none inside a reduce block. With animation-fill-mode: both already in place the layers hold their starting position, so the scene stays composed instead of falling apart.Is parallax bad for performance?
transform only. Scroll-driven animations run off the main thread in browsers that support them, so the layers keep up even while script is busy. It gets expensive when you animate top or background-position, or when you promote dozens of layers with will-change at once.