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/core

v1.2.1

Published

Physics-first web animation: interruptible springs with velocity conservation, inertia, colors, units, keyframes, accessible by default, zero dependencies, ~12 kB gzip for animate() (tree-shakes to ~3.5 kB for primitives).

Readme

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

Why

  • Physical by default. No hardcoded durations, no cubic-bezier guesswork. Springs, inertia and decay drive the motion; duration/easing exists as an escape hatch, not the other way around.
  • Interruptible by design. Every animated value knows its position and its velocity. Retargeting mid-flight continues the motion seamlessly - never a jump, never a restart.
  • Accessible natively. prefers-reduced-motion is respected with zero configuration (skip or fade, configurable), reacts to mid-session OS changes, and supports app-level overrides.
  • One rAF loop. Every animation batches into a single scheduler tick - simulations first, style writes after. WAAPI is used opportunistically (eligible duration tweens ride the compositor) and hands control back losslessly on interruption.
  • Deterministic. Fixed-timestep simulation (1/120 s): the same inputs produce the same trajectory at 60, 120 or 144 Hz.

Install

npm install @underlying/core

Bundle size

All numbers are measured gzip (level 9) on the built ESM bundle, tree-shaken:

| Import | gzip | What it pulls in | | --- | --- | --- | | Full surface (import * as core) | 10.07 kB | everything the package entry exports | | Primitives only (animatable + bindStyle + physics) | 3.04 kB | no value model - the registry and CSS parsers stay tree-shaken | | animate() import graph | 9.04 kB | animate() deliberately pulls the registry and the four built-in value parsers | | @underlying/core/playback | 5.36 kB | net cost on top of a core you already ship (it imports the shared core chunk) |

The split is real: if you only ever touch animatable/bindStyle/simulate, you ship none of the value model. Reaching for animate() opts you into the registry and the length/color/complex/number parsers.

Quick start

import { animatable, bindStyle } from '@underlying/core'

const x = animatable(0)
bindStyle(element, { x })

x.spring(300)                  // physical motion toward 300
x.spring(0)                    // retarget mid-flight: velocity is conserved
x.velocity()                   // readable at any time, in units/s

// Gesture handoff: release a drag into a spring or an inertial glide
x.spring(0, { velocity: gestureVelocity })
x.decay({ velocity: gestureVelocity, min: 0, max: 800 })  // rubber-band edges

// Escape hatch when you really need a duration
x.to(300, { duration: 300, easing: (t) => t })

Every animation method returns a handle:

const handle = x.spring(100)
await handle.finished          // resolves at rest or on interruption - never rejects
handle.stop()                  // freeze in place; position AND velocity stay readable

Element-level API

import { animate, stagger, chain } from '@underlying/core'

animate(el, { x: 100, opacity: 0 })              // springs by default
animate(el, { x: 100 }, { duration: 400 })       // tween - delegated to WAAPI when free

chain([
  () => animate(title, { opacity: 1 }),
  () => stagger(items, (item) => animate(item, { y: 0 }), 60),
])

Repeated animate() calls on the same element retarget the same underlying values - interruption with velocity conservation, not parallel animations.

Every handle is awaitable - await animate(...) (or await handle) resolves when it settles or is interrupted (it delegates to finished and never rejects):

await animate(el, { opacity: 0 })
el.remove()

Playback (opt-in)

Springs stay live; tweens are seekable. @underlying/core/playback is a separate bundle entry (5.36 kB gzip net on top of the core) that adds pause / timeScale / reverse / seek, a bake() bridge that samples a spring into a scrubbable clip, follow() for momentum scrub, and sequence() - the live, interruptible twin of @underlying/timeline (a cascade you grab mid-flight, not a baked table you scrub). The same entry exports animatePlayback() (the playback-aware twin of animate()) and timeScope() (a child scheduler whose pause() / timeScale dilate every animation inside it - the time-dilation seam the whole layer rides on).

import { playable, follow, sequence } from '@underlying/core/playback'

const motion = playable(value).spring(300)
motion.pause().timeScale(0.25)            // slow-mo, identical trajectory shape
if (motion.bake()) motion.progress(0.5)   // a live spring, now scrubbable

const lag = follow(0)                     // a value that springs toward a moving target
onScroll((y) => lag.target(y))            // momentum scrub, conserved velocity

sequence().spring(a, 1).spring(b, 1, { overlap: 80 }).play()  // live cascade, interruptible

A PlaybackHandle also answers the everyday playhead questions:

const h = playable(v).to(300, { duration: 400, repeat: 2 })
h.isActive()       // still running (not finished/paused)?
h.iteration()      // 0-based, advances each repeat
h.totalProgress()  // 0..1 across the WHOLE run (all iterations + delays)
h.startTime()      // the initial delay (ms); endTime() = when the whole run ends
h.restart()        // replay from the top

Transform channels

The transform is driven as separate live channels: x, y, scale, rotate, plus perspective, rotateX/Y/Z, skewX/Y, scaleX/Y, and the pivot originX / originY (transform-origin). Each is its own interruptible spring, so a 3D card flip retargets mid-flight like any value. perspective is the perspective() function on the element itself - set it rather than spring it from nothing; and transform-style: preserve-3d is a CSS mode you put on the scene.

animate(card, { rotateY: 180 })                  // a real spring, interruptible
animate(panel, { rotateX: 20, skewX: 6, scaleX: 1.2 })
animate(square, { originX: 0, originY: 0 })       // animate the pivot

Any CSS property, colors, units

Beyond those transform/opacity channels, animate() accepts any CSS property (and custom properties) as a string or number. Values decompose into scalar channels - each an interruptible spring - and reformat to a CSS string every frame.

animate(panel, { width: '50%' })                 // computed px -> % : one measurement, velocity rebased
animate(button, { backgroundColor: '#10b981' })  // hex/rgb()/hsl()/named, mixed in gamma-2.0 space
animate(button, { outlineColor: 'rebeccapurple' })
animate(card, { boxShadow: '0px 12px 32px rgba(0, 0, 0, 0.35)' })  // composite: numbers + colors
animate(meter, { '--progress': 0.8 })            // custom property

Units convert by measuring once at the start (240px retargeted to 50% rebases position and velocity). Unconvertible or unparseable values snap to the target with a one-time console warning, never a throw.

Filters, attributes, autoAlpha

filter() is a typed builder for the CSS filter string, emitted in a fixed canonical order so two results interpolate cleanly:

import { animate, filter } from '@underlying/core'

animate(hero, { filter: filter({ blur: 8, brightness: 1.1 }) })  // -> 'blur(8px) brightness(1.1)'

An attr: key animates an element/SVG attribute through setAttribute (reading the start via getAttribute) - the same springs, keyframes, relatives, and expressive stops as any property. animate() accepts SVG elements too:

animate(circle, { 'attr:r': [10, 40], 'attr:fill': '#ff0055', opacity: 1 }) // attr + style, one handle
animate(svg, { 'attr:viewBox': '0 0 50 50' })
animate(circle, { 'attr:r': '+=15' })                                        // relative to the live attribute

On an SVG element the transform channels (x, scale, rotate, ...) still work, but they write CSS transforms (their own transform-box/origin); for SVG geometry prefer attr: (attr:cx, attr:r, attr:points) or a motionPath.

autoAlpha animates opacity and toggles visibility: hidden at 0, so a fully transparent element also drops out of hit-testing. It runs on the JS path (the visibility toggle rides the opacity write):

animate(dialog, { autoAlpha: 0 })                       // fade out AND become non-interactive
fromTo(toast, { autoAlpha: 0 }, { autoAlpha: 1 })       // reveal from hidden

Keyframes

animate(badge, { x: [null, 120, 80] })                  // null = from the current value
animate(badge, { x: [0, 120, 80] }, { duration: 600 })  // explicit 0 = teleport start

Without a duration the waypoints are chained springs (settle at each, then retarget); with a duration they become an evenly-split piecewise tween that rides the compositor (WAAPI multi-keyframe) when eligible.

Expressive keyframes - position, per-segment easing, holds

A keyframe entry can be a { value, at, ease } stop instead of a bare value. at places the waypoint at a fraction (0..1) of the total duration; ease sets the easing of the segment reaching it; a null mid-array HOLDS the previous value (a dwell). These are tween-mode features (they need a duration); expressive keyframes run on the JS path rather than the compositor. An at on the last waypoint reaches the final value at that fraction and holds it to the end (a settle-and-dwell, WAAPI-consistent).

import { animate } from '@underlying/core'
import { easeOutBack } from '@underlying/utils'

animate(el, {
  x: [0, { value: 120, at: 0.25, ease: easeOutBack }, 60], // snap out fast, ease the rest
  opacity: [0, 1, 1, 0],                                     // different length is fine
}, { duration: 800 })

animate(box, { x: [0, 100, null, 0] }, { duration: 600 })   // rise, hold, fall

Entrances - from() / fromTo()

Animate into a resting state instead of out of one, with no manual setStyle first. The from-state is parked synchronously in the call frame (no flash), then the element springs to its target:

import { from, fromTo } from '@underlying/core'

from(card, { y: 24, opacity: 0 })                 // rises into its NATURAL resting state
fromTo(card, { y: 24, opacity: 0 }, { y: 0, opacity: 1 }) // explicit from and to

from() captures each element's current value per key as the to-state, so a set returns to its own resting values. fromTo() is sugar over animate(target, to, { from }) - the to keeps full target parity (keyframes, relative '+=', per-target functions); the from-state is one value per key.

Both share every animate() option. With a stagger delay (or staggerDelay()) every element is parked at its from-state immediately and holds it through its own delay, like a real entrance:

from('.card', { y: 24, opacity: 0 }, { delay: staggerDelay({ each: 60 }) })

Under reduced motion the from-set is skipped entirely: the element settles at its target, never stranded at the from-state.

Teleport & gesture handoff

import { setStyle, releaseStyle } from '@underlying/core'

const onDrag = (px: number) => setStyle(panel, { width: `${px}px` })       // coherent teleport
const onRelease = (px: number, v: number) => {
  setStyle(panel, { width: `${px}px` }, { velocity: v })                   // seed gesture momentum
  animate(panel, { width: '50%' })                                         // spring inherits it
}

releaseStyle(panel)  // forget the element: dispose channels, remove our inline styles, start cold next time

Custom physics

Spring, decay, and tween are presets over one primitive: a Simulation - an acceleration plus a rest condition. value.simulate() runs anything on the same fixed-timestep clock, from the current position and velocity, fully interruptible.

import { animatable } from '@underlying/core'

// gravity plus a damped floor: an accelerating fall, a decaying bounce
const bounce = {
  acceleration: (pos, vel) => (pos > floor ? G - K * (pos - floor) - C * vel : G),
  rest: (pos, vel) => (pos >= floor && Math.abs(vel) < 3 ? floor : null),
}
const y = animatable(0)
y.simulate(bounce)   // the same engine behind spring/decay/to

For physics that is not bound to an Animatable - a canvas particle system, confetti, a 2D field - reach for the @underlying/core/physics subpath. It is the bring-your-own-loop seam: stepSimulation(sim, state, dt) is the single semi-implicit Euler step the whole engine runs on, and SIMULATION_TIMESTEP_S (1/120 s) is the fixed timestep to drive it with.

import { stepSimulation, SIMULATION_TIMESTEP_S, type Simulation } from '@underlying/core/physics'

let state = { position: 0, velocity: 0 }
state = stepSimulation(simulation, state, SIMULATION_TIMESTEP_S)  // your loop, your render

Easing

Springs need no easing, but the duration escape hatch ({ duration }) takes an easing. Pass a function, or a named ease by string. easeInCubic, easeOutCubic, easeInOutCubic, and linear are exported straight from @underlying/core. The named ease families (power2.out, elastic.out(1, 0.3), steps(...), ...) live in @underlying/utils; a one-time import '@underlying/utils/register' registers them so the string forms resolve. Wire your own with registerEasing(name, factory), or resolve a string yourself with resolveEasing(input).

import { animate, easeOutCubic } from '@underlying/core'
import '@underlying/utils/register'  // registers the named ease families (once)

animate(box, { x: 300 }, { duration: 400, easing: easeOutCubic })
animate(box, { x: 0 }, { duration: 400, easing: 'elastic.out(1, 0.3)' })

Custom value types

The registry is the extension point for the package family (and your app):

import { registerValueType, numberValueType } from '@underlying/core'

registerValueType(['--progress'], numberValueType)  // explicit, never at import time

Reduced motion

Active with zero configuration: under prefers-reduced-motion, animations fast-forward to their exact rest state (even bounded inertia lands on its edge).

import { setReducedMotionBehavior, setReducedMotionOverride } from '@underlying/core'

setReducedMotionBehavior('fade')      // movement snaps, opacity still animates
setReducedMotionOverride(true)        // app-level toggle (null = follow the OS)
x.spring(100, { reducedMotion: 'allow' })  // essential, gesture-driven motion

License

MIT © underlyi.ng