Skip to main content
  • Created:
  • Updated:
  • Author:
    Takeshi Takatsudo

Scroll-Driven Animations

The Problem

Scroll-linked effects like progress bars, parallax backgrounds, and element reveal animations have traditionally required JavaScript scroll event listeners or Intersection Observer callbacks. These approaches run on the main thread, can cause jank under load, and add complexity. AI agents almost never suggest the CSS-native scroll-driven animations API, even though it provides performant, compositor-thread animations tied to scroll position with zero JavaScript.

The Solution

CSS Scroll-Driven Animations allow you to connect a @keyframes animation to scroll progress instead of time. Two timeline types are available:

  • scroll() — Tracks the scroll position of a container (0% = top, 100% = fully scrolled). Use for global progress bars and parallax.
  • view() — Tracks an element's visibility as it enters and exits the viewport. Use for reveal animations and element-level effects.

Both timelines use the standard @keyframes syntax and run on the compositor thread when animating transform and opacity, ensuring smooth 60fps performance.

Scroll-Driven Fade-In — Scroll down inside the preview

Code Examples

Reading Progress Bar

A progress bar that fills as the user scrolls the page:

.progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: var(--color-primary, #2563eb);
transform-origin: left;
animation: scale-progress linear;
animation-timeline: scroll();
}

@keyframes scale-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
<div class="progress-bar" aria-hidden="true"></div>

Fade-In on Scroll (View Timeline)

Elements fade in as they enter the viewport:

.reveal {
animation: fade-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}

@keyframes fade-in {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

animation-range: entry 0% entry 100% means the animation plays from when the element starts entering the viewport to when it is fully inside.

Parallax Background

.hero {
position: relative;
height: 80vh;
overflow: hidden;
}

.hero__background {
position: absolute;
inset: -20% 0;
background-image: url("hero.jpg");
background-size: cover;
background-position: center;
animation: parallax linear;
animation-timeline: scroll();
}

@keyframes parallax {
from {
transform: translateY(-10%);
}
to {
transform: translateY(10%);
}
}

The background moves slower than the scroll, creating the parallax depth effect — entirely without JavaScript.

Sticky Header Shrink on Scroll

.header {
position: sticky;
top: 0;
animation: header-shrink linear both;
animation-timeline: scroll();
animation-range: 0 200px;
}

@keyframes header-shrink {
from {
padding-block: 1.5rem;
font-size: 1.5rem;
}
to {
padding-block: 0.5rem;
font-size: 1rem;
}
}

animation-range: 0 200px limits the animation to the first 200px of scroll, so the header shrinks quickly and then stays at its reduced size.

Reveal Animation with Stagger Using View Timeline

.card {
animation: card-reveal linear both;
animation-timeline: view();
animation-range: entry 10% entry 90%;
}

@keyframes card-reveal {
from {
opacity: 0;
transform: translateY(32px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}

Each card triggers its own reveal animation as it enters the viewport, independent of other cards.

Horizontal Scroll Progress for a Specific Container

.scrollable-section {
overflow-y: auto;
max-height: 60vh;
scroll-timeline-name: --section-scroll;
scroll-timeline-axis: block;
}

.section-progress {
height: 3px;
background: var(--color-primary, #2563eb);
transform-origin: left;
animation: scale-progress linear;
animation-timeline: --section-scroll;
}

@keyframes scale-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}

Named scroll timelines (scroll-timeline-name) let you link an animation to a specific scroll container, not just the document root.

View Timeline Ranges Explained

The animation-range property accepts named ranges for view timelines:

/* Full visibility lifecycle */
.element {
animation-timeline: view();
}

/* entry: element enters the viewport */
.entry-anim {
animation-range: entry 0% entry 100%;
}

/* exit: element leaves the viewport */
.exit-anim {
animation-range: exit 0% exit 100%;
}

/* contain: element is fully contained in viewport */
.contain-anim {
animation-range: contain 0% contain 100%;
}

/* cover: from first pixel entering to last pixel leaving */
.cover-anim {
animation-range: cover 0% cover 100%;
}

Common AI Mistakes

  • Using JavaScript for scroll-linked animations: Reaching for addEventListener('scroll') or Intersection Observer when CSS animation-timeline handles the use case natively.
  • Not knowing the API exists: This is the most common mistake. AI agents generate JavaScript for progress bars and reveal animations that CSS can handle alone.
  • Forgetting animation-range: Without animation-range, a view timeline animation spans the entire visibility lifecycle. Most use cases need a specific range like entry or contain.
  • Animating expensive properties: Using scroll-driven animations on layout-triggering properties like height or margin. Stick to transform and opacity for compositor-thread performance.
  • Not providing a fallback: Scroll-driven animations are not supported in all browsers (Firefox has limited support). Always ensure the content is visible and usable without the animation.
  • Using scroll() when view() is appropriate: scroll() tracks the scroll container's position globally; view() tracks an individual element's visibility. Element reveals need view(), not scroll().

When to Use

  • Reading progress bars: A scroll() timeline tracking document scroll.
  • Element reveal animations: A view() timeline triggering fade-in, slide-up, or scale animations as elements enter the viewport.
  • Parallax effects: A scroll() timeline moving background layers at different speeds.
  • Header transformations: Shrinking/restyling a sticky header based on scroll depth.
  • Not for complex interaction logic: Scroll-driven animations are declarative and respond to scroll position. For logic like "animate only once" or "trigger after a delay," JavaScript or Intersection Observer may still be needed.
  • Always as progressive enhancement: Ensure content works without the animation. Use @supports (animation-timeline: scroll()) for feature detection if needed.

References