11 / blog / how-toNovember 20268 min read

Parallax scrolling with CSS and JavaScript

Parallax is layers moving at different speeds as the page scrolls: the far ones lag behind, the near ones run ahead, and a flat page gains depth. Here is a scene that does it in pure CSS on a scroll timeline, a short script for browsers without one, the same in one library call, and what each does for readers who ask for less motion.

  • How-To
  • Parallax
  • Scroll-driven animations
  • prefers-reduced-motion
Moves
transform only
CSS path
animation-timeline: view()
scrollParallax
in the 11.9 KB main entry
Far --speed: −0.2
down 20% of its height · 0.89× the scroll on screen
Middle --speed: −0.1
down 10% of its height · 0.94× the scroll on screen
Near --speed: 0.1
up 10% of its height · 1.06× the scroll on screen
Fig. 1 — what the CSS below does to each layer while it crosses the window: it ends --speed × 100% of its own height from where the page put it. Up runs ahead of the page and reads as near, down lags behind and reads as far. Added to the page’s own movement, that is the rate each one moves on screen against the scroll, in this 70vh scene.
01The idea

What parallax scrolling is

When you look out of a train window, the fence rushes past and the hills barely move. Parallax scrolling copies that: a scene is cut into layers, and as the page scrolls each layer moves at its own speed. A layer that moves slower than the page looks far away, one that moves faster looks close.

Each layer here is an element stacked inside the scene, with its speed in a custom property, --speed. A positive speed moves it up, ahead of the page; a negative one moves it down, behind; 0 leaves it where the page puts it.

index.html6 lines
<section class="scene">
  <div class="layer" style="--speed: -0.2"><!-- far: sky, sun --></div>
  <div class="layer" style="--speed: -0.1"><!-- middle: hills --></div>
  <div class="layer" style="--speed: 0.1"><!-- near: grass --></div>
  <h2>Three layers, three speeds</h2>
</section>
scene.css16 lines
.scene {
  position: relative;
  height: 70vh;
  overflow: clip; /* not hidden: see below */
}

.layer {
  position: absolute;
  /* 40% of the scene past its top and its bottom, so a layer
     can travel without showing an edge. */
  inset: -40% 0;
}

.scene h2 {
  position: relative; /* above the layers */
}

The layers are taller than the scene so that moving them never shows an edge. A layer travels --speed times its own height in all, and the 40% overhang on each side covers speeds up to ±0.2 at any window height. A faster layer needs more art past the edge it moves toward.

02Pure CSS

Parallax in CSS with a scroll timeline

A scroll-driven animation runs on the scroll instead of the clock. With animation-timeline: view(), each layer’s animation goes from 0% as the layer’s top meets the bottom of the window to 100% as its bottom leaves the top, so it takes the whole time the layer is on screen. The keyframe moves it --speed times its own height: up for a positive speed, down for a negative one.

parallax.css14 lines
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .layer {
      animation: parallax linear both;
      /* From the layer's top meeting the bottom of the window
         to its bottom leaving the top. */
      animation-timeline: view();
    }
  }
}

@keyframes parallax {
  to { transform: translateY(calc(var(--speed) * -100%)); }
}

The linear keeps each layer moving at a steady rate against the scroll; any other easing makes it speed up and slow down as it crosses, which reads as a wobble rather than depth. The easing visualizer plots the difference. The window a layer’s travel is spread over, from its top at the bottom of the screen to its bottom at the top, is the one you can scroll through in the scroll trigger visualizer, set to top bottom and bottom top.

What you see is the page’s movement plus the layer’s own. A layer H tall in a window V tall crosses it in V + H of scroll and moves speed × H more over it, so on screen it moves 1 + speed × H ÷ (V + H) times the scroll. Here every layer is 1.26 windows tall, which gives the rates in Fig. 1.

The whole effect sits inside @supports and @media (prefers-reduced-motion: no-preference). A browser without scroll timelines skips it, as does a reader who asked for less motion, and both see the scene as it is laid out: every layer at rest, nothing missing. More on view() and its ranges is in CSS scroll-driven animations explained.

03What to avoid

Why not background-attachment: fixed, or perspective

Two older ways of doing this in CSS are still copied around. background-attachment: fixed keeps a background still while the page moves over it. That is one layer at one speed, zero, and Safari on iOS does not support it: WebKit bug 234779, open since 2022, describes it as unsupported there, with the background scrolling along with the page.

The other pushes layers back in 3D. The page scrolls inside an element with a perspective, and a layer moved back with translateZ moves slower, as far things do:

perspective.css13 lines
/* The old trick: the page scrolls inside .viewport, not the
   window, and a layer pushed 1px back moves at half speed. */
.viewport {
  height: 100vh;
  overflow-x: hidden;
  overflow-y: auto;
  perspective: 1px;
}

.back {
  /* Pushed back, it looks smaller: scale(1 + 1px ÷ 1px) undoes that. */
  transform: translateZ(-1px) scale(2);
}

It works in all three engines, at half speed for the layer above, but only because the window no longer scrolls: .viewport does. window.scrollY stays 0, scroll listeners on the window never fire, and nothing else on the page that follows the window’s scroll moves. Every layer needs its scale worked out from its depth, and a layer that is not a direct child of .viewport needs transform-style: preserve-3d on each element in between. The scroll timeline above has none of that: the window scrolls as usual, and each layer states its speed.

04JavaScript

A small JavaScript version

For browsers without scroll timelines, a script can do the same with the same markup and the same --speed. It runs only where CSS.supports says the CSS version will not, so the two never both move a layer.

parallax.js61 lines
// For browsers without scroll timelines: the same layers and the
// same --speed, moved by one scroll listener, once per frame.
const reduce = matchMedia('(prefers-reduced-motion: reduce)');

function parallax(scene) {
  const layers = [...scene.querySelectorAll('.layer')].map((el) => ({
    el,
    speed: parseFloat(getComputedStyle(el).getPropertyValue('--speed')) || 0,
    top: 0,
    height: 0,
  }));
  let viewport = 0;
  let onScreen = false;
  let queued = false;

  // Layout is read here, at the start and on resize, never per frame.
  function measure() {
    viewport = innerHeight;
    const sceneTop = scene.getBoundingClientRect().top + scrollY;
    for (const l of layers) {
      l.top = sceneTop + l.el.offsetTop; // offsetTop ignores transforms
      l.height = l.el.offsetHeight;
    }
    frame();
  }

  function frame() {
    queued = false;
    for (const l of layers) {
      if (reduce.matches) {
        l.el.style.transform = '';
        continue;
      }
      // 0 as its top meets the bottom of the window, 1 as its bottom leaves the top.
      const p = (scrollY + viewport - l.top) / (viewport + l.height);
      const t = Math.min(1, Math.max(0, p));
      l.el.style.transform = `translateY(${-l.speed * l.height * t}px)`;
    }
  }

  function request() {
    if (!onScreen || queued) return;
    queued = true;
    requestAnimationFrame(frame);
  }

  // Watch the scene, not the layers: a layer's box moves and is clipped.
  new IntersectionObserver(([entry]) => {
    onScreen = entry.isIntersecting;
    request();
  }).observe(scene);

  addEventListener('scroll', request, { passive: true });
  addEventListener('resize', measure);
  reduce.addEventListener('change', frame);
  measure();
}

if (!CSS.supports('animation-timeline: view()')) {
  document.querySelectorAll('.scene').forEach(parallax);
}

The scroll listener is passive and does no work itself: it asks for one animation frame, and further scroll events before that frame are ignored. The frame reads only scrollY and writes only transform. Where each layer sits and how tall it is are measured once at the start and again on a resize, never in the frame, because reading layout right after writing styles makes the browser lay the page out again, on every frame. Moving a layer with transform never changes the layout, so the browser can move it without repainting it.

The IntersectionObserver stops the frames while the scene is off screen. It watches the scene, not the layers, on purpose: a layer’s box includes its own transform and is cut by the scene’s clip, so a layer that has moved far enough can be reported out of view while its scene is still on screen, and would stop where it was. svg-scroll-draw’s own parallax did exactly that until 2.23.4.

05svg-scroll-draw

scrollParallax, one call per layer

svg-scroll-draw’s scrollParallax moves an element the same way: from translateY(0%) to translateY(−speed × 100%) of its own height, linearly, from its top meeting the bottom of the window to its bottom leaving the top. It is in the main entry, svg-scroll-draw, and the same speeds give the same scene:

parallax.js8 lines
import { scrollParallax } from 'svg-scroll-draw';

// In place of the CSS animation and the script above: one call per
// layer, with the same --speed. It writes the transform itself.
for (const layer of document.querySelectorAll('.scene .layer')) {
  const speed = parseFloat(getComputedStyle(layer).getPropertyValue('--speed'));
  scrollParallax(layer, { speed });
}

It runs the frame loop in JavaScript in every browser, measures each layer when it starts and when the layout around it changes, never while you scroll, and runs frames only while the scroll is inside the layer’s window. The speed defaults to 0.3; axis: 'x' moves a layer sideways instead, still following the vertical scroll. For sections that really scroll sideways, the horizontal scroll generator builds those.

The parallax scrolling generator has a four-layer scene with a slider for each layer’s speed: scroll its preview to feel the depth, then copy the calls, a React component or a whole HTML page. For one set of options on many elements, there is scrollParallaxGroup, in animate multiple elements on scroll.

06prefers-reduced-motion

Parallax and reduced motion

Large movement that the reader did not ask for can make some people dizzy or sick, and parallax, where parts of the page slide against each other, is the kind of motion the reduced-motion setting exists for. All three versions here honour it, in slightly different ways:

  • The CSS only animates inside prefers-reduced-motion: no-preference, so under reduced motion every layer stays where the page lays it out, and it follows the setting live.
  • The script clears each layer’s transform when the setting is on, and listens for it changing, so the scene goes back to rest as soon as the reader turns it on.
  • scrollParallax sets each layer at its end position, translateY(−speed × 100%), and leaves it there; if the setting is turned on while the page is open, every layer goes there at once. Make sure the scene still reads that way: the generator’s End position button shows it.

Either way nothing moves against the scroll. Keeping the differences between speeds small, 0.1 to 0.4, helps everyone else. More on handling the setting for each kind of effect is in reduced motion for scroll animations.

— / next

Set the speeds, copy the code

Give each layer of a scene its own speed, scroll the preview to see the depth, and copy the JavaScript, a React component or an HTML page.

Star on GitHubFound this useful? A star helps others find it.

→Parts list

Keep reading

More from the blog