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.
use-scroll-timeline · v0.1.0
Fallback — IntersectionObserver + shared rAFReact 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.
Bundle
3.7 kB
Dependencies
0
Frame loops
1 shared
Hooks
6
useScrollReveal
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.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.fade-up
Rises 48px into place. The default.
fade
Opacity only — the safe one.
fade-down
Drops in from above.
fade-left
Enters from the right edge.
fade-right
Enters from the left edge.
zoom
Scales up from 0.92.
blur
12px of blur resolving to none.
const ref = useScrollReveal<HTMLDivElement>({
variant: "blur",
distance: 48,
range: ["entry 0%", "cover 40%"],
});
return <div ref={ref}>…</div>;useScrollParallax
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.(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.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.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
--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.top: calc(var(--lab) * (100% - 128px)). No JavaScript touches that element.Scrollport
0.0%
contain progress
entry
Coming into viewcontain
Fully inside the scrollportexit
On the way outuseScrollProgress({
target: sectionRef,
cssVariable: "--p-entry",
rangeStart: "entry 0%",
rangeEnd: "entry 100%",
})useScrollProgress · useScrollProgressValue
--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.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”
timeline: "scroll" means.source takes a ref, so the timeline follows that div rather than the page, and axis: "inline" switches the maths from scrollTop to scrollLeft.useScrollTimeline
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.fill: "both" at work.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.
position: sticky does the pinning. The scroll timeline does the shrinking. Neither one needs a frame loop when the browser supports the API.
If this is the only effect you want, stack-on-scroll ships it as a layout component instead of a hook.