CSS scroll-driven animations allow an element’s animation to be driven by scroll position rather than time — with animation-timeline: scroll() or animation-timeline: view(), a CSS @keyframes animation that would normally run over 1 second can instead play from 0% to 100% as the user scrolls from the top to the bottom of a container or as an element enters and exits the viewport. This replaces the most common use case of Intersection Observer API + JavaScript for scroll-triggered animations — CSS handles it natively with no JavaScript, no layout jank (the animation runs on the compositor thread), and no event listener overhead. The scroll() function links animation progress to the scroll position of a scroll container: animation-timeline: scroll(root block) binds to the page’s main scroll position in the block direction (vertical); animation-timeline: scroll(nearest inline) binds to the nearest scrollable ancestor in the inline direction (horizontal). The view() function links animation progress to an element’s position within its scroll container — the animation plays as the element scrolls through the viewport: animation-range: entry 0% entry 100% means the animation runs from when the element starts entering the viewport to when it is fully in view. animation-range controls which part of the scroll timeline drives the animation: cover covers the full range while the element is anywhere in the viewport, entry covers the range while the element is entering, exit covers the range while it is exiting. As of late 2023, scroll-driven animations are supported in Chrome 115+ and Edge 115+ — Firefox has partial support, Safari does not yet support the spec. A JavaScript Intersection Observer fallback is recommended for production use. The Service Workers post covered background JavaScript features; scroll-driven animations replace the most common scroll-triggered JavaScript pattern entirely with CSS.
Problem: A WordPress landing page has 12 content sections that should fade in and slide up as the user scrolls to them — the current implementation uses an Intersection Observer JavaScript file (4KB gzipped) plus a CSS animation class toggle. The animations cause brief layout thrashing during scroll on low-powered Android devices.
Solution: Replace the JavaScript Intersection Observer with CSS animation-timeline: view() scroll-driven animations, keeping the JavaScript as a @supports-based fallback for browsers without support.
/* ── Scroll-driven fade-in animation ──────────────────────────────────────── */
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(2rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Apply scroll-driven animation to sections — requires Chrome 115+ */
@supports (animation-timeline: view()) {
.wp-block-group.animate-on-scroll,
.wp-block-columns.animate-on-scroll,
.wp-block-cover.animate-on-scroll {
animation: fade-in-up linear both;
animation-timeline: view();
/* Play the animation while the element enters the viewport (0% = starts entering, 30% = fully in) */
animation-range: entry 0% entry 30%;
}
}
/* ── Reading progress bar driven by scroll position ───────────────────────── */
@keyframes grow-width {
from { width: 0%; }
to { width: 100%; }
}
.reading-progress-bar {
position: fixed;
top: 0;
inset-inline-start: 0;
height: 3px;
background: var(--wp--preset--color--primary, #1a56db);
transform-origin: inline-start;
/* Bind width animation to document scroll progress */
animation: grow-width linear both;
animation-timeline: scroll(root block);
}
/* ── Parallax effect using scroll() ───────────────────────────────────────── */
@keyframes parallax-move {
from { transform: translateY(0); }
to { transform: translateY(-15%); }
}
@supports (animation-timeline: scroll()) {
.hero__background-image {
animation: parallax-move linear both;
animation-timeline: scroll(root block);
/* Only animate while this section is visible */
animation-range: 0% 50%;
}
}
/* ── Staggered card entrance animations ───────────────────────────────────── */
@supports (animation-timeline: view()) {
.card-grid__item {
animation: fade-in-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
/* Stagger using animation-delay */
.card-grid__item:nth-child(2) { animation-delay: calc(1 / 6 * 0.3s); }
.card-grid__item:nth-child(3) { animation-delay: calc(2 / 6 * 0.3s); }
.card-grid__item:nth-child(4) { animation-delay: calc(3 / 6 * 0.3s); }
}
// Intersection Observer fallback for Safari and Firefox
// Only runs if @supports (animation-timeline: view()) does NOT match
if (!CSS.supports('animation-timeline', 'view()')) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target); // animate once
}
});
},
{ threshold: 0.15 }
);
document.querySelectorAll('.animate-on-scroll').forEach(el => observer.observe(el));
}
NOTE: CSS scroll-driven animations respect the user’s prefers-reduced-motion media query — wrap all animation declarations in @media (prefers-reduced-motion: no-preference) { ... } to disable them for users who have enabled the reduce-motion accessibility setting. This is not just good practice — it is a WCAG 2.1 success criterion (2.3.3 Animation from Interactions). Also note that animation-fill-mode: both (set via the both keyword in the animation shorthand) is essential for scroll-driven animations — without it, elements snap back to their initial state when they are not in the active scroll range, causing flicker as users scroll back up past animated sections.