Every scroll animation library I have reached for over the last few years does the same thing: it listens to the scroll event, reads the element's position, and writes a new transform. Sixty times a second, on the main thread, next to your hydration and your data fetching and your own click handlers.
The browser has had a better way to do this since Chrome 115 shipped in July 2023. ScrollTimeline and ViewTimeline let you hand your keyframes to the browser's own animation engine and say "this animation's playhead is the scroll position". For transform, opacity and filter, the compositor takes it from there. No JavaScript runs per frame. None.
Almost nobody uses it. Not because it is obscure — because it is awkward:
@keyframes reveal {
from { opacity: 0; translate: 0 32px; }
to { opacity: 1; translate: 0 0; }
}
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
That is fine in a stylesheet. Inside a component, where the distance is a prop and the range depends on the layout, it means generating CSS strings or writing a <style> tag by hand. And you still need to answer the question "what happens in Firefox?" — which, as of today, still has scroll-driven animations behind a flag in stable.
Those two problems are exactly what a hook should hide. So I built one.
use-scroll-timeline
npm i use-scroll-timeline
"use client";
import { useScrollReveal } from "use-scroll-timeline";
export function Card() {
const ref = useScrollReveal<HTMLDivElement>({ variant: "fade-up" });
return <div ref={ref}>I reveal myself as I scroll into view</div>;
}
3.7 kB min+gzip. Zero dependencies. Six hooks. No provider, no context, no CSS import.
Live demo — every hook, with live read-outs →
Anything more specific goes through the core hook, which takes plain Web Animations keyframes — the same object you would pass to element.animate():
const ref = useScrollTimeline<HTMLElement>({
keyframes: {
opacity: [0, 1],
scale: ["0.8", "1"],
filter: ["blur(8px)", "blur(0px)"],
},
range: ["entry 0%", "cover 40%"],
});
The part I like most: progress as a CSS variable
Most scroll libraries hand you a number and let you put it in state. That number arrives every frame, so your component re-renders every frame, and now you have a JavaScript scroll animation again with extra steps.
useScrollProgress writes progress into a CSS custom property instead and re-renders nothing:
const ref = useScrollProgress<HTMLDivElement>({ timeline: "scroll" });
<div ref={ref} className="reading-bar" />;
.reading-bar {
transform-origin: left;
scale: var(--progress, 0) 1;
}
Where the native API exists, the hook registers that property with CSS.registerProperty and lets the browser animate it. So the reading progress bar at the top of a page — the thing every blog implements with a scroll listener — costs zero main-thread work.
useScrollProgressValue still exists for when you genuinely need the number in React. It is throttled by a precision option, defaulting to about 100 renders across the whole range instead of one per frame.
The fallback is the interesting bit
This is the part I expected to be ugly and turned out clean.
The naive fallback is to reimplement keyframe interpolation in JavaScript — parse the values, lerp them, write styles. That is a lot of code and a lot of ways to be subtly wrong about translate versus transform versus filter.
Instead: build the exact same WAAPI animation, pause it, and scrub it.
const animation = element.animate(keyframes, { duration: 1000, fill: "both" });
animation.pause();
// later, from the scroll driver:
animation.currentTime = progress * 1000;
The browser is already an excellent keyframe interpolator. The fallback borrows it and only replaces the clock. Same keyframes, same easing, same fill behaviour, no interpolation code of my own — and the difference between the native path and the fallback becomes where the playhead comes from, nothing else.
The driver itself is one IntersectionObserver per element to gate the work, and one shared requestAnimationFrame loop plus one capture-phase scroll listener for the entire page. Ten animated elements cost one frame callback, not ten.
Named ranges, which are the actual API
The variants are the marketing; the ranges are the feature. This is the vocabulary from the CSS spec, and the hooks take it verbatim as strings:
| Range | Starts when | Ends when |
|---|---|---|
cover |
subject's leading edge touches the scrollport | subject has completely left |
entry |
subject starts entering | subject is fully in |
exit |
subject starts leaving | subject has completely left |
contain |
subject is fully inside | subject starts leaving |
range: ["entry 25%", "contain 50%"]
Here is the thing that cost me an afternoon and is worth knowing whether or not you use this package: the entry range is exactly as long as the element's own height. A 150px card finishes its entire reveal in 150px of scrolling. It technically works, it is spec-correct, and it looks like nothing happened.
End the range later and the same animation becomes something you can actually see:
useScrollReveal({ range: ["entry 0%", "cover 40%"] }); // ~400px of travel
I only understood this properly after building a panel that watches one element through entry, contain and exit simultaneously and prints all three numbers. Watching them hand over to each other is the fastest way to internalise the model — it is the first section of the demo.
Reduced motion, and why your page might look broken
prefers-reduced-motion: reduce is respected by default: the hook skips the animation and lands the element on its final keyframe.
This is correct behaviour that looks exactly like a bug. If you have "Animation effects" turned off in Windows or "Reduce motion" on in macOS, every reveal on the page appears already revealed, parallax layers sit still, and changing a prop seems to do nothing. I lost a good twenty minutes to this on my own library. Both demos now detect it and say so in a banner, and you can opt out per hook with respectReducedMotion: false.
Where it runs natively
| Browser | Path |
|---|---|
| Chrome / Edge 115+ | Native ViewTimeline / ScrollTimeline
|
| Safari 26+ | Native, threaded since 26.4 |
| Firefox | Fallback — still behind a flag in stable |
| Anything older | Fallback |
Roughly 80% of users get the compositor path today, and the number only goes up. The remainder get an animation that is visually identical and still cheaper than a typical scroll-listener implementation, because the whole page shares one loop.
Three things shipping it taught me
tsup silently ate my "use client" directive. The banner was configured correctly, but tsup's rollup treeshaking pass strips module-level directives from the bundle — so the built file had no directive and the package would have broken in the App Router. Fixed with a tiny post-build script that re-attaches it on the same line as the first statement, which keeps source-map line numbers accurate.
publint found a real bug in my exports map. I had a single types field, which means TypeScript resolves the ESM .d.ts even when the consumer uses require(). CJS users would have had types that only work under dynamic import(). Split into import and require conditions pointing at .d.ts and .d.cts. Run publint --strict and attw --pack . before your first publish — always.
"provenance": true in publishConfig breaks a manual publish. Provenance can only be generated on a supported CI runner, so that flag makes npm publish from your laptop fail outright. Leave it out of the manifest and pass --provenance in the release workflow instead.
Links
- Demo — https://use-scroll-timeline-demo.vercel.app/
- npm — use-scroll-timeline
- Source — github.com/saadahmad888/use-scroll-timeline
- Related — stack-on-scroll, the sticky stacking-card version of the same idea
It is 0.1.0, so the API can still move. If you use it and something is awkward, an issue is genuinely welcome — that is the most useful thing anyone can send me right now.
Built by Saad Ahmad. I build small, focused front-end libraries and the sites that show them off — TypeScript, React, Next.js, and a slightly unreasonable interest in what the platform can already do without a dependency.
More of my work at isaadahmad.com.
If your team is fighting a janky scroll page, carrying 70 kB of animation library for four effects, or needs a component library packaged properly — that is the kind of work I take on, and my inbox is open.
Top comments (0)