Scroll-Driven Parallax Effects in CSS

Parallax — background layers moving more slowly than the foreground as the page scrolls — used to mean a scroll event listener that read scrollY and wrote a transform on every frame. It was one of the most reliable ways to make a page janky: the listener ran on the main thread, competed with everything else, and fell behind the scroll the moment the page got busy. Scroll-driven animations replace the listener with a timeline the browser manages itself. A layer's translate is tied to its progress through the viewport, and the compositor updates it in step with scrolling. This page builds that effect, keeps it subtle, and covers the fallback and reduced-motion handling. It belongs to Scroll-Driven Animations in the CSS-Only Micro-Interactions & Animations guide.

How the effect is produced

Parallax is a difference in speed. When the user scrolls 100 pixels, the foreground moves 100 pixels, and a background layer that moves only 80 pixels appears farther away. With CSS you do not set speeds directly. Instead you give the background a translate animation that runs as the section crosses the viewport: it starts slightly pushed up and ends slightly pushed down. Across the scroll range, that downward drift subtracts from the upward scroll, so the layer drifts more slowly than its container.

The counter-movement that creates depth Three snapshots of a section scrolling up through the viewport. At entry the background layer is shifted up by 10 percent; halfway it is centred; at exit it is shifted down by 10 percent. The section travels the full distance while the background travels less, which reads as depth. The layer runs against the scroll translate: 0 -10% translate: 0 0 translate: 0 10% section entering section centred section leaving Frames are the section; the tinted block is its background layer.

The timeline that drives this is view(): an anonymous view progress timeline measuring how far an element has travelled through its nearest scroll container's visible area. At 0% the element's leading edge has just entered; at 100% its trailing edge has just left.

The complete implementation

Scroll inside the demo frame to see two layers move at different rates behind the section headings.

Live demoParallax layers on a view() timeline
Scroll inside the frame. Each scene's background drifts against the scroll, so it moves more slowly than the heading. Browsers without scroll-driven animations show the static layout.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scroll-driven parallax</title>
<style>
  body { margin: 0; font: 16px/1.5 system-ui, sans-serif; }

  .scene {
    position: relative;
    min-block-size: 70vh;
    display: grid;
    place-items: center;
    overflow: clip;           /* hide the layer's oversized edges */
    isolation: isolate;
  }

  .scene__layer {
    position: absolute;
    inset: -15% 0;            /* 15% taller at each end to cover the travel */
    z-index: -1;
    background: linear-gradient(160deg, #1e3a8a, #0ea5e9);
  }

  .scene h2 {
    color: #fff;
    font-size: clamp(1.5rem, 4vw, 3rem);
    margin: 0;
  }

  @supports (animation-timeline: view()) {
    @media (prefers-reduced-motion: no-preference) {
      .scene__layer {
        animation: parallax linear both;
        animation-timeline: view();
      }

      @keyframes parallax {
        from { translate: 0 -10%; }
        to   { translate: 0 10%; }
      }
    }
  }
</style>
</head>
<body>
  <section class="scene">
    <div class="scene__layer" aria-hidden="true"></div>
    <h2>Mountains</h2>
  </section>
  <p style="padding: 2rem; max-inline-size: 60ch;">
    Body content between scenes scrolls normally.
  </p>
</body>
</html>

A note on the direction. The keyframes move the layer from -10% to 10%down over the course of the section's journey up the viewport. That downward drift subtracts from the upward scroll, so the layer appears to move more slowly. Reverse the values and the layer moves faster than the scroll, which reads as a foreground element sliding past rather than a distant backdrop.

The key technique: keep the layer covered

The failure every parallax implementation must avoid is exposing the edge of the moving layer. If the layer is exactly the size of its section and moves 10% down, a strip of empty section shows at the top. The implementation handles this two ways at once: inset: -15% 0 makes the layer taller than the section by more than its maximum travel, and overflow: clip on the section hides the excess.

Oversize the layer by more than its travel A section outline with a dashed layer outline extending 15 percent above and below it. Arrows show the layer can move 10 percent of its own height up or down, which is about 13 percent of the section and less than the 15 percent overhang, so the section is always fully covered. The parts outside the section are clipped. Overhang must exceed travel section (overflow: clip) overhang 15% overhang 15% travel ±10% dashed: layer

The percentages in translate resolve against the layer's own height, and the percentages in inset resolve against the section's height, so the numbers are not in the same units. With a 15% overhang at each end, the layer is 130% of the section's height, and 10% of that is 13% of the section — safely inside the overhang. A travel of 12% would already be 15.6% of the section and expose a sliver of edge at the extremes. The safe rule is to keep the translate percentage at no more than about three quarters of the inset percentage, or to use an animation range that trims the extremes, as the next section shows.

Tuning with animation-range

By default a view() timeline runs from the moment the section's leading edge enters to the moment its trailing edge leaves — the cover range. For a tall hero at the top of the page, the section is already visible at load, so the first part of that range never plays. animation-range narrows the timeline to the part that matters:

.hero__layer {
  animation: parallax linear both;
  animation-timeline: view();
  /* Only animate while the hero is leaving the viewport. */
  animation-range: exit 0% exit 100%;
}

@keyframes parallax {
  from { translate: 0 0; }
  to   { translate: 0 10%; }  /* with inset: 0 0 -15% on the layer */
}

Now the layer starts at rest and drifts only as the hero scrolls away. Ranges are covered in depth, with every named range illustrated, in View Timelines and animation-range.

Variation: multiple layers for stronger depth

Several layers moving at different rates — distant hills slowest, near trees fastest — create a stronger depth illusion. Each layer gets its own keyframes or, more compactly, a custom property for its travel:

@property --travel {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 10%;
}

.scene__layer {
  animation: drift linear both;
  animation-timeline: view();
}
.scene__layer--far  { --travel: 5%; }
.scene__layer--mid  { --travel: 10%; }
.scene__layer--near { --travel: -8%; }  /* moves faster than scroll */

@keyframes drift {
  from { translate: 0 calc(var(--travel) * -1); }
  to   { translate: 0 var(--travel); }
}

Keep the number of animated layers small — two or three. Each is a composited layer with its own memory cost, and large layers covering the whole viewport are the most expensive kind. will-change and the Compositor Thread explains that cost.

Choosing the scroller and axis

view() with no arguments finds the nearest ancestor that scrolls and tracks the element along the block axis. That is right for a page that scrolls vertically, but two situations need arguments. In a horizontal carousel, view(inline) tracks progress along the inline axis, so layers inside each slide drift sideways as the carousel scrolls. And when the element sits inside a nested scroller — a scrolling panel within a scrolling page — the timeline attaches to the inner scroller, which may be what you want or may leave the effect frozen while the page moves. If the effect should follow the page instead, give the page-level element a named timeline with view-timeline-name and reference it from the layer, widening its reach with timeline-scope when the layer is not a descendant.

The second argument to view() is an inset that shrinks the area the timeline considers "in view". view(block 20% 0%) treats the top 20% of the viewport as outside, so the animation finishes before the section reaches a sticky header. Insets are useful when fixed chrome covers part of the scroller and a layer's motion should not be wasted underneath it.

When not to use parallax

Parallax earns its place on a small number of pages: a product launch, a story-driven landing page, a portfolio. On documentation, forms, dashboards and anything read repeatedly, it adds motion that competes with the content and costs performance on every visit. Even where it fits, one parallax scene per page is usually enough; a stack of them turns scrolling into a ride. If a design calls for depth without motion, a fixed shadow or layered illustration delivers most of the impression with none of the risk.

Accessibility: parallax is a known trigger

Parallax is one of the motion patterns most often reported as causing dizziness and nausea for people with vestibular disorders, because the content moves in a way that disagrees with the scroll gesture. The implementation above only enables the animation inside @media (prefers-reduced-motion: no-preference), so anyone who has asked their operating system to reduce motion sees a static layout. That is the correct default: parallax is decorative, and removing it loses nothing. Beyond the media query, keep offsets small and avoid parallax on text itself. Vestibular-Safe Animation Patterns sets out which motions are riskiest.

Browser support

animation-timeline and animation-range are supported in Chrome and Edge 115+ and Safari 26+. In Firefox, scroll-driven animations are available only as a preview behind a flag, not in stable releases. The @supports (animation-timeline: view()) wrapper gives every other browser the static layout. @property is supported in Chrome and Edge 85+, Firefox 128+ and Safari 16.4+; prefers-reduced-motion in Chrome 74+, Edge 79+, Firefox 63+ and Safari 10.1+.

FAQ

How does CSS parallax work without JavaScript? A view() timeline tracks an element's progress through its scroll container. Animating a layer's translate against that timeline moves it at a different rate from the page scroll, which is the parallax effect. The browser runs it off the main thread.

How far should a parallax layer move? Less than you think. A background that shifts by 10 to 20 percent of its own height reads as depth. Larger offsets expose the layer's edges, need oversized images, and are more likely to trouble people with vestibular sensitivity.

Is parallax safe for people who get motion sickness? Large parallax is one of the most common triggers for vestibular discomfort because the content moves differently from the scroll gesture. Keep offsets small and remove the effect entirely under prefers-reduced-motion.

What happens in browsers without scroll-driven animations? Wrap the animation in @supports (animation-timeline: view()). Browsers without support render the layers in their resting positions, which is a complete static layout with no broken motion.

Related articles

More pages in the same section.