use-scroll-timeline
v0.1.1
Published
Scroll-driven animation hooks for React, built on the native CSS ScrollTimeline API. Runs off the main thread, falls back to IntersectionObserver, ~2 kB.
Downloads
281
Maintainers
Readme
use-scroll-timeline
Scroll-driven animation hooks for React, on the browser's native timeline engine.
Live demo · Quick start · Hooks · Ranges · Browser support
animation-timeline: view() is the fastest way to animate on scroll, and almost nobody reaches for it — the CSS syntax is awkward from inside a component, the named ranges are hard to remember, and writing the fallback yourself is a chore.
This is a 3.7 kB wrapper that hides all three.
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>;
}That's the whole API surface for the common case. No provider, no context, no CSS import.
Why not Framer Motion or GSAP
| | Frame source | Bundle |
| :------------------------- | :--------------------------------- | :----------- |
| use-scroll-timeline | Native scroll timeline, compositor | 3.7 kB |
| Framer Motion useScroll | JavaScript, every frame | ~30–50 kB |
| GSAP + ScrollTrigger | JavaScript, every frame | ~70 kB |
Size is the smaller half of it. A JS-driven scroll animation has to wake the main thread on every single frame, so it competes with hydration, data fetching and your own event handlers. A native scroll-driven animation of transform / opacity / filter doesn't — the compositor owns it.
Where the native API is missing, the same hook falls back to an IntersectionObserver-gated rAF loop that all hooks on the page share. One scroll listener and one requestAnimationFrame, no matter how many elements you animate.
Quick start
Anything more specific than a preset goes through the core hook, which takes plain Web Animations keyframes:
import { useScrollTimeline } from "use-scroll-timeline";
const ref = useScrollTimeline<HTMLElement>({
keyframes: {
opacity: [0, 1],
scale: ["0.8", "1"],
filter: ["blur(8px)", "blur(0px)"],
},
range: ["entry 0%", "cover 40%"],
});Progress as a CSS variable, with zero re-renders:
const ref = useScrollProgress<HTMLDivElement>({ timeline: "scroll" });
<div ref={ref} className="reading-bar" />;.reading-bar {
transform-origin: left;
scale: var(--progress, 0) 1;
}Hooks
useScrollTimeline(options)
The core hook. Returns a ref for the element you want animated.
| Option | Type | Default | Notes |
| :--------------------- | :-------------------------------------------- | :------------------- | :------------------------------------------------------------- |
| keyframes | Keyframe[] \| PropertyIndexedKeyframes | — | Standard WAAPI keyframes |
| timeline | "view" \| "scroll" | "view" | Element through the viewport, or a container's own scroll |
| range | [start, end] | ["cover 0%", "cover 100%"] | Shorthand for the two below |
| rangeStart rangeEnd| string \| number | — | "entry 50%", "contain", "75%", or 0.75 |
| axis | "block" \| "inline" \| "x" \| "y" | "block" | Scroll axis |
| source | Element \| RefObject \| () => Element | nearest scroller | Container for timeline: "scroll" |
| fill | "none" \| "forwards" \| "backwards" \| "both" | "both" | Whether end states hold outside the range |
| easing | string | "linear" | Any CSS easing |
| respectReducedMotion | boolean | true | Jump to the end state instead of animating |
| target | RefObject \| () => Element | — | Animate a different element than the returned ref |
| disabled | boolean | false | Skip attaching entirely |
| cssVariable | string | — | Write 0–1 progress to a custom property |
| onProgress | (p: number) => void | — | Runs on the main thread — see the notes |
| forceFallback | boolean | false | Use the JS path even where native works |
useScrollReveal(options?)
const ref = useScrollReveal({ variant: "blur", distance: 48 });variant: fade · fade-up · fade-down · fade-left · fade-right · zoom · blur.
Override either end with from / to.
The default is entry 0% → entry 100%, and that range is exactly as long as the element's own height. On a short card the reveal is over in ~150px of scrolling, which is easy to miss entirely. End it later for a slower, more visible reveal:
useScrollReveal({ variant: "fade-up", range: ["entry 0%", "cover 40%"] });revealKeyframes(variant, distance) returns the pair a preset uses, if you want to inspect one or hand it to useScrollTimeline yourself.
useScrollParallax(options?)
const back = useScrollParallax({ distance: 420 });
const mid = useScrollParallax({ distance: 220 });Each layer travels distance / 2 px either side of its natural position, so it sits at rest exactly halfway through the range. Offset at any moment is (0.5 - progress) * distance.
Depth comes from the difference between layers: 420 and 220 reads as depth, 240 and 200 reads as nothing. parallaxKeyframes({ distance, axis, scale }) returns the keyframes directly.
useScrollProgress(options?)
Writes 0–1 progress into a CSS custom property (--progress by default) and never re-renders your component. Where the native API exists the property is registered with CSS.registerProperty and animated by the browser, so this stays off the main thread too. Pass onChange if you also need the value in JS.
useScrollProgressValue(options?)
The escape hatch — progress as React state.
const [ref, progress] = useScrollProgressValue({ precision: 0.05 });It re-renders as you scroll, so keep the component small and raise precision (default 0.01, about 100 renders across the range) where you can.
useScrollTimelineSupport()
const { supported, css } = useScrollTimelineSupport();Returns { supported: false, css: false } during SSR and the real values after hydration, so it can never cause a mismatch.
Ranges
Same vocabulary as CSS animation-range. Subject is your element; scrollport is the viewport or scroll container.
| 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, or fills the scrollport |
| exit | subject starts leaving | subject has completely left |
| contain | subject is fully inside | subject starts leaving |
| entry-crossing | leading edge crosses the far edge | trailing edge crosses the far edge |
| exit-crossing | leading edge crosses the near edge | trailing edge crosses the near edge |
Write them as "entry 0%", "cover 50%", or bare — "contain" means 0% as a start and 100% as an end. A plain "40%" or 0.4 is a cover offset.
range: ["entry 25%", "contain 50%"]Browser support
| 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 |
The fallback keeps your exact keyframes: it builds the same WAAPI animation, pauses it, and scrubs currentTime from the shared rAF loop, which only runs while the element is intersecting.
prefers-reduced-motion: reduce is honoured by default — the element lands on its final keyframe with no animation. If a page looks static when you expected motion, check that setting first.
Next.js and RSC
The bundle carries "use client", so importing it from a Server Component marks only this module as client code. In practice:
// components/reveal.tsx
"use client";
import { useScrollReveal, type RevealVariant } from "use-scroll-timeline";
export function Reveal({
children,
variant = "fade-up",
}: {
children: React.ReactNode;
variant?: RevealVariant;
}) {
const ref = useScrollReveal<HTMLDivElement>({ variant });
return <div ref={ref}>{children}</div>;
}Server rendering is safe — nothing touches window until a layout effect runs.
Without React
import { scrollAnimate } from "use-scroll-timeline";
const handle = scrollAnimate(element, {
keyframes: { opacity: [0, 1] },
range: ["entry 0%", "entry 100%"],
});
handle.native; // did it get the compositor path?
handle.cancel();parseOffset, formatOffset, rangeEdges, viewProgress, scrollProgress and coverProgress are exported too, if you want the range maths on its own.
Notes and gotchas
- Animate compositable properties.
transform/translate/scale/rotate,opacityandfilterstay off the main thread.width,heightandmarginforce layout every frame and will be slow on both paths. onProgressruns on the main thread. It has to. PrefercssVariablewhen you only need to style something.- The fallback assumes horizontal-tb writing mode when mapping
block/inlineonto the viewport box. The native path has no such limitation. - Options are compared by value. A fresh object literal every render is fine; the animation is rebuilt only when a value actually changes.
Related
stack-on-scroll — sticky stacking-card sections. Same idea, shipped as one layout primitive instead of a general hook.
Contributing
npm install
npm test # vitest + jsdom
npm run demo # builds, then serves demo.html on :5173
npm run verify # typecheck, tests, build, publint, are-the-types-wrongdemo.html runs the built bundle in a real browser, asserts against it on load, and has a switch that forces the fallback path so you can compare it with the native one.
Author
Built by Saad Ahmad — isaadahmad.com · @saadahmad888
