CSS Scroll-Driven Animations: No JavaScript Required

Scroll-linked animation in CSS only, from progress bars to parallax, plus the jank that scaleX and a careless animation-range cause.

cssanimationtutorialintermediate

Scroll-linked effects have usually meant an IntersectionObserver or a scroll listener with a requestAnimationFrame guard. CSS scroll-driven animations move that into the stylesheet.

Two Types of Scroll Timelines

  1. scroll() links progress to scroll position in a container.
  2. view() links progress to an element's visibility in a scroller.

You pick one per animation with animation-timeline.

Progress Bar With scroll()

A progress bar that fills as you scroll:

@keyframes grow {
    from { width: 0%; }
    to { width: 100%; }
}
 
.progress-bar {
    position: fixed;
    top: 0;
    left: 0;
    height: 4px;
    background: oklch(60% 0.2 260);
 
    animation: grow linear;
    animation-timeline: scroll(root block);
    animation-fill-mode: both;
}

scroll(root block) is the document scroller on the vertical axis. Without both, the bar snaps to zero outside the range.

width triggers layout on every frame, so if the bar stutters, pin the element at full width and animate transform: scaleX() instead.

Reveal on Scroll With view()

Fade elements in as they enter the viewport:

@keyframes fade-in {
    from {
        opacity: 0;
        translate: 0 40px;
    }
    to {
        opacity: 1;
        translate: 0 0;
    }
}
 
.card {
    animation: fade-in linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 40%;
}

entry 0% entry 40% starts as the top edge enters and finishes once 40% of the element is visible. On a tall card that is a long scroll; I ended up nearer 20%.

animation-range Reference

It picks which portion of the timeline drives the animation:

KeywordMeaning
entryElement entering the scroller
exitElement leaving the scroller
containWhile element is fully contained
coverFrom first pixel entering to last pixel leaving
/* Play animation only while element is entering */
animation-range: entry 0% entry 100%;
 
/* Play while element covers the center of viewport */
animation-range: cover 25% cover 75%;

cover is usually what people mean by "while it's on screen". contain counts only the stretch where it is fully inside the viewport.

Named Timelines for Complex Layouts

Use a named timeline when the scroller is not an ancestor:

.scroller {
    overflow-y: scroll;
    scroll-timeline-name: --my-scroll;
    scroll-timeline-axis: block;
}
 
.indicator {
    animation-timeline: --my-scroll;
}

The name has to be a custom identifier starting with --, or the declaration is dropped.

Parallax Effect

@keyframes parallax-shift {
    from { transform: translateY(0); }
    to { transform: translateY(-100px); }
}
 
.hero-image {
    animation: parallax-shift linear both;
    animation-timeline: scroll(root);
    animation-range: 0% 50%;
}

Parallax from a scroll listener is where I have seen the worst jank.

Browser Support

Chrome 115+, Edge 115+, Safari 18+ (fall 2024) and Firefox 132. Wrap it in @supports (animation-timeline: scroll()): an ignored animation-timeline leaves a normal timed animation, so cards fade in on load instead of on scroll.


Progress bars and fade-ins are where this has stuck for me. Neither needs a library.