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


CSS Parallax Compositor Pipeline A flow diagram showing scroll offset entering the compositor thread, which drives two separate scroll timelines — one for the background layer (slow) and one for the foreground layer (fast) — both outputting compositor-only transform: translateY values. The main thread is shown as idle during this process. Scroll Offset (viewport position) Compositor animation-timeline: scroll(root) interpolates keyframes .layer-bg translateY slow .layer-fg translateY fast Main thread — idle during scroll (zero Layout / Paint events)

Syntax Reference

The shipped CSS Scroll-Driven Animations Level 1 API exposes two scroll-timeline binding functions. For parallax, scroll() is the right choice.

Travel distance is the depth cue Three layers over one viewport of scroll. The background travels least, the midground more, and the content layer not at all. The difference between any two layers is the depth the eye reads. Halving every travel distance halves the perceived depth without changing the composition. travel over one viewport of scroll Content layer 0px — reference plane Midground -24px just behind the text Background -48px distant total disparity — the number to audit Express each distance as a multiple of one custom property and the whole composition scales together.
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

  1. Layer trapped in an overflow container. animation-timeline: scroll() (no argument) resolves to the nearest scrollable ancestor. If a parallax layer sits inside overflow: auto or overflow: hidden, it binds to that container’s scroll — which may be zero. Fix: always use the explicit scroll(root block) form for viewport-relative parallax.
The edge-gap failure A background layer that travels 48 pixels must start 48 pixels taller than its container, or the far edge pulls into view at one end of the range. The usual symptom is a sliver of page background appearing at the top on load and at the bottom at full scroll. container layer, exact height gap at the top once the layer moves layer, oversized by the travel distance no gap at either end height: calc(100% + var(--travel)); inset-block-start: calc(var(--travel) / -2); Overflow must be clipped on the container, or the oversized layer bleeds into the section above. Clipping also caps the painted area, which keeps the layer cheap.
  1. Animating layout properties. Using top, margin, or width in @keyframes triggers 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 with transform: translate().

  2. animation shorthand resets animation-timeline. Writing animation: parallax-bg linear both after a separately declared animation-timeline: scroll(root) resets animation-timeline back to auto. Always set animation-timeline last, or combine both in the same declaration block.

  3. prefers-reduced-motion partial reset. animation: none does not reset animation-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;
      }
    }
  4. contain: strict breaks view(). contain: strict (which implies contain: size) prevents view() from measuring the element’s position within the scroll container. Use contain: layout paint instead of contain: strict on parallax containers.

  5. scroll-snap compresses the animation range. A scroll-snap-type parent snaps the scroll position to discrete stops, which collapses scroll() progress to discrete jumps rather than a smooth gradient. Either remove scroll-snap from the parallax container, or switch to view() on each snap child, which tracks entry/exit independently of the snap position.

Performance Checklist

  • Animate only transform or opacity in parallax @keyframes — never layout or paint properties
  • Apply will-change: transform to each parallax layer before scroll begins; remove it on scrollend if you have many layers
  • Use contain: layout paint on parallax containers to scope layout invalidation
  • Use scroll(root block) explicitly — never rely on the default nearest-ancestor resolution for viewport parallax
  • Keep translateY offsets below 50vh (or 0.5 * 100vh with --speed) to avoid vestibular triggers
  • Verify zero Layout and Paint events 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 animation and animation-timeline inside @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

Up: Scroll-Driven & View Transition Implementation Patterns