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.
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 CSSanimation-timelinehandles 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: Withoutanimation-range, a view timeline animation spans the entire visibility lifecycle. Most use cases need a specific range likeentryorcontain. - Animating expensive properties: Using scroll-driven animations on layout-triggering properties like
heightormargin. Stick totransformandopacityfor 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()whenview()is appropriate:scroll()tracks the scroll container's position globally;view()tracks an individual element's visibility. Element reveals needview(), notscroll().
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.