9 CSS Parallax Scrolling — No JS, Scroll-Driven
CSS parallax scrolling is scroll-linked motion — a background, a layer, a whole hero — driven entirely by animation-timeline: scroll(root), with no
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Slow-moving background
- 02 3-layer depth parallax
- 03 Text drifts over image
- 04 Hero shrink parallax
- 05 Horizontal timeline parallax
- 06 Layered depth cards
- 07 Sticky + parallax combo
- 08 Mouse-move parallax
- 09 Scroll zoom-out
Watch instead of scroll on this page: none of the demos below have a real scrollbar, so each one fakes the effect with a two-second loop running back and forth inside a fixed, non-scrollable iframe. The order below moves from a single drifting layer toward genuine staging. Items 01 through 04 change one property at a time, and 05 through 07 combine that same scroll(root) timeline with sticky positioning, a horizontal axis, and stacked layers. Items 08 and 09 are the idea-form entries, swapping the scroll trigger for a cursor and opening on a close-up that zooms out.
01Slow-moving background
A single background layer drifts upward at roughly 0.4x the page's own scroll speed, so it lags behind the foreground title and opens a gap the eye reads as depth. Reach for it on a landing hero background or a banner sitting right under the nav — anywhere a full parallax rig would be overkill for one moving layer.
.bgslow__bg {
position: absolute;
inset: -20% -10%;
opacity: .5;
@supports (animation-timeline: scroll()) {
animation: drift linear both;
animation-timeline: scroll(root);
}
}
@keyframes drift {
from { transform: translateY(0); }
to { transform: translateY(-24px); }
}
023-layer depth parallax
Three circles sit back, mid, and front, and animation-timeline: scroll(root) drives each one through its own keyframes at 10px, 26px, and 46px of total travel. The front layer moves nearly five times as far as the back one, which is what actually reads as perspective rather than three flat circles scrolling in sync. Use the full three-layer version on a game showcase or a brand-story section where one flat layer would look thin.
.layer3__back { animation: drift-back linear both; animation-timeline: scroll(root); }
.layer3__mid { animation: drift-mid linear both; animation-timeline: scroll(root); }
.layer3__front { animation: drift-front linear both; animation-timeline: scroll(root); }
@keyframes drift-back { to { transform: translateY(-10px); } }
@keyframes drift-mid { to { transform: translateY(-26px); } }
@keyframes drift-front { to { transform: translateY(-46px); } }
03Text drifts over image
The photo itself barely moves — only 14px of total travel — while the caption above it rises 70px over the same scroll distance, so the two layers visibly separate instead of tracking together. mix-blend-mode: difference keeps the caption legible no matter what color sits underneath it as it slides. It suits an editorial-style hero or a photo-essay title where the headline is meant to feel like it cuts through the image rather than just sitting on it.
.textimg__image {
animation: image-drift linear both;
animation-timeline: scroll(root);
}
.textimg__text {
mix-blend-mode: difference;
animation: text-drift linear both;
animation-timeline: scroll(root);
}
@keyframes image-drift { to { transform: translateY(-14px); } }
@keyframes text-drift { from { transform: translateY(60px); } to { transform: translateY(-70px); } }
04Hero shrink parallax
The hero title scales down to about 55% of its starting size and fades to a quarter of its opacity over the same scroll range. Both effects come from one keyframe animation instead of two separate ones. It reads as the headline stepping back to make room, which fits a landing page's first screen or the opening beat of an app-intro sequence.
.heroshrink__title {
animation: shrink linear both;
animation-timeline: scroll(root);
}
@keyframes shrink {
from { transform: scale(1); opacity: 1; }
to { transform: scale(.55); opacity: .25; }
}
05Horizontal timeline parallax
The card row never receives a horizontal scroll gesture of its own — animation-timeline: scroll(root) reads the page's normal vertical scroll and feeds it straight into a translateX keyframe instead. That one substitution turns an ordinary vertical scroll into a row of cards traveling sideways, which fits a company-history page or a product roadmap better than a flat vertical list. It needs no extra scroll container and no scroll-event code to keep the two axes in sync.
.hline__track {
display: flex;
animation: travel linear both;
animation-timeline: scroll(root);
}
@keyframes travel {
from { transform: translateX(0); }
to { transform: translateX(-120px); }
}
06Layered depth cards
Three cards start stacked with a slight offset and scale difference, and the front card peels away fastest while the two behind it trail at 94% and 88% of its distance, growing slightly as they're revealed. The scale change is what sells the stack — without it the three cards would just look like they slide at different speeds instead of sitting at different depths. Feature-highlight cards and pricing comparisons both read well with this, since revealing one card at a time keeps attention on a single option.
.depthcards__card--1 {
animation: peel-1 linear both;
animation-timeline: scroll(root);
}
.depthcards__card--2 { transform: translateY(9px) scale(.94); }
.depthcards__card--3 { transform: translateY(18px) scale(.88); }
@keyframes peel-1 { to { transform: translateY(-34px) scale(1); } }
07Sticky + parallax combo
position: sticky pins the caption at 40% from the top of its container while the striped background behind it keeps drifting on its own animation-timeline: scroll(root) track. The two layers visibly separate as a result, instead of scrolling together. Storytelling sections and product-spec walkthroughs both lean on this, since a caption that holds still gives a reader time to finish the sentence before the next background pattern arrives.
.stickypar__caption {
position: sticky;
top: 40%;
}
.stickypar__bg {
animation: bg-drift linear both;
animation-timeline: scroll(root);
}
@keyframes bg-drift {
from { transform: translateY(0); }
to { transform: translateY(-40px); }
}
08Mouse-move parallax
Three circles trail the cursor at three different multipliers — 0.3, 0.6, and 1 — so the layer with the highest multiplier swings furthest and reads as the closest one to the viewer. Getting the cursor into CSS still takes a few lines of script: a pointermove listener normalizes the cursor to a -0.5 to 0.5 range and writes it into two custom properties the layers read. It fits a product showcase or a portfolio hero where a viewer is expected to rest their cursor over the piece rather than scroll straight past it.
// --mx and --my come from a pointermove listener, -0.5 to 0.5
.mousepar__layer {
transform: translate(
calc(var(--mx, 0) * var(--depth) * 22px),
calc(var(--my, 0) * var(--depth) * 22px)
);
transition: transform 120ms ease-out;
}
.mousepar__layer--a { left: 18%; top: 24%; }
.mousepar__layer--b { left: 62%; top: 54%; }
.mousepar__layer--c { left: 40%; top: 68%; }
09Scroll zoom-out
The media block starts scaled up to 145% of its real size, and animation-timeline: scroll(root) drives it back down to 100% as the page scrolls. As a result, the frame around it only becomes visible once the zoom finishes. It suits a product-detail opener or a gallery intro where the first thing a visitor sees should feel like a close-up before the fuller page reveals itself.
.zoomout__media {
transform: scale(1.45);
animation: zoom-out linear both;
animation-timeline: scroll(root);
}
@keyframes zoom-out {
from { transform: scale(1.45); }
to { transform: scale(1); }
}
Where it breaks — the traps
animation-timeline: scroll(root) is the one property every pattern above depends on, and it isn't universal yet. caniuse tracks it as supported from Chrome 115 and Safari 26, with Firefox still shipping it behind a flag as of September 2026. Every demo above wraps its scroll(root) animation in an @supports (animation-timeline: scroll()) block, so a browser that doesn't understand the property falls back to a still layer instead of a half-broken one. Skip that guard and a Firefox reader on the stable channel sees item 04's hero permanently mid-shrink, because the base transform still applies but the keyframe animation driving it never starts. Grab the nine as working files rather than retyping every guard by hand: the archive opens once you type x6462unj exactly as it reads here, with no dashes or spaces added. Every pattern inside carries the same fallback, not just the one shown above.
Accessibility
Every pattern above only stops moving under prefers-reduced-motion because its own reduced-motion block cancels it, and each one throws away a different amount of motion rather than freezing identically. Items 01, 02, 05, and 07 drop their animation entirely and hold a still transform, so each background stays exactly where it starts instead of drifting. Item 09's media snaps straight to its fully revealed, un-zoomed state instead of looping back and forth. Item 04's title keeps its full opacity and size rather than settling mid-shrink, and item 08 cancels both its animation and its pointer-driven transition so nothing trails the cursor at all. MDN documents prefers-reduced-motion and the operating-system settings that turn it on. Items 03 and 06 simply rest in their final layout: the caption sits still over the image, and the three cards stay stacked without peeling apart.
Every other scroll-driven pattern this site has published sits behind the CSS hub, and the about page covers what these files include and what they leave out.
FAQ
Does CSS parallax scrolling work without JavaScript?
Eight of the nine patterns above run purely on animation-timeline: scroll(root), so scrolling itself drives every keyframe with zero JavaScript attached to the page. The one exception is the mouse-move layer in item 08, which needs a short pointermove listener because a cursor position has no scroll-linked timeline of its own to read from. Everything else here — the shrinking hero, the sticky caption, the horizontal timeline — ships as pure CSS.
Why does my parallax layer jump instead of moving smoothly with the scroll?
That's almost always animation-timeline: scroll(root) failing silently rather than throwing an error. Check that the @supports (animation-timeline: scroll()) guard actually wraps the animation, and confirm the browser is one of the versions that supports the property. A layer set to position: fixed instead of absolute or sticky inside its scrolling container is the second most common cause, since a fixed element ignores the very scroll it's meant to track.
Which of these nine patterns is fastest to add to an existing page?
Item 01's slow-moving background is the smallest change — one element, one keyframe, and one animation-timeline line, with no layout restructuring needed around it. Item 08's mouse-move parallax takes the longest to wire up, since it needs a pointermove listener, a couple of custom properties, and per-layer depth values tuned by hand.