Home / Articles / Animation & Motion /

animationscroll

Scroll-driven animations from scratch: animation-timeline

Link animations to scroll position with pure CSS. No IntersectionObserver, no scroll event listeners — just keyframes tied to the scrollbar.

The scroll() function

animation-timeline: scroll() binds an animation's progress to the scroll position of a container. At 0% scroll the animation is at its start; at 100% scroll it's complete.

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

.reading-progress {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 3px;
  background: oklch(0.72 0.19 265);
  transform-origin: left;
  animation: progress-bar linear both;
  animation-timeline: scroll();
}

This creates a reading progress bar that fills as the user scrolls down the page. No JavaScript required — the browser maps scroll offset directly to animation progress.

Choosing the scroll axis and scroller

The scroll() function accepts two arguments: which scroller to track and which axis.

/* Default: nearest ancestor scroller, block axis */
animation-timeline: scroll();

/* Explicit: root scroller, block (vertical) axis */
animation-timeline: scroll(root block);

/* Horizontal scrolling container */
.carousel-indicator {
  animation: slide-marker linear both;
  animation-timeline: scroll(nearest inline);
}

The scroller options are nearest (default), root, and self. The axis options are block (default) and inline.

The view() function

animation-timeline: view() ties animation progress to an element's visibility within a scroll port. The animation starts when the element enters the viewport and completes when it exits.

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

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

The animation-range property controls which portion of the visibility timeline maps to the animation. entry 0% is when the element's leading edge crosses the viewport; entry 100% is when it's fully entered.

Animation range in depth

The range determines when the animation starts and ends relative to the element's intersection with the scroll port. There are named ranges you can combine with percentages.

/* Animate only during entry */
animation-range: entry 0% entry 100%;

/* Animate during the contain phase (fully visible) */
animation-range: contain 0% contain 100%;

/* Animate only during exit */
animation-range: exit 0% exit 100%;

/* Full journey: entry through exit */
animation-range: entry 0% exit 100%;

/* Start when 25% entered, finish at 75% of contain */
animation-range: entry 25% contain 75%;
Use the Chrome DevTools Animation Timeline inspector to visualize these ranges. It shows exactly which scroll positions map to which keyframe percentages.

Parallax with scroll()

Traditional parallax required JavaScript scroll listeners. With scroll(), you can create depth layers in pure CSS.

@keyframes parallax-slow {
  from { transform: translateY(0); }
  to   { transform: translateY(-100px); }
}

@keyframes parallax-fast {
  from { transform: translateY(0); }
  to   { transform: translateY(-250px); }
}

.bg-layer {
  animation: parallax-slow linear both;
  animation-timeline: scroll(root);
}

.fg-layer {
  animation: parallax-fast linear both;
  animation-timeline: scroll(root);
}

Different translation distances create the illusion of depth. The background layer moves slowly while foreground elements move faster.

Named scroll timelines

When you need to reference a specific scroll container from a descendant element, use a named timeline with scroll-timeline-name.

.scroll-container {
  overflow-y: auto;
  scroll-timeline-name: --section-scroll;
  scroll-timeline-axis: block;
}

.scroll-container .progress-indicator {
  animation: progress-bar linear both;
  animation-timeline: --section-scroll;
}

Named timelines let deeply nested elements bind to a specific ancestor scroller rather than relying on nearest resolution.

Progressive enhancement

Scroll-driven animations are not yet supported in all browsers. Use @supports to provide a graceful fallback.

.reveal-on-scroll {
  /* Fallback: element is always visible */
  opacity: 1;
  transform: translateY(0);
}

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

Without the @supports check, browsers that don't understand animation-timeline would still parse the keyframes animation and run it immediately — not the intended behavior.