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

@boflyeta/motio

v0.1.2

Published

Zero-dependency JavaScript animation library. One shared rAF ticker, transform/opacity-only presets, FLIP layout animation, and first-class reduced-motion support.

Readme

motio — a zero-dependency JavaScript animation library

motio demo

Live demo npm CI dependencies license

Zero runtime dependencies. One shared requestAnimationFrame loop, presets that touch only transform and opacity, FLIP for layout changes, interruptions that carry an element's real velocity into the animation that replaces them, and reduced motion handled rather than bolted on. 10.7 kB gzipped for all of it; 5.6 kB if you import three presets, because it tree-shakes.

Why I built this

Most animation libraries are either a 40 kB timeline engine or a one-file tweener that spawns a requestAnimationFrame loop per animation and animates whatever property you name. I wanted to find out what the middle actually costs to build: a shared scheduler, a DOM-agnostic interpolation primitive, and presets constrained to the two properties the compositor can animate without touching layout. The constraints turned out to be the interesting part — most of the design below falls out of them.

Quick start

npm install @boflyeta/motio
import { fadeIn, timeline, slideIn } from '@boflyeta/motio';

fadeIn('.card', { stagger: 80 });

No build step is required to use it. The package ships ES modules and the demo in this repo imports src/index.js directly from a static file server.

Examples

fadeIn / fadeOut — opacity only, staggered across a set.

fadeIn('.card', { stagger: 80, duration: 520 });
fadeOut('.toast', { duration: 300 });

slideIn — translate plus fade, from any of four directions. direction names where the element travels to, so 'up' starts below its resting position.

slideIn('.card', { direction: 'up', distance: 32, stagger: 60 });

scaleIn — scale and fade together.

scaleIn('.modal', { from: 0.9, easing: 'backOut' });

spring — a real damped oscillator. There is no duration option; it comes out of the physics and is readable afterwards.

const controls = spring('.badge', {
  from: { scale: 0.4, y: 20 },
  stiffness: 220,
  damping: 12,
});

controls.duration; // e.g. 812 — derived, not chosen

interruptionfrom: 'current' starts from wherever the element is, and velocity: 'inherit' starts at whatever speed it is already moving. Grab a card mid-slide and it redirects instead of stopping dead for a frame.

slideIn('.card', { direction: 'up', distance: 120, duration: 800 });

card.addEventListener('pointerdown', () => {
  spring('.card', { from: 'current', to: { y: 0 }, velocity: 'inherit' });
});

velocityOf(el, 'y') reports the same measurement directly, in pixels per second.

flipList — animate a reorder, filter, or insertion using only transforms.

flipList('.list li', {
  mutate: () => list.append(...shuffle([...list.children])),
  duration: 450,
  easing: 'quartOut',
});

drawSVG — animate stroke-dashoffset so the geometry never changes.

drawSVG('#signature path', { duration: 1200, stagger: 120 });

scrollScrub — bind an element's position in the viewport to a tween's seek.

scrollScrub('.parallax', { from: { y: 60 }, to: { y: -60 } });

textScramble — decode text from noise, with the real string on aria-label while it settles.

textScramble('.status', { text: 'Connected', duration: 900 });

splitText — per-character reveal. Generated spans are aria-hidden; the sentence stays on the container.

splitText('.headline', { stagger: 24, y: 18, easing: 'quartOut' });

magneticHover — pull an element toward the pointer, ease it back on leave.

const magnet = magneticHover('.cta', { strength: 0.4, scale: 1.06 });
magnet.cancel(); // unbinds and resets

particleBurst — canvas confetti from an element or a point.

button.addEventListener('click', () => particleBurst(button, { count: 80 }));

counter — an animated number that lands on an exact value.

counter('.stat', { to: 12480, duration: 1400, easing: 'expoOut' });
counter('.price', { to: 49.99, decimals: 2, prefix: '$' });

timeline — sequencing with relative offsets and stagger.

timeline()
  .add('.hero h1', slideIn, { direction: 'up', duration: 500 })
  .add('.hero p', fadeIn, { at: '-=300' })
  .add('.card', scaleIn, { stagger: 60, at: '-=200' });

tween — the primitive underneath all of it, with no DOM knowledge at all.

const controls = tween({
  from: { x: 0, scale: 0.8 },
  to: { x: 120, scale: 1 },
  easing: 'backOut',
  onUpdate: ({ x, scale }) => {
    el.style.transform = `translateX(${x}px) scale(${scale})`;
  },
});

await controls.finished;

How it works

One loop, not one per animation

Every running animation in motio is a subscriber to a single requestAnimationFrame loop. The obvious alternative — each animation calling requestAnimationFrame for itself — works fine for one animation and gets steadily worse as you add more, in ways that are easy to miss until a page is busy.

The first problem is time. Each independent loop computes its delta from its own start, so two animations that are supposed to be in lockstep drift apart by a fraction of a frame and stay drifted. A staggered list animated by twenty separate loops is twenty slightly different interpretations of "now"; driven by one loop, every subscriber receives the identical timestamp for a frame, and a stagger is exact by construction. The second problem is layout. If each animation independently reads geometry and then writes styles, the frame becomes a sequence of read-write-read-write, and every read after a write forces the browser to flush pending layout to answer honestly. One loop makes it possible to order that work; twenty loops make it impossible to even see. The third is that a loop nobody is using still costs something — a loop that keeps scheduling frames while nothing is animating quietly prevents the browser from going idle, which on a laptop is battery. motio's ticker stops scheduling entirely when the last subscriber leaves and restarts on the next subscribe, and activeCount() is exported so you can assert that a page is genuinely idle rather than hoping.

Two implementation details are worth naming because both caused real bugs during development. Subscriptions that arrive during a tick are queued and flushed before and after the iteration rather than applied immediately: mutating the subscriber set mid-iteration is not a crash, but its semantics are the wrong ones, since an entry added during a for...of is visited in that same pass. A tween that completes and starts a follow-up would otherwise run the follow-up's first frame with the finishing tween's delta, and a handler that resubscribes itself would spin forever. The subtler one: because a tick clears its scheduled-frame handle before running handlers, a handler that subscribed mid-tick would find the loop looking idle and queue a frame of its own, on top of the one the tick was already going to queue when it finished. Two pending frames means every subscriber runs twice per frame, and each doubled tick can double again. A test that expected fewer than forty ticks and saw forty-six is what caught it.

Only transform and opacity

Changing width, height, top, left, or margin changes an element's geometry, which invalidates layout — not just for that element but potentially for everything after it in flow — and then requires a repaint. Doing that inside an animation means paying layout and paint on every single frame, for every animating element. A list of fifty items animating top is fifty layout invalidations per frame, and layout is the expensive stage.

transform and opacity are different in kind, not degree. Neither affects the position or size of anything in the layout tree, so neither invalidates layout, and both can be applied by the compositor to an already-painted layer. That is why every preset here is restricted to those two properties: not as a stylistic rule but because it is the difference between an animation that survives a busy main thread and one that does not. will-change is used to promote an element before its animation starts so the first frame does not pay for the promotion — and released the moment the animation ends, because every promoted layer holds its own GPU texture and leaving the hint on a few dozen cards is a straightforward way to waste tens of megabytes of video memory. The release is reference-counted per property, since overlapping animations on one element are normal, and it is wired to the finished promise rather than to a completion callback, because finished settles on cancellation too and a cancelled animation must not leak a layer.

FLIP: faking layout animation with transforms

The constraint above raises an obvious objection: what about animations that are layout changes — a list reordering, a filter removing items, a card being inserted? The browser cannot transition an element between two positions it computed from layout, and animating top/left to fake it is exactly what we just ruled out.

FLIP — First, Last, Invert, Play — sidesteps the problem instead of solving it. Measure where everything is (First), let the layout change happen instantly, measure where everything ended up (Last), then apply to each element the transform that puts it visually back where it started (Invert) and animate that transform away (Play). The elements are at their final layout positions the entire time; only the transform lies about it, and transforms are free of layout. The illusion is exact, and measurable: reorder a list from [1,2,3] to [3,2,1], and immediately after the invert every element's bounding rect is identical to what it was before the mutation, even though the DOM order has already changed.

The read/write batching in flipList is not stylistic. Every getBoundingClientRect issued after a style write forces the browser to flush pending layout before it can answer. Measuring one element, transforming it, then measuring the next turns a single layout pass into one per element — precisely the cost FLIP exists to avoid. So all the reads happen, then the mutation, then all the reads again, then all the writes, in that order and no other.

Clamped frame deltas

Browsers throttle requestAnimationFrame in background tabs and stop it entirely in some cases. When the tab comes back, the first frame's timestamp can be seconds after the last one. Passed through unclamped, a delta of five seconds advances a 600ms tween well past its end, so every animation on the page completes in a single frame and the user returns to a page where everything silently finished — including the entrance animations they never saw. The same thing happens on the main thread without any tab switching, whenever a long synchronous task blocks the loop.

motio clamps the delta to 64ms, roughly four frames at 60Hz: long enough to absorb ordinary jank without visibly slowing anything down, short enough to cap a stall. The trade is that after a long pause an animation is behind wall-clock time — it resumes rather than catches up. That is the right trade, because nobody was watching the animation while the tab was hidden, and "resumes smoothly" is what a person expects to see. The test for this deliberately blocks the event loop for 150ms and asserts that no subscriber ever observes a delta above the cap.

seek, and why scroll scrubbing needs no clock

A tween is really two things bolted together: a mapping from progress to values, and a clock that advances progress. seek(progress) exposes the first without the second. It sets state and emits exactly one frame — it does not subscribe to the ticker, and it deliberately does not settle the finished promise, so a scrubbed animation can run to its end, back past its start, and forward again without ever being "done".

That separation is what makes scroll scrubbing fall out for free rather than needing a second system. Scroll is already a stream of progress values; it does not need a clock, and running one alongside it would mean two sources of truth fighting over the same element. So scrollScrub holds no ticker subscription at all; the test suite asserts that seek never subscribes, and scroll scrubbing is nothing but seek. An IntersectionObserver gates the scroll listener so off-screen elements cost nothing, because a page with fifty scrubbed elements otherwise runs fifty getBoundingClientRect calls on every scroll event, and that is how a scroll handler ends up owning the frame budget.

The same mechanism is what makes the timeline work. It does not run its children; it builds them paused and drives them with seek, while one master tween walks a clock across the sequence. So a timeline of forty staggered elements costs exactly one ticker subscription, and the entire sequence can be paused, reversed, scrubbed to 40%, or bound to scroll — because none of the children own any time of their own.

Interruption, and why velocity has to be measured

An animation library that only knows how to start from a declared value cannot handle being interrupted. Grab a card halfway through a slideIn and spring it somewhere else, and the spring begins where it was told to begin, at a velocity of zero. The card stops dead for one frame and sets off again. It reads as a glitch, because nothing physical changes direction by first coming to a halt.

Fixing it needs two things the library did not have. The first is a velocity, and a velocity can only be measured, not declared: it is a property of what the element is doing right now, not of what any animation intended. So every write that goes through setTransform or setOpacity also records a timestamped sample, and velocityOf(el, 'y') differentiates them. The measurement window matters more than it looks — differencing two consecutive frames divides a small position change by a jittery interval and produces a number that swings wildly, so the older sample is held until it is at least 32ms old and the velocity is measured across two or three frames. An exponential filter would smooth it too, but it would also lag, and lag is exactly what ruins a handoff: it reports the speed from a moment ago rather than the speed now.

The second is knowing who is allowed to write. transform is a single CSS property, so two animations both writing y fight for it every frame and the winner is whichever ticked last. Each preset now names the channels it writes, and claiming a channel displaces whoever held it. The granularity is per channel rather than per element on purpose: a magnetic hover writing x/y and a fade writing opacity are not in conflict and have to keep composing, which is the same reason setTransform merges through a shared store instead of overwriting.

Displacement is per element, too, and that falls out of the stagger design. One tween drives a whole staggered list, so cancelling it because a single card was grabbed would freeze the other 199 mid-flight. Instead the displaced animation stops writing that one element and carries on; when it loses its last element it cancels itself, which releases the ticker subscription and the will-change hints along with it.

What the spring then does with the measurement is the part the precomputed-curve design made awkward. A spring here is simulated once up front and used as an easing curve, which is what lets it be scrubbed, reversed, and placed in a timeline — but a curve is fixed at creation, and elements interrupted at different points of a stagger are moving at different speeds and so want different curves. Each element therefore gets its own simulation, memoized on the velocity rounded to a hundredth, which collapses a 200-item list back to a handful of simulations without changing a visible frame. They settle at different times; the tween runs for the longest and each element's curve is stretched across that shared span so it reaches its own resting point on schedule and stays pinned there. The single-tween cost model survives intact.

One honest limitation: the simulation moves a single scalar from 0 to 1, so it can carry exactly one velocity even when the element is moving on several channels. The channel with the furthest to travel is the one whose velocity is honoured, since it dominates what the motion looks like, and the rest ride the same curve.

Reduced motion

prefers-reduced-motion: reduce is a vestibular accessibility setting, not a taste preference; large translations and parallax can genuinely make people ill. Every animation respects it by default, and the handling is deliberate: rather than skipping the animation, the tween emits its final frame and settles immediately. Layout and final state stay correct, and only the movement is skipped — an element that fades in still ends up visible. The preference is read at play time rather than at creation, so an in-app toggle takes effect on the next animation, and setReducedMotion(true | false | null) exists because an OS setting is not always something a person can change on a shared or locked-down machine.

API reference

tween(options)

The DOM-agnostic primitive. Interpolates a number, or a flat object of numbers, and hands the result to onUpdate once per frame.

| Option | Type | Default | Notes | | --- | --- | --- | --- | | from | number \| Record<string, number> | — | Must match to's shape. | | to | number \| Record<string, number> | — | | | duration | number | 600 | Milliseconds, per iteration. | | delay | number | 0 | | | easing | EasingInput | 'cubicOut' | Function, name, or [x1,y1,x2,y2]. | | repeat | number | 0 | Extra iterations. Infinity loops. | | yoyo | boolean | false | Reverse each repeat instead of restarting. | | autoplay | boolean | true | | | respectReducedMotion | boolean | true | | | onUpdate | (value, progress, controls) => void | — | | | onStart / onRepeat / onComplete | (controls) => void | — | |

Returns chainable controls: play(), pause(), resume(), reverse(), restart(), cancel(), seek(progress), plus getters progress, isPlaying, duration, and a finished promise.

finished resolves on cancel rather than rejecting — cancelling is a normal event, not an error, and a rejected promise nobody awaited becomes an unhandled rejection. It settles once.

The per-frame path allocates nothing: object keys are snapshotted at creation and one output object is reused, so a caller that needs to keep a frame's value must copy it.

timeline(options)

.add(target, preset, options) — chainable. Accepts stagger and an at position: a number (absolute ms), '+=200' (gap), '-=200' (overlap), '<' (alongside the previous entry), or '>' / omitted (after it). A negative stagger ripples from the last element backwards.

Same control surface as tween, plus duration. Entries must be added before playback starts; autoplay is deferred by a microtask so chained .add() calls are measured first. Child durations are read from the children themselves, which is what lets a spring sit in a sequence and be placed correctly.

Presets

Every preset takes (target, options) where target is a selector, element, NodeList, array, or any iterable of those, and returns tween controls. All accept the shared options duration, delay, easing, stagger, autoplay, respectReducedMotion, repeat, yoyo, and the lifecycle callbacks.

| Preset | Notable options | | --- | --- | | fadeIn / fadeOut | from, to | | slideIn | direction, distance, fade | | scaleIn | from, to, fade, origin | | spring | from (transform parts or 'current'), to, stiffness, damping, mass, velocity (number or 'inherit') | | flipList | mutate (required), scale | | drawSVG | from, to (fractions), reverse | | scrollScrub | from, to, onUpdate, startOffset, endOffset, root | | textScramble | text, characters, overlap | | splitText | y, rotate, fade | | magneticHover | strength, maxDistance, smoothing, scale | | particleBurst | count, colors, spread, angle, velocity, gravity, drag, size | | counter | from, to, decimals, format, locale, prefix, suffix |

magneticHover is the one preset not built on tween, because a tween interpolates toward a value fixed when it starts and a magnetic element's target changes with every pointer move. It takes a ticker subscription directly and releases it when everything is at rest. It returns the same control surface; seek and reverse are documented no-ops.

Easing

25 easings — quad, cubic, quart, expo, circ, back, elastic, bounce in In, Out, and InOut, plus linear — each exported individually and available by name through easings. All return exactly 0 at t=0 and 1 at t=1, asserted with Object.is so a -0 fails.

  • cubicBezier(x1, y1, x2, y2) — matches the CSS signature, so a curve copied from devtools behaves identically. Solved with Newton-Raphson and a bisection fallback for curves whose derivative goes flat.
  • resolveEasing(value) — normalizes a function, a name, or four control points.

Ticker

subscribe(handler), unsubscribe(handler), activeCount(), frameTime(), stop(). Handlers receive (delta, timestamp). A handler that throws is removed from the loop and reported once, so one broken animation cannot stop the others or spam an error every frame.

Reduced motion

prefersReducedMotion(), setReducedMotion(true | false | null), onReducedMotionChange(handler) — the last returns an unsubscribe function and only fires when the effective value changes.

Motion state

velocityOf(el, channel) reports how fast a channel is currently moving, in that channel's units per second — pixels for x/y/z, degrees for rotations, multiplier per second for scale, opacity per second for opacity. It returns 0 for anything standing still, never animated, or stopped long enough that its last samples describe history rather than motion. forget(el) drops an element's samples and channel claims, which matters for a recycled list row that is about to represent different data and should not inherit the outgoing row's momentum.

Utilities

resolve(target) normalizes any accepted target to an array of elements. setTransform(el, parts) merges translate / rotate / scale / skew through a shared per-element store so two presets on one element compose instead of overwriting each other, and records each written channel for velocity tracking. setOpacity(el, value) does the same for opacity. getTransform, clearTransform, getOpacity, clearOpacity, claimWillChange, clearWillChange.

Browser support

Chrome and Edge 80+, Firefox 74+, Safari 13.1+ — anything with ES modules, optional chaining, and nullish coalescing. Nothing is transpiled and no polyfills are shipped.

Three presets need a little more: scrollScrub uses IntersectionObserver, magneticHover uses Pointer Events, and particleBurst uses the canvas 2D context. Everything else needs only requestAnimationFrame and inline styles.

Importing the package outside a browser is safe. window, document, matchMedia, and requestAnimationFrame are all guarded, so server rendering and test runners get a module that loads, reports prefersReducedMotion() === false, resolves selectors to [], and can still drive a tween by seek.

License

MIT — see LICENSE.