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.
// 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)); @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%;} scroll driven animations Browser Support
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.
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.
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.