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

@underlying/scroll

v1.2.1

Published

Scroll-driven animation for @underlying/core: scrub, pin, parallax, snap. Physics-first, no scroll-jacking.

Downloads

162

Readme

Part of underlying, a physics-first motion library with first-class framework adapters (Angular first).

Why

  • Scroll as a source, not an engine. This package owns the three browser concerns @underlying/core refuses - one IntersectionObserver, one passive scroll/resize listener, and getBoundingClientRect - turns scroll into a normalized 0..1, and fans it onto the core's existing seams. No physics is re-implemented.
  • Locked or momentum, one field. smooth: false locks the playhead to the scroll frame-for-frame (reversible, deterministic). A number routes through follow(), so the motion lags the scroll by that many seconds with conserved velocity.
  • Composed, not bolted on. Scrub drives the seekable PlaybackHandle.progress(); parallax returns a bindStyle-ready Animatable; everything runs on the same scheduler tick as the rest of your motion.
  • Accessible by default. One reduced-motion policy: momentum scrub collapses to locked, parallax is disabled, momentum snap goes instant - re-routed live when the OS preference changes.
  • SSR-safe and lazy. Nothing touches browser globals at import; the source is created on the first track()/builder call. Never scroll-jacking.

Install

npm install @underlying/scroll

@underlying/core comes with it as a dependency; scroll shares its one rAF loop and value model.

Quick start

import { createScroll } from '@underlying/scroll'
import { animatable, bindStyle, linear } from '@underlying/core'
import { playable } from '@underlying/core/playback'

const scroll = createScroll() // viewport, y-axis, shared scheduler

// Locked scrub: scroll position drives a seekable handle, frame-for-frame.
// A linear tween maps the scroll straight to progress (no ease-in on top).
const x = animatable(0)
bindStyle(panel, { x })
const clip = playable(x).to(600, { paused: true, easing: linear })
scroll.scrub(clip, { target: panel })            // smooth: false (default)

Smooth scroll - inertia takes over the page

Pass { smooth: true } and a spring takes over the scroll: wheel, touch (opt-in) and keys re-aim it and it glides to rest instead of stopping dead. It drives native scroll - no transform, no wrapper - so the scrollbar, in-page anchors, position: sticky and find-in-page keep working, and every other effect here (scrub, parallax, velocity, pin, snap, scrollTo) reads the smoothed position with no extra wiring.

const scroll = createScroll({ smooth: { smooth: 0.12 } }) // catch-up time constant, seconds

const lean = scroll.velocity({ map: v => Math.max(-10, Math.min(10, v * 0.03)) })
bindStyle(card, { skewY: lean })   // leans with the smoothed speed, for free
scroll.scrollTo(section)           // springs through the engine's one shared spring

Options (createScroll({ smooth }) or controller.smooth()): smooth (catch-up seconds, default 0.1, the same stiffnessFor mapping scrub/parallax use), spring, wheelMultiplier (1), touchMultiplier (2), keyboard (default on - Space/PageUp-Down/Home/End/Arrows, never stealing a form field), touch (default off - intercepting touchmove kills iOS native momentum and pull-to-refresh, so native touch is the honest default). The engine is lazy and one-per-controller; controller.smooth() returns a handle (enabled(), target(), velocity(), setTarget()). A user scroll the engine did not drive (scrollbar, anchor, keyboard) is adopted, not fought. Off under reduced motion - native scroll runs raw.

Scrub - locked or momentum

// Momentum: the handle trails the scroll by ~0.2s, velocity conserved.
scroll.scrub(clip, { target: panel, smooth: 0.2 })

// A live spring is seekable only after bake(); scrub does it once at link time.
const spring = playable(y).spring(800, { paused: true })
scroll.scrub(spring, { target: hero, smooth: 0.3 })

// A raw callback is always locked (nothing to seek).
scroll.scrub((p) => { label.textContent = `${(p * 100) | 0}%` })

Parallax

// Returns an Animatable - hand it straight to bindStyle.
const bgY = scroll.parallax({ target: section, output: [-120, 120] })
bindStyle(bg, { y: bgY })

const lead = scroll.parallax({ target: section, output: [60, -60], smooth: 0.15 })
bindStyle(foreground, { y: lead })

Velocity - lean with scroll speed

velocity() exposes how fast the scroller is moving as one bindStyle-ready value (px/s, signed), smoothed through a spring so it ramps and eases back to rest the moment you stop. Map it to a few degrees of skew, a scale, or a blur for the speed-reactive lean.

// raw signed px/s -> a few degrees, clamped; relaxes to 0 when scrolling stops
const skew = scroll.velocity({ map: (v) => Math.max(-8, Math.min(8, v * 0.02)) })
bindStyle(content, { skewY: skew })

Physics-first: a spring owns the relax, so a fresh flick mid-relax re-aims it with velocity conserved, never a restart. smooth (seconds) tunes the ramp/relax; spring overrides the follow config. Disabled under reduced motion (held at map(0)).

Marquee - a seamless looping ticker

marquee() is a standalone helper (no controller needed). It clones a track's children just enough to fill the container, drifts the strip at a constant speed, and wraps at exactly one content period so there's no seam. Hand it scroll.velocity() and it speeds up and reverses with the scroll - the agency ticker.

import { createScroll, marquee } from '@underlying/scroll'

const scroll = createScroll()
marquee(track, {
  speed: 60,                   // base drift, px/s
  velocity: scroll.velocity(), // + the live scroll speed -> reacts to scrolling
  pauseOnHover: true,
})

The container needs overflow: hidden; the track (its child holding the items) is what scrolls. Options: speed, direction (1 / -1), axis ('x' / 'y'), velocity (a signed-px/s Animatable to add), velocityFactor, pauseOnHover (eases the drift to a stop via a spring), spring. The loop sleeps while the container is off-screen (IntersectionObserver) and sits still under reduced motion. Clones are aria-hidden + inert; dispose() removes them and restores the element.

Pin

// Wrap in a spacer, position:fixed across the range. pin.track is the progress
// THROUGH the pinned span - feed it to a nested scrub.
const pin = scroll.pin(panel, { range: ['start start', 'bottom bottom'] })

const cap = animatable(0)
bindStyle(caption, { opacity: cap })
scroll.scrub(playable(cap).to(1, { paused: true, easing: linear }), { track: pin.track })

Triggers

import { playable } from '@underlying/core/playback'

// Enter/leave via IntersectionObserver; direction read from the entry geometry.
scroll.trigger(card, {
  onEnter: () => card.classList.add('in'),
  onLeaveBack: () => card.classList.remove('in'),
})

// Or drive a PlaybackHandle with toggleActions verbs
// [onEnter, onLeave, onEnterBack, onLeaveBack].
scroll.trigger(card, { toggle: clip, toggleActions: ['play', 'pause', 'resume', 'reverse'] })

scrollTo - spring the scroller to a target

// Spring the scroller to an absolute px position or an element brought into
// view. Returns a ScrollToHandle - { finished, cancel() }.
const handle = scroll.scrollTo(section, { offset: -80 })  // land 80px below the top
await handle.finished                                     // resolves on arrival

// align picks which '<elementEdge> <viewportEdge>' pair to bring together.
// Default 'start start' - the section's top lands at the viewport top.
scroll.scrollTo(section, { align: 'center center', offset: -44 })

// One follow() is shared across calls, so a scrollTo issued mid-flight RE-AIMS
// the spring already in motion - velocity conserved, no restart jolt. Pass your
// own spring, or immediate for a hard jump (always on under reduced motion).
scroll.scrollTo(1200, { spring: { stiffness: 120 } })
scroll.scrollTo(0, { immediate: true })

handle.cancel()  // freeze the scroller where it is; finished resolves

scrollTo() never aims past the reachable range, and the handle's finished never rejects - it resolves on arrival, or when the scroll is canceled or superseded by a later call.

Snap

// Opt-in momentum snap. On scroll-idle it springs to the nearest stop in the
// direction you were scrolling. CSS scroll-snap stays the recommended default.
scroll.snap({ to: 0.25 })                          // a stop every 25%
scroll.snap({ to: [0, 0.4, 1] })                   // explicit stops
scroll.snap({ to: (p, direction) => /* ... */ })   // custom resolver

Track - the raw primitive

// Everything above composes from this: normalized 0..1 over a range, deduped.
const t = scroll.track({ target: section })
t.progress()                 // read synchronously
t.on((p) => render(p))       // or subscribe

scroll.dispose()             // tears down the loop, observers, and every binding

More on the controller

// markers(): dev-only overlay for a range. Solid lines travel with the content
// (the element's enter/leave edges); dashed lines are the fixed scroller
// positions they fire against. When a solid meets a dashed of the same colour,
// that edge fires. Reads the DOM live - never ship it on.
const m = scroll.markers({ target: section, label: 'hero' })
m.dispose()

// progress(): whole-scroller progress 0..1 (scrollPos / maxScroll), read
// synchronously. Cheaper than a track() when you only need the page fraction.
scroll.progress()

// refresh(): re-measure every registered track. Call after a layout change the
// controller can't observe (a font swap, an image load, a panel that expands).
scroll.refresh()

SSR and tests

Nothing touches browser globals at import, and the DOM source is created lazily on the first builder call. For server rendering or a headless test, inject a deterministic source with createManualScrollSource() and drive it by hand - the same seam the core exposes with its manual driver.

import { createScroll, createManualScrollSource } from '@underlying/scroll'

const source = createManualScrollSource({ viewportSize: 800, maxScroll: 2000 })
const scroll = createScroll({ source })

source.setBox(section, { start: 1000, size: 600 }) // place an element (content coords)
source.emitScroll(500)                             // move the scroll, fire listeners
source.emitResize()                                // trigger a re-measure pass
scroll.progress()                                  // assert against a known number

The offset grammar

Ranges use the [element edge] [viewport edge] offset model. The default is ['start end', 'end start'] - progress 0 when the element's start edge meets the viewport's end, 1 when its end edge meets the viewport's start.

scroll.track({ target: el, range: ['start center', 'end center'] })

Reduced motion

Honored automatically (consulting the core's prefers-reduced-motion state): a locked scrub stays (it is user-driven and safe), a momentum scrub collapses to locked, parallax is disabled at its resting transform, and a momentum snap becomes an instant jump. The policy re-routes live when the preference changes.

License

MIT © underlyi.ng