← Back to blog

Published on September 29, 2026

CSS Scroll Animations: Scroll-Driven Animations, Parallax and an Infinite Marquee

  • css
  • javascript
  • web-development

After animated backgrounds and mouse-driven effects, I close the series on library-free visual effects with scroll-driven ones: elements that appear as you scroll, layers moving at different speeds (parallax), bands of logos or text scrolling forever.

In Aceternity UI these correspond to components such as Parallax Scroll and Infinite Moving Cards, which according to their installation instructions require motion, clsx and tailwind-merge. Today CSS can do much of this work on its own, thanks to scroll-driven animations. There is, however, a browser support gap you need to know about before using them.

Two ways to tie an animation to scroll

It's worth separating two families of effects right away, because they're built differently:

Scroll-driven animations: scroll() and view()

Scroll-driven animations replace time with a scroll timeline. You write a normal @keyframes, assign it with animation, and then use animation-timeline to say what drives it. The two main functions, as described in the Chrome for Developers article Animate elements on scroll with Scroll-driven animations, are:

With view() you'll often also use animation-range, which says which stretch of the timeline the animation attaches to. For example, entry 0% cover 30% means: start when the element begins to enter, finish when it has covered 30% of its passage.

There's an easy mistake to make, which the Chrome article itself calls out: animation-timeline isn't part of the animation shorthand and must be declared after it, because the shorthand resets every longhand it doesn't include to its initial value. Write it before, and it's silently cancelled, with no error message.

On performance, the Chrome article talks about scroll-driven animations "running off the main thread". The rule from the previous articles still applies: animate transform and opacity.

Support today: you need a fallback

According to MDN's compatibility data (@mdn/browser-compat-data, version 8.1.3 of 24 September 2026), animation-timeline is supported by:

MDN classifies it as "Limited availability": it isn't Baseline, because it doesn't work in some of the most widely used browsers. In practice this means using it as a progressive enhancement: inside an @supports (animation-timeline: view()) block, with an alternative for browsers without support.

Scroll reveal, with a fallback

The demo below detects support in your browser. If animation-timeline: view() is supported it uses the native version, otherwise it falls back to IntersectionObserver. If your browser supports it, you can force the fallback with the button and compare the two: with the native version the element follows the scroll even backwards, with the fallback it appears once, with a fixed duration.

Demo

Reveal with view() and an IntersectionObserver fallback

 

Scroll inside this box ↓

Block 1
Block 2
Block 3
Block 4
Block 5
Block 6
Block 7
Block 8

Code

index.html

<div class="reveal">…</div>
<div class="reveal">…</div>

styles.css

@keyframes reveal {
  from { opacity: 0; transform: translateY(24px); }
  to   { opacity: 1; transform: none; }
}

@supports (animation-timeline: view()) {
  .reveal {
    animation: reveal linear both;
    animation-timeline: view();
    animation-range: entry 0% cover 30%;
  }
}

.reveal--fallback {
  opacity: 0;
  transform: translateY(24px);
  transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}

.reveal--fallback.is-visible {
  opacity: 1;
  transform: none;
}

@media (prefers-reduced-motion: reduce) {
  .reveal { animation: none; }
  .reveal--fallback { opacity: 1; transform: none; transition: none; }
}

script.js

if (!CSS.supports("animation-timeline: view()")) {
  const items = document.querySelectorAll(".reveal");
  const io = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) {
        entry.target.classList.add("is-visible");
        io.unobserve(entry.target);
      }
    }
  }, { threshold: 0.2 });

  items.forEach((el) => {
    el.classList.add("reveal--fallback");
    io.observe(el);
  });
}

One fallback detail that matters: the hidden state (opacity: 0) is not in the base CSS, but in a class added by JavaScript (reveal--fallback). If the script doesn't load, the elements simply stay visible. The opposite approach, hiding them in CSS and revealing them with JavaScript, risks leaving content invisible because of a network error or a blocked script.

Parallax with scroll()

Parallax is the ideal use case for scroll(): each layer moves vertically in proportion to scroll progress, multiplied by its own "depth". In the demo the depth is a CSS variable (--depth):

A single @keyframes serves every layer, because var(--depth) is resolved on each element.

Demo

Parallax with animation-timeline: scroll()

 

Scroll inside this box ↓

Slow
Normal
Fast

Code

index.html

<div class="layer" style="--depth: 1">…</div>
<div class="layer" style="--depth: 0">…</div>
<div class="layer" style="--depth: -1">…</div>

styles.css

@keyframes parallax {
  to { transform: translateY(calc(var(--depth) * 150px)); }
}

@supports (animation-timeline: scroll()) {
  .layer {
    animation: parallax linear both;
    animation-timeline: scroll();
  }
}

@media (prefers-reduced-motion: reduce) {
  .layer { animation: none; }
}

Here the chosen fallback is no effect: in browsers without support the layers just scroll normally. That's deliberate. Rebuilding parallax in JavaScript would mean listening to the scroll event and updating positions every frame on the main thread, which is exactly the work scroll-driven animations avoid. For a decorative effect, losing it in one browser costs less than making scrolling heavier for everyone.

An infinite marquee without JavaScript

A band scrolling forever (client logos, technologies, keywords) isn't tied to scroll, but it's the other big "moving" effect on landing pages, and it has a history worth knowing. The HTML <marquee> element still exists, but MDN marks it as deprecated, says its use is "strongly discouraged" and suggests CSS animations and transforms instead, with prefers-reduced-motion to stop them.

The simplest CSS technique is this:

  1. the content is written twice, in two identical groups side by side in a single track;
  2. the track moves from 0 to translateX(-50%): at the halfway point the second group sits exactly where the first one was;
  3. the animation restarts and the jump is invisible.

For the jump to be invisible, each group must also include the trailing space (here a padding-right equal to the gap). The second group has aria-hidden="true": it's a visual duplicate, and a screen reader shouldn't read the list twice.

Demo

CSS-only infinite marquee, with pause

  • HTML
  • CSS
  • JavaScript
  • SVG
  • Accessibility
  • Performance
  • Core Web Vitals
  • Responsive

Code

index.html

<div class="marquee">
  <div class="marquee__track">
    <ul class="marquee__group">
    <li>HTML</li>
    <li>CSS</li>
    <li>JavaScript</li>
    <li>SVG</li>
    <li>Accessibility</li>
    <li>Performance</li>
    <li>Core Web Vitals</li>
    <li>Responsive</li>
    </ul>
    <ul class="marquee__group" aria-hidden="true">
    <li>HTML</li>
    <li>CSS</li>
    <li>JavaScript</li>
    <li>SVG</li>
    <li>Accessibility</li>
    <li>Performance</li>
    <li>Core Web Vitals</li>
    <li>Responsive</li>
    </ul>
  </div>
</div>
<button class="marquee-toggle" type="button" aria-pressed="false">Pause</button>

styles.css

.marquee {
  overflow: hidden;
}

.marquee__track {
  display: flex;
  width: max-content;
  animation: marquee 24s linear infinite;
}

.marquee:hover .marquee__track,
.marquee:focus-within .marquee__track,
.marquee.is-paused .marquee__track {
  animation-play-state: paused;
}

.marquee__group {
  display: flex;
  flex-shrink: 0;
  gap: 3rem;
  padding-right: 3rem;
  margin: 0;
  list-style: none;
  white-space: nowrap;
}

@keyframes marquee {
  to { transform: translateX(-50%); }
}

@media (prefers-reduced-motion: reduce) {
  .marquee__track { animation: none; }
  .marquee { overflow-x: auto; }
}

script.js

const marquee = document.querySelector(".marquee");
document.querySelector(".marquee-toggle").addEventListener("click", (e) => {
  const paused = marquee.classList.toggle("is-paused");
  e.currentTarget.setAttribute("aria-pressed", String(paused));
});

This version's limit: each group must be at least as wide as the container, otherwise an empty gap appears while it scrolls. With few items on wide screens, you fix it by repeating the items within each group. Alternatively, JavaScript can work out how many copies are needed by measuring the real width of the container and the groups (for example with ResizeObserver, which reports when they change).

Pause: a requirement, not an extra

A marquee starts on its own and lasts well over five seconds, so it falls squarely under WCAG 2.2 success criterion 2.2.2 Pause, Stop, Hide (level A): there must be a mechanism to pause, stop or hide it. The demo has three, all based on animation-play-state: paused:

With prefers-reduced-motion: reduce the animation stops entirely and the band becomes manually scrollable (overflow-x: auto), so the content stays reachable.

Series recap

Across three articles, backgrounds, mouse effects and scroll, every effect rebuilt follows the same three rules: animate only transform and opacity, leave JavaScript the bare minimum (measure, don't draw), and always provide a still version for people who asked for less motion.