Type something to search...
How to Create Smooth Scroll-Driven Animations with CSS

How to Create Smooth Scroll-Driven Animations with CSS

For years, anything that moved in response to scrolling meant JavaScript. You attached a scroll listener, read scrollY, did some maths, and wrote styles back to the DOM, usually wrapped in requestAnimationFrame and a throttle to keep it from janking. Libraries grew up around this pattern, and so did a lot of sluggish pages.

Scroll-driven animations replace that whole approach with a few lines of CSS. Instead of an animation running over time, you tie its progress to a scroll position. Scroll halfway down the page, and the animation is halfway through. Scroll back up, and it plays in reverse. Because the browser handles it, animations of properties like transform and opacity can run off the main thread and stay smooth even when your JavaScript is busy.

In this guide, I'll cover the two kinds of timelines, the properties that control them, and several practical effects you can drop into a real project, along with a solid fallback strategy.

The Core Idea: Swapping Time for Scroll

A normal CSS animation has a timeline that is the document's clock. animation-duration: 2s means the keyframes are mapped across two seconds of time.

A scroll-driven animation keeps the same @keyframes, but swaps the timeline. Its progress from 0% to 100% is mapped to a range of scrolling instead. The new property that makes this happen is animation-timeline.

There are two kinds of timeline:

  • Scroll progress timeline (scroll()): progress tracks how far a scroll container has been scrolled, from the top (0%) to the bottom (100%).
  • View progress timeline (view()): progress tracks an element's visibility as it moves through a scroll container's viewport, from the moment it starts entering to the moment it fully leaves.

Everything else builds on these two.

Your First Scroll Progress Timeline: A Reading Progress Bar

A reading progress bar across the top of an article is the "hello world" of scroll-driven animations.

<div class="progress" aria-hidden="true"></div>
<article class="post">
  <h1>Designing for slow networks</h1>
  <p>...</p>
</article>
.progress {
  position: fixed;
  inset: 0 0 auto 0;
  height: 4px;
  background: linear-gradient(90deg, #38bdf8, #818cf8);
  transform-origin: 0 50%;
  transform: scaleX(0);
  z-index: 10;
}

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

@supports (animation-timeline: scroll()) {
  .progress {
    animation: grow-progress linear both;
    animation-timeline: scroll(root block);
  }
}

That's the whole effect. A few details matter:

  1. animation-timeline goes after the animation shorthand. The shorthand resets animation-timeline to auto, so if you declare the timeline first, the shorthand wipes it out. This is the most common reason a scroll-driven animation silently doesn't work.
  2. linear easing makes the bar grow in direct proportion to scroll. Other easing functions still work, but for progress indicators linear is what users expect.
  3. No duration is needed. Duration is meaningless when progress comes from scroll. In the shorthand, leaving it out is fine.
  4. scroll(root block) means "the root scroller, in the block direction". scroll() with no arguments defaults to the nearest ancestor scroll container and the block axis, which works here too, but being explicit helps readability.

I animate transform: scaleX() instead of width on purpose. Transforms can be composited, which keeps the effect smooth. Animating width forces layout on every frame.

The scroll() Arguments

scroll() accepts up to two keywords:

  • Scroller: nearest (default), root, or self.
  • Axis: block (default), inline, y, or x.

For a horizontal carousel with its own overflow, you might use scroll(self inline) on the carousel itself, or scroll(nearest inline) on a child indicator.

View Progress Timelines: Reveal on Scroll

The second timeline type, view(), is what makes "fade in as it enters the viewport" effects trivial. The timeline belongs to the element being animated and tracks its own journey through the scrollport.

<section class="features">
  <article class="feature reveal">...</article>
  <article class="feature reveal">...</article>
  <article class="feature reveal">...</article>
</section>
@keyframes reveal {
  from {
    opacity: 0;
    transform: translateY(2rem) scale(0.98);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

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

The new piece here is animation-range, which tells the browser which slice of the timeline the keyframes should cover.

Understanding Animation Ranges

A view timeline is divided into named ranges:

  • cover: the full range, from the element's leading edge first touching the scrollport to its trailing edge leaving it.
  • contain: the range where the element is fully inside the scrollport (or fully covers it, if it's taller than the viewport).
  • entry: from when the element starts entering until it's fully entered.
  • exit: from when the element starts leaving until it's fully gone.
  • entry-crossing and exit-crossing: the element crossing the starting or ending edge, measured by the element's own size.

You combine a range name with a percentage. entry 10% cover 35% means "start when the element is 10% of the way through entering, finish when it's 35% of the way through the full cover range". In practice, that makes cards fade in shortly after they peek above the fold, and be fully visible well before the centre of the screen.

You can also use separate longhands:

.reveal {
  animation-range-start: entry 10%;
  animation-range-end: cover 35%;
}

If you omit animation-range, the default is normal, which for a view timeline means the full cover range. That's often too slow for reveals, which is why tuning the range is where most of the design work happens.

Keyframe-Level Ranges

You can also put range names directly inside keyframes, which lets a single animation handle entering and exiting:

@keyframes fade-in-out {
  entry 0% {
    opacity: 0;
    transform: translateY(2rem);
  }
  entry 100% {
    opacity: 1;
    transform: none;
  }
  exit 0% {
    opacity: 1;
    transform: none;
  }
  exit 100% {
    opacity: 0;
    transform: translateY(-2rem);
  }
}

@supports (animation-timeline: view()) {
  .fade-in-out {
    animation: fade-in-out linear both;
    animation-timeline: view();
  }
}

This is useful for sections that should fade in as they arrive and fade out as they leave, like a storytelling page.

Named Timelines: Animating One Element Based on Another

So far the animated element has either used the root scroller or its own visibility. Sometimes you need element A to animate based on element B's scroll. That's what named timelines are for.

For a horizontal image gallery with a separate progress indicator:

<div class="gallery">
  <div class="gallery__track">
    <img src="/img/one.jpg" alt="Harbour at dawn" />
    <img src="/img/two.jpg" alt="Fishing boats" />
    <img src="/img/three.jpg" alt="Lighthouse" />
  </div>
  <div class="gallery__indicator"></div>
</div>
.gallery {
  timeline-scope: --gallery;
}

.gallery__track {
  display: flex;
  gap: 1rem;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-timeline: --gallery inline;
}

.gallery__track img {
  flex: 0 0 80%;
  scroll-snap-align: center;
}

.gallery__indicator {
  height: 3px;
  margin-top: 0.75rem;
  background: #818cf8;
  transform-origin: left;
  transform: scaleX(0);
}

@supports (animation-timeline: scroll()) {
  .gallery__indicator {
    animation: grow-progress linear both;
    animation-timeline: --gallery;
  }
}

Named timelines use a dashed ident like --gallery. The scroller declares it with scroll-timeline (or view-timeline for a view timeline), and the animated element references it by name.

The catch is visibility: by default, a named timeline is only visible to the scroller's descendants. The indicator here is a sibling, not a descendant, so timeline-scope: --gallery on the shared parent hoists the name up to where both elements can see it.

A Subtle Parallax Header

Parallax gets overdone, but a gentle version on a hero image can add depth:

.hero {
  position: relative;
  overflow: clip;
  height: 70vh;
}

.hero__image {
  position: absolute;
  inset: -10% 0;
  width: 100%;
  height: 120%;
  object-fit: cover;
}

@keyframes parallax {
  to {
    transform: translateY(15%);
  }
}

@supports (animation-timeline: view()) {
  .hero__image {
    animation: parallax linear both;
    animation-timeline: view();
    animation-range: exit;
  }
}

Because the image is oversized by 20% of its container, it can drift downward without exposing an empty edge. Using overflow: clip rather than hidden avoids creating a scroll container, which would change which scroller the timeline attaches to.

Why view() with animation-range: exit? view() on .hero__image tracks the image's own position in the scrollport, and exit covers the time the hero is leaving the top of the screen. That's the right window for this effect, since you only want movement while the hero is scrolling away.

Making It Smooth: Performance Tips

Scroll-driven animations are fast by design, but you can still undermine them.

  • Stick to compositor-friendly properties. transform, opacity, filter, and clip-path are the safe choices. Animating top, height, margin, or box-shadow triggers layout or paint on every scroll frame and loses the main benefit.
  • Avoid animating hundreds of elements at once. Each view timeline has some cost. For long lists, reveal the section containers rather than every row.
  • Use linear for continuous effects. Scroll already provides natural acceleration from the user's gesture. Stacking an ease-in-out curve on top of scroll physics can feel rubbery.
  • Don't combine with scroll-behavior: smooth expecting magic. Smooth scrolling changes how fast the scroll position moves after an anchor jump, and the animation follows it, which is fine. But it won't smooth out a jerky trackpad or wheel.

Respecting Reduced Motion

Motion tied to scroll can cause genuine discomfort for people with vestibular disorders. Parallax in particular is a known trigger. Always gate larger effects behind a reduced-motion check:

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .reveal {
      animation: reveal linear both;
      animation-timeline: view();
      animation-range: entry 10% cover 35%;
    }
  }
}

Wrapping the whole thing in prefers-reduced-motion: no-preference means users who ask for less motion simply see the content in its final state. For small, functional effects like a progress bar, it's reasonable to keep them, since they convey information rather than decoration.

Common Pitfalls

Declaring the Timeline Before the Shorthand

This one bears repeating. animation: reveal linear both; resets animation-timeline. Always write animation-timeline after the shorthand, or use longhands throughout.

Hiding Content Before the Animation Loads

If your base styles set opacity: 0 and only the scroll animation brings content back, users in unsupported browsers see nothing. Keep the visible state as the default and apply the hidden starting state only through the keyframes, as the examples above do with animation-fill-mode: both inside @supports.

Attaching to the Wrong Scroller

scroll() and view() look for the nearest scroll container. An ancestor with overflow: hidden or overflow: auto counts as one, even if it never actually scrolls. If the animation is stuck at 0% or 100%, inspect the ancestors for an unexpected overflow value. Switching hidden to clip usually fixes it.

Using position: fixed Inside a Transformed Parent

A fixed-position progress bar inside an element with a transform becomes positioned relative to that element. Keep the progress bar near the root of the document.

Browser Support and Fallbacks

Scroll-driven animations shipped first in Chromium-based browsers, and Safari has since added support in its recent releases. Firefox has had an implementation behind a flag for a long time, but it has not been enabled by default for everyone at the time of writing. Check caniuse before relying on it, and treat these effects as progressive enhancements.

The @supports (animation-timeline: scroll()) pattern used throughout this post is the right fallback. Unsupported browsers ignore the whole block, and content stays in its natural, visible state.

If a progress indicator is genuinely important in every browser, you can add a small JavaScript fallback that only runs when CSS can't do it:

if (!CSS.supports("animation-timeline: scroll()")) {
  const bar = document.querySelector(".progress");
  const update = () => {
    const max = document.documentElement.scrollHeight - innerHeight;
    const progress = max > 0 ? scrollY / max : 0;
    bar.style.transform = `scaleX(${progress})`;
  };
  addEventListener("scroll", update, { passive: true });
  update();
}

This keeps the modern path pure CSS while giving older engines a reasonable equivalent.

Conclusion

Scroll-driven animations turn a whole genre of JavaScript into declarative CSS. A scroll() timeline ties progress to how far a container has scrolled, a view() timeline ties it to an element's journey through the viewport, and animation-range lets you choose exactly which part of that journey the keyframes cover. Named timelines and timeline-scope handle the cases where one element needs to follow another.

Keep the animated properties compositor-friendly, declare the timeline after the shorthand, respect prefers-reduced-motion, and wrap everything in @supports so unsupported browsers get a clean static page. Start with a reading progress bar, then try a reveal effect on a feature grid, and you'll quickly see how much scroll-listener code you no longer need.

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading