Implementing CSS Scroll-Driven Animations on a Website
CSS Scroll-Driven Animations is a native browser API that allows binding CSS @keyframes to scroll position without JavaScript. Available in Chrome 115, Firefox 110 (partial), Safari — no support yet (2025). For production use, a polyfill is needed. Main advantage over JS solutions: animations run on the compositor thread, don't block the main thread.
Basic Concept
Two types of timeline:
-
scroll()— binding to scroll position of scroll container -
view()— binding to element visibility in viewport (like IntersectionObserver)
/* Page reading progress bar */
@keyframes grow-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.reading-progress {
position: fixed;
top: 0; left: 0;
width: 100%; height: 3px;
background: #3b82f6;
transform-origin: left;
animation: grow-bar linear;
animation-timeline: scroll(root); /* bind to root scroll */
animation-fill-mode: both;
}
view(): Animations on Element Appearance
/* Card fade-in on scroll */
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: fade-up ease-out both;
animation-timeline: view();
/* Range: from entry to 40% visibility */
animation-range: entry 0% entry 40%;
}
/* Parallax for images */
@keyframes parallax-img {
from { object-position: 50% 30%; }
to { object-position: 50% 70%; }
}
.hero-image {
animation: parallax-img linear both;
animation-timeline: view();
animation-range: contain;
}
Named Timeline via scroll-timeline
When you need to control animation from another element:
/* Parent container creates named timeline */
.scroll-container {
overflow-y: scroll;
scroll-timeline: --my-scroll block;
height: 100vh;
}
/* Child element uses this timeline */
.animated-sidebar {
animation: slide-in linear both;
animation-timeline: --my-scroll;
animation-range: 0% 30%;
}
@keyframes slide-in {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
Complex Example: Sticky Header Transformation
/* Header changes on scroll */
@keyframes header-shrink {
from {
padding: 24px 40px;
background: transparent;
backdrop-filter: none;
}
to {
padding: 12px 40px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(12px);
}
}
.site-header {
position: sticky;
top: 0;
animation: header-shrink linear both;
animation-timeline: scroll(root);
animation-range: 0px 200px; /* first 200px of scroll */
}
Polyfill for Safari
npm install @scroll-timeline-polyfill/scroll-timeline
// app/layout.tsx (Next.js) or index.html script
async function loadScrollTimelinePolyfill() {
const isSupported = CSS.supports('animation-timeline: scroll()')
if (!isSupported) {
await import('@scroll-timeline-polyfill/scroll-timeline')
}
}
loadScrollTimelinePolyfill()
Alternative — conditional JS fallback via IntersectionObserver for critical animations.
Progressive Enhancement: @supports
/* Base styles for all browsers */
.animated-section {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.5s, transform 0.5s;
}
.animated-section.visible {
opacity: 1;
transform: translateY(0);
}
/* Scroll-Driven for supporting browsers */
@supports (animation-timeline: scroll()) {
.animated-section {
opacity: 1;
transform: none;
transition: none;
animation: fade-up ease-out both;
animation-timeline: view();
animation-range: entry 0% entry 50%;
}
}
JavaScript fallback via IntersectionObserver:
// utils/scroll-animation-fallback.ts
export function initScrollAnimationFallback() {
// Skip if native support exists
if (CSS.supports('animation-timeline: scroll()')) return
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible')
observer.unobserve(entry.target)
}
})
},
{ rootMargin: '-10% 0px -10% 0px', threshold: 0.1 }
)
document.querySelectorAll('.animated-section').forEach(el => {
observer.observe(el)
})
}
animation-range Details
/* Named keywords for view() */
/* entry: element enters scroll-port */
animation-range: entry 0% entry 100%;
/* exit: element exits */
animation-range: exit 0% exit 100%;
/* contain: while element is fully visible */
animation-range: contain;
/* cover: from start of entry to end of exit */
animation-range: cover 0% cover 100%;
/* Combined: entry + exit */
.element {
animation: appear linear both;
animation-timeline: view();
animation-range: entry 10% exit 90%;
}
Typical Timelines
Reading progress bar + 3–4 fade animations on scroll — 4–6 hours. Full system with polyfill, @supports, JS fallback, tests in Safari and mobile browsers — 2–3 working days.







