Scroll-Driven Animations in CSS with scroll-timeline

Scroll-linked animations without a library

Fade-in-on-scroll used to mean IntersectionObserver, GSAP, or AOS.js. CSS can now trigger animations based on scroll position, zero JavaScript, smooth 60fps.

Old way JS + CSS
// scroll-reveal.jsconst obs = new IntersectionObserver(  (entries) => {    entries.forEach(e => {      if (e.isIntersecting)        e.target.classList.add('visible');    });  });document.querySelectorAll('.reveal')  .forEach(el => obs.observe(el));
Modern
Pure CSS
@keyframes reveal   {  from   {    opacity: 0;    translate: 0 40px;  }  to     {    opacity: 1;    translate: 0 0;  }}.reveal   {  animation: reveal linear both;  animation-timeline: view();  animation-range: entry 0% entry 100%;}
Limited availability 78% global usage

This feature is not Baseline because it does not work in some of the most widely-used browsers.

Not ready for production without a fallback.

115+
18+
115+
animation-timeline: view()
↓ Scroll down ↓
First item fades in
Second item fades in
Third item fades in
You get the idea ✦
↑ Scroll back up ↑

GPU-accelerated

Runs on the compositor thread, 60fps guaranteed. No main-thread jank from JS observers or scroll handlers.

No JavaScript at all

Drop GSAP, AOS.js, or custom IntersectionObserver code. Entire scroll animation in 4 lines of CSS.

Reversible by default

Scroll back up and the animation reverses naturally. No need for "unobserve" or state management.

Lines Saved
11 → 4
JS + CSS → pure CSS
Old Approach
JS runtime
Main thread blocking
Modern Approach
Compositor
GPU-accelerated

How it works

animation-timeline: view() binds a standard @keyframes animation to the element's visibility in the scroll port. As the element scrolls into view, the animation progresses from 0% to 100%. For simpler one-shot entry effects that don't need scroll-linking, see entry animations without JavaScript.

animation-range: entry 0% entry 100% means the animation plays fully during the "entry" phase, from the moment the element's edge appears to when it's fully visible. You can also use exit, cover, or contain ranges.

Since this uses the browser's animation engine (compositor thread), it's inherently smoother than any JavaScript approach that mutates styles on the main thread.

Always wrap scroll-driven animations in @media (prefers-reduced-motion: no-preference). Scroll-linked motion is particularly likely to cause discomfort for vestibular disorder users because it ties movement directly to a physical gesture rather than a discrete trigger.

For scroll-linked progress effects (e.g. a reading progress bar), use animation-timeline: scroll() instead of view(). scroll() tracks the scroll container's total progress; view() tracks a specific element's position within the viewport. Both share the same animation-range syntax.

ESC