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.

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.

  2. 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().

  3. 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.

  4. 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;
      }
    }
  5. 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.

  6. 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

Related

Up: Scroll-Driven & View Transition Implementation Patterns