Drop Intersection Observer and scroll listeners for four practical CSS scroll animations: progress bars, reveals, sticky headers, and parallax.

IntersectionObserver, GSAP, and raw scroll listeners are still the default answer for effects that CSS can now drive on its own. Scroll-driven animations landed in Chrome in 2023 and Safari 26 in 2025, which means many common css scroll animations patterns no longer need JavaScript at all. Firefox still needs a flag as of early 2026, so the right move is progressive enhancement and testing with the Viewport Debugger.
A typical frontend stack still treats scroll animation as a JavaScript problem.
You want a reading progress bar, so you attach a scroll event and calculate a percentage. You want cards to fade in, so you wire up IntersectionObserver. You want a sticky header to shrink after the hero, so you toggle a class once scrollY crosses a threshold. You want parallax, so you multiply scrollY by a speed factor and push a transform.
All of that works. It also means extra state, event wiring, edge cases around resize, and code whose only real job is “map scroll position to animation progress.”
CSS now has a native way to do exactly that: animation-timeline.
There are only two timelines you need to care about:
scroll() ties animation progress to a scroll container’s scroll position. Use it for page progress bars, sticky header changes, and parallax.view() ties animation progress to when an element becomes visible in the viewport. Use it for reveals and fade-ins.Both are values for animation-timeline. That’s the conceptual part. The rest is just replacing JavaScript patterns you probably already have.
scroll(root)This is the cleanest swap because the JavaScript version is almost always the same: listen for scroll, compute progress, and set a width or transform.
Use a fixed element at the top of the page and animate scaleX() from 0 to 1. Always animate transform, not width, because transforms stay on the compositor and avoid layout work.
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: oklch(0.65 0.22 280);
transform-origin: left center;
animation: grow-progress linear;
animation-timeline: scroll(root);
}
/* JS this replaces:
window.addEventListener('scroll', () => {
const pct = window.scrollY / (document.body.scrollHeight - window.innerHeight);
bar.style.transform = `scaleX(${pct})`;
}); */Minimal HTML:
<div class="progress-bar" aria-hidden="true"></div>
<main class="article">
<h1>Long article title</h1>
<p>...</p>
<p>...</p>
<p>...</p>
</main>Why this works well:
scroll(root) binds the animation timeline to the main document scroll.linear means the keyframes map directly to scroll progress.transform-origin: left center makes the bar grow from left to right.One practical note: if your page has very little scrollable content, the effect will complete quickly because the scroll range is short. That’s not a bug; it’s exactly tied to available scroll distance.
view()This is the big one. Most teams still reach for IntersectionObserver to add a class when a card, section, or image enters the viewport. For many reveal effects, view() does the same job with less code and better control over progress.
Here’s the exact pattern.
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: no-preference) {
.reveal {
animation: fade-in-up linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
}
/* JS this replaces:
const observer = new IntersectionObserver((entries) => {
entries.forEach(el => {
if (el.isIntersecting) el.target.classList.add('visible');
});
});
document.querySelectorAll('.reveal').forEach(el => observer.observe(el)); */And a runnable HTML example:
<section class="feature-grid">
<article class="card reveal">
<h2>Fast setup</h2>
<p>Drop the class on any section, card, or media block you want to reveal.</p>
</article>
<article class="card reveal">
<h2>Less glue code</h2>
<p>No observer registration, no cleanup, and no visible class toggles.</p>
</article>
<article class="card reveal">
<h2>Scroll-linked progress</h2>
<p>The animation completes as the element enters, not after it has fully passed through.</p>
</article>
</section>A complete styling block makes the behavior easier to drop into a real page:
.feature-grid {
width: min(72rem, calc(100% - 2rem));
margin-inline: auto;
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
padding-block: 8rem;
}
.card {
background: white;
color: #0f172a;
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 10px 30px rgb(15 23 42 / 0.08);
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: no-preference) {
.reveal {
animation: fade-in-up linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
}The important line is this one:
animation-range: entry 0% cover 30%;That range makes the reveal finish early, as the element comes into view, instead of dragging on until it exits. For product grids, marketing sections, and editorial blocks, that usually feels more natural than animating across the entire visible lifecycle.
A few practical rules:
24px is enough for most UI.both so the final state sticks once the animation completes.prefers-reduced-motion, because viewport-linked motion is exactly the kind of thing some users opt out of.A shrinking or solidifying sticky header is another pattern that usually ships with a scrollY > 80 check and a .scrolled class. CSS can own that transition directly.
Use scroll(root) again, define a short range, and let the animation settle into its end state.
@keyframes header-shrink {
from {
padding-block: 1.5rem;
background-color: transparent;
box-shadow: none;
}
to {
padding-block: 0.75rem;
background-color: #0f172a;
box-shadow: 0 2px 12px rgb(0 0 0 / 0.3);
}
}
.site-header {
position: sticky;
top: 0;
animation: header-shrink linear both;
animation-timeline: scroll(root);
animation-range: 0px 120px;
}
/* JS this replaces:
window.addEventListener('scroll', () => {
header.classList.toggle('scrolled', window.scrollY > 80);
}); */Example markup:
<header class="site-header">
<div class="inner">
<a href="/">Brand</a>
<nav>
<a href="/work">Work</a>
<a href="/pricing">Pricing</a>
<a href="/contact">Contact</a>
</nav>
</div>
</header>And enough CSS to make it real:
body {
margin: 0;
font-family: system-ui, sans-serif;
background: linear-gradient(#dbeafe, #ffffff 28rem);
min-height: 200vh;
}
.site-header {
position: sticky;
top: 0;
z-index: 1000;
animation: header-shrink linear both;
animation-timeline: scroll(root);
animation-range: 0px 120px;
}
.site-header .inner {
width: min(72rem, calc(100% - 2rem));
margin-inline: auto;
display: flex;
justify-content: space-between;
align-items: center;
}Two details matter here:
animation-range: 0px 120px means the transition happens in the first 120px of scroll.animation-fill-mode: both is included via animation: ... both;, so the header keeps the scrolled appearance after the range finishes.This pattern is especially useful when your hero starts transparent and the header needs to become readable against regular page content after a short scroll.
Parallax is where developers often assume JavaScript is unavoidable because they need to compute scrollY * speed. For subtle, scroll-linked motion, CSS handles it just fine.
Keep it transform-only and keep the motion restrained.
@keyframes parallax-shift {
from { transform: translateY(0); }
to { transform: translateY(-80px); }
}
.hero-image {
animation: parallax-shift linear both;
animation-timeline: scroll(root);
animation-range: 0px 600px;
}
/* JS this replaces:
window.addEventListener('scroll', () => {
hero.style.transform = `translateY(${window.scrollY * 0.13}px)`;
}); */A more production-ready version looks like this:
<section class="hero">
<img
class="hero-image"
src="https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1400&q=80"
alt="Mountains at sunrise"
>
<div class="hero-copy">
<h1>Scroll-linked motion without JS</h1>
<p>Subtle depth works better than dramatic movement.</p>
</div>
</section>.hero {
position: relative;
min-height: 80vh;
overflow: clip;
display: grid;
place-items: center;
background: #020617;
}
.hero-image {
position: absolute;
inset: -4rem 0 0 0;
width: 100%;
height: calc(100% + 8rem);
object-fit: cover;
will-change: transform;
animation: parallax-shift linear both;
animation-timeline: scroll(root);
animation-range: 0px 600px;
}
.hero-copy {
position: relative;
z-index: 1;
color: white;
text-align: center;
padding: 2rem;
text-shadow: 0 2px 20px rgb(0 0 0 / 0.35);
}
@keyframes parallax-shift {
from { transform: translate3d(0, 0, 0); }
to { transform: translate3d(0, -80px, 0); }
}Why translate3d() and will-change? They help promote the layer for compositor-driven updates. That’s the same performance advice you’d follow in a JavaScript version, but here the browser controls the timing.
Keep the effect subtle. A good parallax value usually feels smaller than what teams first try. If users notice the technique before they notice the content, it’s probably too strong.
This is not “delete all animation JavaScript forever.”
First, browser support is good enough for progressive enhancement, not for pretending it’s universal baseline. Chrome has supported scroll-driven animations since 2023. Safari 26 shipped them in 2025. Firefox still requires a flag in early 2026. That means your production CSS should always fall back to a sensible static state.
Second, these patterns work best when scroll position directly maps to visual progress. If your effect depends on business logic, cross-component orchestration, physics, sequencing with async events, or scrubbing a complex animation timeline across multiple targets, JavaScript libraries may still be the right tool.
Third, scroll-linked motion can become noisy fast. A page with reveals, a reactive header, parallax, counters, and pinned sections all competing for attention is harder to use than a page with one or two restrained effects.
Fourth, layout-affecting properties are still a bad idea. Even though the timeline is CSS-native, you should still prefer compositor-friendly transforms and opacity where possible. That’s why the progress bar uses scaleX instead of width, and why parallax sticks to translate3d.
For production, wrap all of this in support and motion-preference guards. Browsers without support should just render the static state, and users who request reduced motion should not get scroll-driven effects.
@supports (animation-timeline: scroll()) {
@media (prefers-reduced-motion: no-preference) {
/* All scroll-driven animation CSS goes here */
.reveal {
animation: fade-in-up linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
}
}That wrapper is the minimum. In real projects, put each pattern’s animation rules inside it and leave your base styles outside it. Then test across different viewport heights, mobile browser chrome, and safe-area conditions with the Viewport Debugger, especially for sticky headers and short scroll ranges where timing can feel different than on desktop.
These patterns replace a surprising amount of JavaScript, and that’s the real win: less wiring, fewer listeners, and a direct mapping between scroll and motion in CSS. They’re also a progressive enhancement, not a hard dependency, so unsupported browsers simply get the static version instead of a broken one. If you’re swapping out old scroll listeners or IntersectionObserver reveals, use the Viewport Debugger to validate how those animations behave across viewport sizes and scroll ranges before you ship.