DEV Community

RAXXO Studios
RAXXO Studios

Posted on • Originally published at raxxo.shop

CSS Scroll-Driven Animations: 6 Patterns I Ship in 2026

  • A scroll progress bar needs three CSS lines with scroll(root block), zero JavaScript.

  • view() plus animation-range: entry reveals cards as they enter the viewport.

  • Named scroll-timeline drives parallax layers at different speeds from one scroller.

  • Sticky scrubbing ties a tall container's scroll progress to an image or text.

  • Per-item view() timelines stagger list reveals without any offset math in JS.

  • Gate everything with @supports and kill motion under prefers-reduced-motion.

I deleted a 14 KB scroll library from my last project and replaced it with about 40 lines of CSS. The animations got smoother, the bundle shrank, and the main thread stopped choking on scroll listeners. Native scroll-driven animations are the reason. Here are the six patterns I actually ship in 2026, with copy-pasteable code for each.

The whole feature rides on one property: animation-timeline. Instead of timing an animation against a clock, you time it against scroll position. Two functions do most of the work. scroll() tracks how far a scroll container has scrolled. view() tracks how far an element has moved through the scrollport. Pair either with @keyframes and the browser runs the animation off the main thread. No requestAnimationFrame, no IntersectionObserver, no jank.

Pattern 1: A scroll progress bar with scroll(root block)

The reading-progress bar at the top of a long article is the cleanest demo of the feature. It maps document scroll position straight onto a transform. No JavaScript at all.

You need a fixed element and a @keyframes rule that scales it from 0 to 1 on the X axis. The scroll(root block) timeline reads the root scroller's progress along the block axis (vertical in most writing modes). scaleX is the right transform here because it animates on the compositor and stays buttery.


.progress-bar {
  position: fixed;
  inset: 0 0 auto 0;
  height: 4px;
  background: #e3fc02;
  transform-origin: left;
  transform: scaleX(0);
  animation: grow-progress linear;
  animation-timeline: scroll(root block);
}

@keyframes grow-progress {
  to { transform: scaleX(1); }
}

Enter fullscreen mode Exit fullscreen mode

Three things to notice. First, there is no animation-duration. With a scroll timeline the duration is the scroll distance, so auto is implied and the value you set is ignored in Chromium. Firefox is the exception: it wants a non-zero duration to apply the animation at all, so I add animation-duration: 1ms defensively. Second, linear matters. An eased progress bar lies about how far you have read. Third, transform-origin: left anchors the growth so the bar fills left to right.

I use scroll(root block) for page-level progress. If the scroller is a nested overflow: auto panel, swap root for nearest and the timeline binds to the closest ancestor scroll container instead. The block keyword can become inline, x, or y when you need a horizontal scroller. This pattern alone replaced a small JavaScript helper I had been copying between projects for years, part of my push toward pure-CSS animation patterns over runtime dependencies.

Pattern 2: Reveal-on-scroll cards with view()

Fade-and-rise reveals used to mean an IntersectionObserver, a .is-visible class, and a CSS transition. Now view() does it in pure CSS. The view() timeline tracks a single element as it crosses the scrollport, from the moment it enters at the bottom to the moment it leaves at the top.


.card {
  opacity: 0;
  translate: 0 32px;
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}

@keyframes reveal {
  to {
    opacity: 1;
    translate: 0 0;
  }
}

Enter fullscreen mode Exit fullscreen mode

animation-range: entry 0% entry 100% is the heart of it. The entry range covers the phase where the element is entering the scrollport, from first pixel visible (0%) to fully crossed the bottom edge (100%). The card finishes its reveal exactly when it has entered, not when it is centered, so the motion reads as natural rather than late.

I reach for both as the fill mode so the start frame holds before the range begins and the end frame holds after. Without it the card snaps back to opacity: 0 once it scrolls past, which looks broken. Note the translate property instead of a transform offset. I keep transforms free for other uses and animate translate and opacity only, since both are compositor-friendly.

Want the reveal to start a touch later? Try animation-range: entry 25% cover 50%. That delays the start until the element is a quarter into its entry and finishes it when the element covers half the scrollport. Tuning these two numbers is most of the design work. I store my preferred ranges and easings as variables so reveals stay consistent across a site, an approach I wrote up in my motion design tokens.

Pattern 3: Parallax layers with a named scroll-timeline

Parallax means layers moving at different speeds against the same scroll. A named scroll-timeline makes this trivial. You name the scroller once, then point as many animations at it as you like, each with its own @keyframes and travel distance.


.parallax {
  scroll-timeline-name: --hero-scroll;
  scroll-timeline-axis: block;
  overflow-y: auto;
  height: 100svh;
}

.layer-back {
  animation: drift linear;
  animation-timeline: --hero-scroll;
}
.layer-front {
  animation: drift-fast linear;
  animation-timeline: --hero-scroll;
}

@keyframes drift { to { translate: 0 -40px; } }
@keyframes drift-fast { to { translate: 0 -160px; } }

Enter fullscreen mode Exit fullscreen mode

The scroller gets scroll-timeline-name set to a dashed-ident (--hero-scroll). Any descendant can then set animation-timeline: --hero-scroll to ride that progress. Different translate distances in the keyframes produce different apparent speeds, which is the parallax illusion. The background drifts 40px, the foreground 160px, across the full scroll of the container.

By default a named scroll-timeline only reaches direct descendants of the scroller. If a layer lives outside that subtree, declare timeline-scope: --hero-scroll on a shared ancestor and the name becomes visible across the whole tree. I only add timeline-scope when the DOM forces it, because keeping the animated layers inside the scroller is simpler to reason about.

One caution: parallax is the pattern most likely to feel like too much. I keep distances small and always honor reduced-motion (Pattern 6). Subtle depth beats a carnival ride every time.

Pattern 4: Sticky section scrubbing

Scrubbing is the effect where a sticky element changes as you scroll past a tall container. Think of a phone image that rotates, or a headline that swaps, driven entirely by how far through the section you have scrolled. The trick is a tall outer container with a position: sticky child, animated against the container's own view() progress.


.scrub-section {
  height: 300svh;
  position: relative;
}

.scrub-sticky {
  position: sticky;
  top: 0;
  height: 100svh;
  display: grid;
  place-items: center;
}

.scrub-visual {
  animation: scrub linear both;
  animation-timeline: view();
  animation-range: contain 0% contain 100%;
}

@keyframes scrub {
  from { scale: 0.8; rotate: -6deg; }
  to   { scale: 1;   rotate: 0deg; }
}

Enter fullscreen mode Exit fullscreen mode

The outer .scrub-section is 300svh tall, so it occupies three screens of scrolling. Its child sticks to the top and stays pinned while the section scrolls past. I drive .scrub-visual with animation-range: contain 0% contain 100%. The contain range covers the entire window during which the section fully fills (or is fully contained by) the scrollport, which is exactly the pinned phase. So the visual scrubs from start to end across all three screens of stick.

from/to keyframes give a clean two-state scrub. Add intermediate percentages for multi-step sequences: 50% { rotate: 3deg; } introduces a midpoint wobble. Because the timeline is scroll-bound, scrubbing back up reverses the animation automatically. There is no state to manage and nothing to reset. This single pattern replaces the bulk of what I used to reach for a JS scroll library to do.

Pattern 5: Staggered list reveals, no JS

Staggered reveals (items appearing one after another as the list scrolls in) usually need JavaScript to compute per-item delays. With view() per item, the stagger emerges for free, because each item has its own position in the scrollport and therefore its own progress.


.stagger-item {
  opacity: 0;
  translate: 0 24px;
  animation: rise linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 30%;
}

@keyframes rise {
  to { opacity: 1; translate: 0 0; }
}

Enter fullscreen mode Exit fullscreen mode

Each .stagger-item runs the same animation against its own view() timeline. Since items sit at different scroll positions, item two enters slightly after item one, so its reveal fires slightly later. The visual stagger is a side effect of layout, not of any delay value. No nth-child math, no inline custom properties, no observer.

The range entry 0% cover 30% tunes the feel. It starts each reveal as the item begins entering and finishes it when the item covers 30% of the scrollport. A tighter range like entry 0% entry 60% makes items pop faster and overlap less. A wider one spreads the motion out. For a long list, a tighter range keeps the cascade from dragging.

If you want a deliberate cascade that does not depend on item spacing, you can add small animation-delay-style offsets through the range itself per group. I rarely bother. The natural stagger from view() looks more organic than evenly timed delays, and it adapts automatically when content reflows on mobile. One CSS rule, any number of items, correct on every viewport. This is the pattern that convinced me to delete my old reveal helper for good.

Pattern 6: Reduced motion and progressive enhancement, done right

Scroll-driven animations are an enhancement. They must never be load-bearing for content. Two guards keep them safe: a feature query so unsupported browsers get the static layout, and a reduced-motion query so people who ask for less motion get it.


/* Static, accessible baseline lives in the normal rules.
   Motion is added only when both checks pass. */
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .card {
      opacity: 0;
      translate: 0 32px;
      animation: reveal linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 100%;
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

The structure matters. I never set opacity: 0 in the base rule. If I did, a browser without scroll-timeline support (or a user with reduced motion) would see permanently invisible cards. Instead the baseline is the finished, visible state. The motion, including the starting opacity: 0, lives entirely inside the nested @supports and @media block. Strip away the enhancement and the page still reads perfectly.

@supports (animation-timeline: scroll()) gates on the actual feature, not a browser sniff. @media (prefers-reduced-motion: no-preference) flips the logic so motion is opt-in by the user's OS setting, which is the polite default. If you prefer the inverse pattern, wrap the motion normally and add a @media (prefers-reduced-motion: reduce) { animation: none; } override, but I find the no-preference gate harder to get wrong.

Browser reality in 2026

Here is the honest support picture as of May 2026. Chromium (Chrome and Edge) has shipped this since Chrome 115, so it has been stable for a long time. Safari shipped it in Safari 26, which closed the biggest gap. Firefox has it fully implemented but still behind a flag by default, so treat Firefox as unsupported in production and let the @supports baseline carry it. caniuse puts global support around 85% and climbing. One Firefox quirk to remember: it requires a non-zero animation-duration to apply scroll-driven animations, so adding animation-duration: 1ms costs nothing and helps. There is also a scroll-timeline polyfill if you need the effect on older engines, though I prefer graceful degradation over shipping a polyfill.

Bottom Line

Native scroll-driven animations changed how I build scroll effects. Six patterns cover almost everything I need: a progress bar, reveals, parallax, sticky scrubbing, staggered lists, and the guards that keep it all safe. The total cost is a few @keyframes rules and one animation-timeline line each. No scroll listeners, no observers, no library to update when it breaks.

The discipline that makes this work is putting the visible, accessible state in the base rules and adding motion only inside @supports plus a reduced-motion check. Get that wrong and a Firefox user stares at blank cards. Get it right and the page degrades to a clean static layout everywhere, then layers in smooth, compositor-driven motion where it is welcome.

If you want the durations and easings behind these snippets to compose cleanly across a whole site, that comes down to a small token system rather than one-off values. I document how I structure design and motion work, plus the rest of the studio toolkit, over at the RAXXO studio. Steal the patterns, tune the ranges, and delete a scroll library this week.

Top comments (0)