use-scroll-timeline · v0.1.0

Fallback — IntersectionObserver + shared rAF

Scroll is a timeline. Use it as one.

React hooks over the native ScrollTimeline and ViewTimeline APIs. Your keyframes go to the browser’s own scroll-driven animation engine, so transform, opacity and filter run on the compositor instead of a JavaScript frame loop. Where the API is missing, the same hook falls back to IntersectionObserver and nothing changes for your users.

npmGitHubStart scrolling

Bundle

3.7 kB

Dependencies

0

Frame loops

1 shared

Hooks

6

useScrollReveal

Reveal presets

entry 0% → cover 40%
What this is
Seven one-line presets for the most common effect: an element that reveals itself as it scrolls into view. Each card below is a separate hook call.
How it works
The preset picks a pair of keyframes — for fade-up that is { opacity: 0, translate: "0 48px" } going to { opacity: 1, translate: "0 0" } — and hands them to the browser along with a ViewTimeline built from the element itself. There is no trigger and no threshold: the animation’s playhead is the scroll position, which is why scrolling back up plays it in reverse.
Why the range matters
The library default is entry 0% → entry 100%, which lasts exactly as long as the element’s own height — for a short card that is over in roughly 150px of scrolling and is easy to miss. These cards end at cover 40% instead, stretching the reveal over about 400px. It is the single most useful thing to change.
What to watch
Each card has a meter beneath it showing live progress through the range. The card and the meter are driven by two separate hooks, so if the meter moves and the card does not, the problem is in the keyframes rather than the scroll tracking.
If nothing moves
An element already past its range sits at 100% with nothing left to animate. Scroll up until the cards leave the viewport, then come back down.

fade-up

Rises 48px into place. The default.

range progress0%

fade

Opacity only — the safe one.

range progress0%

fade-down

Drops in from above.

range progress0%

fade-left

Enters from the right edge.

range progress0%

fade-right

Enters from the left edge.

range progress0%

zoom

Scales up from 0.92.

range progress0%

blur

12px of blur resolving to none.

range progress0%
const ref = useScrollReveal<HTMLDivElement>({
  variant: "blur",
  distance: 48,
  range: ["entry 0%", "cover 40%"],
});

return <div ref={ref}>…</div>;

useScrollParallax

Depth from distance

cover 0% → cover 100%
What this is
Three boxes that pass by at three different speeds. There is no parallax engine involved: every layer is an ordinary two-keyframe animation, and the only difference between them is how far each one travels.
How it works
useScrollParallax({ distance: 420 }) expands to exactly two keyframes: translate: "0 210px" going to translate: "0 -210px". Across the cover range the layer moves 420px of its own accord while the page carries it along as normal, so it appears to lag behind. The front box has no animation at all — it is the reference point.
The maths
Offset at any moment is (0.5 − progress) × distance. At progress 0.5 — the layer’s centre crossing the viewport centre — every layer sits at its natural position, which is why they all line up on the dashed line.
What to watch
The panel on the right prints each layer's live offset in pixels. Scroll slowly: the back layer's number changes roughly twice as fast as the middle one, and the front box never moves. That difference is the entire effect.
Keep it cheap
Only translate and scale change here. Animating top or margin instead would force layout on every frame and undo the point of using a native timeline.
cover 50% — every layer at rest
backdistance 420px
middledistance 220px
frontno animation
progress0.0%
back210px
middle110px
front0px
const back = useScrollParallax({ distance: 420 });
const mid  = useScrollParallax({ distance: 220 });

// parallaxKeyframes({ distance: 420 }) is what that expands to:
[{ translate: "0 210px" }, { translate: "0 -210px" }]

useScrollProgress ×3 + useScrollProgressValue

One element, four ranges at once

What this is
This entire section — heading, text and all — is the element being measured. Four hooks watch it at the same time through different named ranges.
How it works
Each hook writes a 0–1 number into a CSS custom property on the section (--p-entry and friends). The meters are plain CSS reading var(--p-entry), so React re-renders nothing. The big number is the exception: it uses useScrollProgressValue, which does put the value into state.
What to watch
Entry fills first and stops at 100%. Contain then runs for the whole time the section covers your screen. Exit only starts once the bottom of the section comes back into view. Watching them hand over in sequence is the clearest way to understand what the named ranges mean.
The travelling block
Its vertical position is top: calc(var(--lab) * (100% - 128px)). No JavaScript touches that element.

Scrollport

entry ends
exit begins

0.0%

contain progress

entry

Coming into view

contain

Fully inside the scrollport

exit

On the way out
useScrollProgress({
  target: sectionRef,
  cssVariable: "--p-entry",
  rangeStart: "entry 0%",
  rangeEnd: "entry 100%",
})

useScrollProgress · useScrollProgressValue

A number you can style with

entry 20% → exit 40%
What this is
One hook feeding two consumers: a conic-gradient ring drawn purely in CSS, and a percentage rendered by React.
How it works
The hook writes --progress onto the dial and also calls back with the value. Where the browser supports it, that custom property is registered with CSS.registerProperty and animated by the browser itself, so the ring costs nothing on the main thread. The number does cost a re-render, which is why precision defaults to 0.01: about 100 renders across the whole range instead of one per frame.
Where else
The thin bar pinned to the top of this page is the same hook with timeline: "scroll", measuring the document instead of an element. That one re-renders nothing at all.
const ref = useScrollProgress({ timeline: "scroll" });

/* css */
.bar {
  transform-origin: left;
  scale: var(--progress, 0) 1;
}

timeline: “scroll” · axis: “inline”

A nested scroller, sideways

What this is
Every other section measures an element moving through the viewport. This one measures a scroll container’s own position instead, which is what timeline: "scroll" means.
How it works
source takes a ref, so the timeline follows that div rather than the page, and axis: "inline" switches the maths from scrollTop to scrollLeft.
What to watch
Drag the strip sideways. The page does not move, but the meter underneath fills. Scrolling the page does nothing to it.
axis: block
axis: inline
axis: x
axis: y
axis: nearest
axis: root
axis: self

useScrollTimeline

Stacking cards on an exit range

exit 0% → exit 100%
What this is
The escape hatch. When no preset matches, pass plain Web Animations keyframes and pick a range.
How it works
Each card animates scale 1 → 0.92 and opacity 1 → 0.35 across its exit range, which begins the moment the card’s top edge starts leaving the screen. Combined with position: sticky, the card appears to sink as the next one covers it.
What to watch
Each card dims and shrinks only while it is being covered, then holds that state — that is fill: "both" at work.

The card that stays

Each card sticks, then shrinks and dims as the next one slides over it. That is one animation per card on its own exit range.

No scroll handler anywhere

position: sticky does the pinning. The scroll timeline does the shrinking. Neither one needs a frame loop when the browser supports the API.

Already packaged

If this is the only effect you want, stack-on-scroll ships it as a layout component instead of a hook.