Scroll-driven animation with no JavaScript at all
Technology · 2026-08-04 · 8 min read · 1728 words
By ESPYCRUX, Studio
We rebuilt our site's scroll animation on CSS scroll timelines and deleted every scroll listener, every animation library call and roughly forty JavaScript observers. Here is what the technique actually replaces, the fallback you still have to write, and the three things that caught us out.
Most scroll animation on the web is built the same way. You install a library, wrap things in a component, and that component quietly attaches an IntersectionObserver per element and runs a spring on the main thread. It works. It also means that scrolling — the single most common interaction on your site — is executing JavaScript on every frame.
CSS scroll-driven animations move that work to the compositor. The browser already knows the scroll position; with animation-timeline you can drive a normal CSS animation from it directly, and your JavaScript never runs at all.
We rebuilt our own site on this recently. This is what we removed, what we replaced it with, and what we got wrong on the way.
What we had
The site was a React app using framer-motion for reveals. The pattern was ordinary:
<Reveal>
<h2>Section heading</h2>
</Reveal>Underneath, each Reveal mounted a motion node with its own viewport observer and its own JS-driven spring. On a long page that is roughly forty observers and forty animations, all scheduled through the main thread.
That was not the whole of it. We also had:
- Twenty-six decorative particles, each a
motion.spanrunning an infinite float animation. Twenty-six JS animations that never stop, on every page. - A tilt card that called
setStateon everymousemove, re-rendering the card and its children dozens of times a second and allocating a fresh state object each time. - A sticky header with a
scrolllistener whose only job was toggling one class — which re-rendered the entire layout tree on every scroll event.
None of this was unreasonable. It is what you get by following each library's documentation. It is also far more machinery than the effects justified.
The core idea
A CSS animation normally runs on a time timeline: you give it a duration and the browser plays it. animation-timeline lets you swap that time source for a scroll position.
Two functions matter:
view()— a timeline tied to the element's own progress through the viewport. 0% is the moment it starts entering, 100% is the moment it finishes leaving.scroll()— a timeline tied to a scroll container's overall progress.scroll(root block)is the whole page, top to bottom.
A reveal becomes this:
@keyframes rise {
from { opacity: 0; transform: translate3d(0, 30px, 0); }
to { opacity: 1; transform: translate3d(0, 0, 0); }
}
.reveal {
animation: rise linear both;
animation-timeline: view();
animation-range: entry 5% cover 30%;
}That is the entire reveal. No component, no observer, no library. animation-range says: start when the element is 5% into entering the viewport, finish when the viewport has covered 30% of it.
A reading-progress bar is even smaller:
.progress {
transform-origin: left;
animation: grow linear both;
animation-timeline: scroll(root block);
}
@keyframes grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }That bar is now perfectly synchronised with the scrollbar, forever, and costs nothing.
Staggering without delays
The obvious way to stagger a list is animation-delay. On a scroll timeline that is the wrong tool — the "time" is your scroll position, so a delay behaves unpredictably depending on how fast someone scrolls.
Stagger the range instead:
.item-1 { animation-range: entry 5% cover 30%; }
.item-2 { animation-range: entry 8% cover 34%; }
.item-3 { animation-range: entry 11% cover 38%; }
.item-4 { animation-range: entry 14% cover 42%; }Each item now starts fractionally later in its own journey through the viewport. Scroll slowly and they cascade; scroll fast and they all resolve, because the range is anchored to position rather than to the clock. Nothing is ever left half-animated because a timer was still running when the element left the screen.
Parallax, without a scroll handler
Parallax is where JavaScript scroll listeners usually appear, and where they hurt most, because the handler typically reads layout on every event.
@keyframes drift {
from { transform: translate3d(0, 8%, 0); }
to { transform: translate3d(0, -8%, 0); }
}
.layer {
animation: drift linear both;
animation-timeline: view();
animation-range: cover; /* the element's entire visible pass */
will-change: transform;
}Give two layers different distances and you have depth. We use exactly this for the column rules behind our hero and for the oversized index numerals beside each section — they travel against the content at a different rate, and the main thread is not involved.
The fallback you still have to write
Here is the part most write-ups skip. animation-timeline is not Baseline. MDN currently marks it Limited availability — Chromium ships it, WebKit has been implementing it, and Firefox is behind a flag. You cannot treat it as a given.
Gate everything:
@supports (animation-timeline: view()) {
.reveal {
animation: rise linear both;
animation-timeline: view();
animation-range: entry 5% cover 30%;
}
}
@supports not (animation-timeline: view()) {
.reveal {
opacity: 0;
transform: translate3d(0, 24px, 0);
transition: opacity .65s cubic-bezier(.22,1,.36,1),
transform .65s cubic-bezier(.22,1,.36,1);
}
.reveal.is-in { opacity: 1; transform: none; }
}Then add the .is-in class with an observer — but one observer for the whole page, not one per element. This is the pattern we settled on:
let observer = null;
let count = 0;
function getObserver() {
if (observer) return observer;
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add('is-in');
release(entry.target); // one-shot: stop watching once played
}
}, { rootMargin: '0px 0px -12% 0px', threshold: 0.05 });
return observer;
}
function release(el) {
if (!observer) return;
observer.unobserve(el);
if (--count <= 0) {
observer.disconnect(); // tear down when the last one is done
observer = null;
count = 0;
}
}Two details worth copying. It unobserves each element once it has played, so the observer is not still tracking a hundred elements you have finished with. And when the last element is released it disconnects and nulls the observer entirely, so nothing is retained after a route change. On a single-page app, an observer that quietly survives navigation is a genuine leak.
There is one more case: a browser with neither scroll timelines nor IntersectionObserver. Show the content:
if (typeof IntersectionObserver === 'undefined') {
el.classList.add('is-in');
return;
}Getting this wrong means content that is opacity: 0 forever. It is the worst possible failure mode and it is one line to prevent.
Reduced motion is not optional here
Scroll-driven effects are exactly the category of animation that makes some people feel unwell. Handle it properly, and note the second rule — it is the one people forget:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
}
/* Not enough on its own: the reveal's "from" state is invisible,
so it must also be forced to its finished state. */
.reveal, .drift, .mask {
animation: none !important;
opacity: 1 !important;
transform: none !important;
clip-path: none !important;
}
}Killing the animation alone leaves the element stuck at opacity: 0. You have to explicitly restore the end state.
Three things that caught us out
1. view() measures the element, not the section. We initially put the timeline on a wrapper that was taller than the viewport, so its "entry" phase was over long before the content inside was visible, and everything appeared to fire early. Put the timeline on the thing you are actually animating, or name a timeline explicitly with view-timeline-name and reference it.
2. Content must exist in the DOM. If you prerender for crawlers — and you should — remember that a reveal element is present and readable, just transformed. That is fine. What is not fine is building reveals that inject content on intersection: a crawler that does not scroll sees an empty page. Transform, do not gate.
3. Measure the right thing. We added a small control to a header and wrote a check to shrink it if the page overflowed horizontally. It kept shrinking, on two pages, for no visible reason. The check was measuring document.documentElement.scrollWidth — and those two pages had a marquee track deliberately wider than the viewport, so the check tripped every time. The control was never the problem. Measure the element you care about (host.scrollWidth > host.clientWidth), not the document.
That third one cost an hour and is the general lesson of this whole exercise: when you remove the machinery, the bugs that remain are yours, and they are much easier to see.
What we still use JavaScript for
Scroll timelines are not a total replacement. We kept JS for:
- Pointer-tracked effects. A light that follows the cursor is not a scroll animation. We do it by writing two CSS custom properties from a passive
pointermove, caching the bounding rect onpointerenterrather than reading it every event, and never touching React state — so there is no re-render and no per-event layout read. - The fallback observer, described above.
- **Anything that needs to know which section is current** — a spine marker, a sheet number. That is a discrete state change, not a continuous one, and an observer is the right tool.
The rule we ended on: continuous, position-linked motion belongs in CSS. Discrete state belongs in JavaScript.
Was it worth it
The honest answer is that we did not have a performance emergency. The old version was acceptable.
What changed is the shape of the cost. Before, every scroll event did work proportional to how much was on screen. Now, scrolling the site executes no JavaScript at all — no listeners, no requestAnimationFrame loop, nothing allocating per frame, and nothing that can leak when a component unmounts. The effects also became more elaborate, not less, because they stopped being expensive: type that wipes up from behind a hard edge, rules that draw themselves, numerals that travel against their own section.
If you are starting something new, start here. If you have an existing site, the reveal pattern alone is worth converting — it is usually the largest single source of animation work on the page, and it is about fifteen lines of CSS to replace.
Just write the fallback first. The temptation is to ship the good version and add the @supports not block later, and "later" is how you end up with an invisible page in Firefox.
Sources: MDN — animation-timeline · MDN — CSS scroll-driven animations · WebKit — A guide to scroll-driven animations with just CSS
Tags: css, animation, performance
ESPYCRUX — ESPYCRUX is a small product studio based in India, building focused web applications and writing about the engineering behind them. Articles are written by whoever did the work, and published under the studio name. Reach the studio at admin@espycrux.com.