npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Readme

use-scroll-timeline

Scroll-driven animation hooks for React, on the browser's native timeline engine.

npm version bundle size zero dependencies types license

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, opacity and filter stay off the main thread. width, height and margin force layout every frame and will be slow on both paths.
  • onProgress runs on the main thread. It has to. Prefer cssVariable when you only need to style something.
  • The fallback assumes horizontal-tb writing mode when mapping block / inline onto 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-wrong

demo.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 Ahmadisaadahmad.com · @saadahmad888

License

MIT