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

scrollreel

v0.2.0

Published

Scroll-driven video scrubbing for React. Point it at a video, get a frame-accurate scroll animation.

Downloads

92

Readme

scrollreel

Scroll-driven video scrubbing for React. Point it at a video, get a frame-accurate scroll animation — the effect Apple uses on its product pages.

npm install scrollreel
import { ScrollVideo } from 'scrollreel'

<ScrollVideo src="/hero.mp4" scrollLength="300vh" />

Scroll down, the frames advance. Scroll up, they run backwards.


Why this isn't just video.currentTime = progress * duration

That is the obvious implementation, and it looks fine on desktop Chrome with a short test clip. In production it breaks:

  • Seeking is keyframe-bound. H.264 with a typical 2-second GOP makes the browser jump to the nearest keyframe and decode forward — up to 60 frames of work per seek at 30fps. You see stutter and frame-snapping.
  • Seeking is async and coalesced. Scroll fires far faster than seeks resolve, so intermediate seeks get dropped and motion looks stepped.
  • iOS Safari is the hard case. Even with muted + playsinline + preload="auto", currentTime scrubbing on a normal MP4 often only repaints at keyframes.

So scrollreel keeps that as the API but not the implementation. It ships pluggable engines and a CLI that produces media the engines can actually scrub.

Engines

| Engine | How it works | Smoothness | Payload | Status | | --- | --- | --- | --- | --- | | frames | Pre-extracted image sequence painted to canvas | Perfect, every browser | Large, but responsive sets and bisecting load cut what's actually fetched | Recommended | | video | Real <video>, coalesced currentTime seeks | Good only with a short GOP | Small | Fallback | | webcodecs | Manual VideoDecoder + frame cache | Perfect, small payload | Small | Not implemented in 0.1 |

engine="auto" (the default) picks frames when you pass a frames prop, otherwise video. It never auto-selects webcodecs.

Prepare your media

The library ships a CLI that wraps ffmpeg. Do not skip this step — feeding <ScrollVideo> a stock MP4 and using the video engine is exactly the janky path described above.

# Recommended: extract an image sequence + manifest.json
npx scrollreel prepare hero.mp4 --out public/hero --frames 180 --width 1920

# Better: responsive sets, so a phone doesn't download desktop-sized frames
npx scrollreel prepare hero.mp4 --widths 768,1280,1920

# Smallest output, if your ffmpeg has libaom
npx scrollreel prepare hero.mp4 --widths 768,1280,1920 --format avif

# Alternative: re-encode with a keyframe every 6 frames so native seeking works
npx scrollreel prepare hero.mp4 --mode keyframes --gop 6

Then:

<ScrollVideo frames="/hero/manifest.json" scrollLength="400vh" />

Requires ffmpeg and ffprobe on PATH. Run npx scrollreel --help for all options.

Choosing a frame count

--frames 180 over scrollLength="300vh" means roughly one new frame per 13px of scroll on a 1080p screen — smooth without being wasteful. Go higher (240–300) for slow, deliberate hero animations; lower (90–120) for short accent clips. Frame count is the main lever on payload, so tune it before tuning quality.

API

<ScrollVideo>

| Prop | Type | Default | Notes | | --- | --- | --- | --- | | src | string | — | Video URL. Used by video / webcodecs. | | frames | string \| string[] \| FramesManifest | — | Manifest URL, list of frame URLs, or inline manifest. | | engine | 'auto' \| 'frames' \| 'video' \| 'webcodecs' | 'auto' | | | scrollLength | string \| number | '300vh' | How far you scroll to play the whole clip. Bigger = slower. | | stageHeight | string \| number | '100vh' | Height of the pinned stage. | | fit | 'cover' \| 'contain' \| 'fill' | 'cover' | | | axis | 'y' \| 'x' | 'y' | Horizontal needs a horizontally scrolling ancestor. | | smoothing | number | 0 | Eases progress instead of tracking 1:1. Try 0.85. | | poster | string | — | Painted until the first frame decodes, instead of black. | | segments | Segment[] | — | Thresholds firing onEnter / onExit as they're crossed. | | respectReducedMotion | boolean | true | Holds one frame when the OS asks for reduced motion. | | reducedMotionFrame | number | 0.5 | Which frame to hold, as 0..1. | | debug | boolean | false | Overlay: engine, progress, frame index, load %. | | onProgress | (p: number) => void | — | Called every frame with 0..1. Does not re-render. | | onReady / onError | callbacks | — | | | crossOrigin | '' \| 'anonymous' \| 'use-credentials' | — | Needed for cross-origin sources. | | children | ReactNode | — | Overlaid on the canvas, inside the pinned stage. | | progressState | boolean | false | Mirror progress into React state. Re-renders every frame. |

useScrollVideo(options)

The headless version — you own the markup.

import { useScrollVideo } from 'scrollreel'

function Hero() {
  const { containerRef, canvasRef, progressRef, ready } = useScrollVideo({
    frames: '/hero/manifest.json',
  })

  return (
    <div ref={containerRef} style={{ height: '400vh', position: 'relative' }}>
      <div style={{ position: 'sticky', top: 0, height: '100vh', overflow: 'hidden' }}>
        <canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block' }} />
      </div>
    </div>
  )
}

Returns { containerRef, canvasRef, progressRef, progress, ready, error }.

Read progressRef.current inside your own requestAnimationFrame to drive other animations without re-rendering. progress is only live when progressState: true.

Driving other elements from scroll

const captionRef = useRef<HTMLDivElement>(null)

<ScrollVideo
  frames="/hero/manifest.json"
  onProgress={(p) => {
    if (captionRef.current) captionRef.current.style.opacity = String(p > 0.6 ? 1 : 0)
  }}
>
  <div ref={captionRef} className="caption">Built for the road</div>
</ScrollVideo>

Mutating the DOM from onProgress keeps 60fps. Calling setState there will not.

useScrollProgress(options)

The progress engine on its own, with no video attached — same pinning, smoothing, and segment behaviour. Use it to scrub anything: SVG paths, counters, CSS custom properties.

import { useScrollProgress } from 'scrollreel'

function Bar() {
  const barRef = useRef<HTMLDivElement>(null)
  const { containerRef } = useScrollProgress({
    smoothing: 0.8,
    onProgress: (p) => {
      if (barRef.current) barRef.current.style.transform = `scaleX(${p})`
    },
  })

  return (
    <div ref={containerRef} style={{ height: '200vh' }}>
      <div style={{ position: 'sticky', top: 0 }}>
        <div ref={barRef} style={{ transformOrigin: 'left' }} />
      </div>
    </div>
  )
}

Returns { containerRef, progressRef, progress, reducedMotion }.

Segments

Fire callbacks as progress crosses thresholds — the usual way to sync captions to a scroll animation.

<ScrollVideo
  frames="/hero/manifest.json"
  segments={[
    { at: 0.33, onEnter: () => setChapter('middle'), onExit: () => setChapter('intro') },
    { at: 0.66, onEnter: () => setChapter('finale'), onExit: () => setChapter('middle') },
  ]}
/>

A fast scroll that jumps past several thresholds at once fires all of them, in order. Dropping the intermediate ones is how scrollytelling captions end up stuck in the wrong state.

Responsive frame sets

--widths emits one directory per width and a manifest with variants:

{
  "variants": [
    { "width": 768,  "height": 432,  "frames": ["768/0001.webp", "..."] },
    { "width": 1920, "height": 1080, "frames": ["1920/0001.webp", "..."] }
  ]
}

The engine measures the canvas, multiplies by DPR (capped at 2), and fetches the smallest variant at least that wide. Flat 0.1-style manifests still work unchanged.

Performance notes

  • The render loop is gated by an IntersectionObserver — an offscreen <ScrollVideo> costs nothing.
  • The canvas backing store is sized to devicePixelRatio (capped at 2) so frames aren't resampled twice.
  • The frames engine blocks readiness on frame 0 only, then streams the rest in bisecting order (0, n-1, n/2, n/4, 3n/4…) at 6-way concurrency. Because it falls back to the nearest decoded frame, the whole clip is scrubbable almost immediately at coarse temporal resolution and sharpens as frames land — rather than the first 40% working while the rest is missing.
  • With a responsive manifest, only the variant matching the container's width x DPR (capped at 2x) is fetched.
  • Redundant paints are skipped: if the frame index and canvas size are unchanged, drawImage is not called.
  • Serve frames with long-lived cache headers; they're immutable.

Accessibility

Pass aria-label to describe the animation — the canvas is exposed as role="img".

prefers-reduced-motion is honoured by default: when the OS asks for reduced motion the component holds a single frame (reducedMotionFrame, default the midpoint) instead of scrubbing, and segment callbacks stop firing. The canvas carries data-scrollreel-reduced-motion so you can style around it. Opt out with respectReducedMotion={false} if you have a good reason.

Browser support

frames works anywhere <canvas> and IntersectionObserver do. video depends on the browser's seek behaviour, so pair it with --mode keyframes. webcodecs needs VideoDecoder (Chrome, Safari 16.4+, Firefox 130+) and is not implemented yet.

The bundles ship a 'use client' banner, so importing ScrollVideo directly from a Next.js App Router server component works without a wrapper.

Roadmap

  • [x] prefers-reduced-motion handling
  • [x] Horizontal scroll direction
  • [x] AVIF output from the CLI
  • [x] Responsive frame sets, bisecting load order, poster, smoothing, segments, debug overlay
  • [ ] WebCodecs engine — demux with mp4box.js, bounded VideoFrame LRU with explicit .close(), directional decode-ahead
  • [ ] Vite/Next build plugin so frame extraction runs automatically
  • [ ] Frame interpolation, so fewer frames still look smooth

License

MIT © Sunny Marthak