@tweens/tweens
v0.3.0
Published
Physics-accurate spring animation engine for the DOM. Analytical solver, interruptible, Promise-based.
Maintainers
Readme
tweens
Physics-accurate spring animation engine for the DOM. Analytical solver — no numerical drift, no fixed durations, interruptible at any point.
tweens.dev · npm install @tweens/tweens
Quick start
import { spring } from 'tweens'
const el = document.getElementById('card')
// Move right 100px with a bouncy spring
await spring(el, { x: 100 }, 'bouncy')
// Scale and fade in one call
await spring(el, { scale: 1.1, opacity: 0.5 }, 'snappy')
// Custom physics
spring(el, { x: 0 }, { stiffness: 300, damping: 20 })
// Design by feel — no physics required
spring(el, { y: -40 }, { duration: 0.4, bounce: 0.3 })API
spring(el, props, options?)
Animates one or more properties to target values. Returns a Promise<void> that resolves when the spring settles.
spring(
el: HTMLElement,
props: SpringProps,
options?: SpringConfig | SpringPreset,
): Promise<void>Multiple props animate in parallel and the promise resolves when all settle.
// Sequence two animations
await spring(el, { x: 100 })
await spring(el, { y: 50, scale: 1.2 })enter(el, fromProps, options?)
Animates FROM values back to where the element currently is. Useful for entrance animations.
enter(
el: HTMLElement,
fromProps: SpringProps,
options?: SpringConfig | SpringPreset,
): Promise<void>import { enter } from 'tweens'
// Element slides and fades in from below
enter(el, { y: 24, opacity: 0 }, 'snappy')tween(el, fromProps, toProps, options?)
Animates FROM one set of values TO another.
tween(
el: HTMLElement,
fromProps: SpringProps,
toProps: SpringProps,
options?: SpringConfig | SpringPreset,
): Promise<void>import { tween } from 'tweens'
tween(el, { x: -200, opacity: 0 }, { x: 0, opacity: 1 }, 'gentle')cascade(els, props, options?, interval?)
Animates a list of elements with a time offset between each. Returns a Promise<void> that resolves when all elements settle.
cascade(
els: NodeListOf<Element> | HTMLElement[],
props: SpringProps,
options?: SpringConfig | SpringPreset,
interval?: number, // seconds between each element, default 0.05
): Promise<void>import { cascade } from 'tweens'
const items = document.querySelectorAll('.list-item')
cascade(items, { y: 0, opacity: 1 }, 'snappy', 0.06)snap(el, props)
Instantly sets properties with no animation. Cancels any running animation for those props.
snap(
el: HTMLElement,
props: SpringProps,
): voidimport { snap } from 'tweens'
snap(el, { x: 0, opacity: 1 })stop(el, prop?)
Cancels animations on an element. Pass a prop to cancel just that property, or omit to cancel all.
stop(
el: HTMLElement,
prop?: AnimatableProp,
): voidimport { stop } from 'tweens'
stop(el) // cancel everything
stop(el, 'x') // cancel only xSpringProps
Any combination of these properties:
| Prop | Unit | Default |
| --------- | ------- | ------- |
| x | px | 0 |
| y | px | 0 |
| scale | – | 1 |
| rotate | degrees | 0 |
| opacity | 0–1 | 1 |
| blur | px | 0 |
Multiple transform props compose correctly — spring(el, { x: 100 }) followed by spring(el, { scale: 1.2 }) won't clobber each other.
SpringConfig
Two ways to configure a spring:
Physics params
spring(el, { x: 100 }, {
stiffness: 300, // spring constant (default: 170)
damping: 20, // friction (default: 26)
mass: 1, // inertia (default: 1)
})The damping ratio ζ = damping / (2 * √(stiffness * mass)) determines the character:
ζ < 1— underdamped, overshoots and oscillatesζ = 1— critically damped, fastest settle with zero overshootζ > 1— overdamped, slow creep toward target
Duration + bounce
More intuitive — design by feel without thinking about physics:
spring(el, { x: 100 }, {
duration: 0.4, // seconds until effectively settled
bounce: 0.3, // 0 = no overshoot, 1 = maximum oscillation
})Orchestration
spring(el, props, {
delay: 0.2, // seconds before animation starts
repeat: 3, // extra plays after the first; -1 = infinite
yoyo: true, // reverse direction on every other repeat
onStart: () => {},
onUpdate: (value) => {}, // fires every frame
onComplete: () => {},
})Relative values
spring(el, { x: '+=100' }) // add 100 to current x
spring(el, { x: '-=50' }) // subtract 50 from current xSpringPreset
| Preset | ζ | Character |
| --------- | ----- | -------------------------------------- |
| bouncy | 0.612 | Lively, ~9% overshoot |
| snappy | 0.850 | Decisive, <1% overshoot |
| gentle | 0.783 | Slow and smooth |
| stiff | 1.02 | Overdamped — snaps to target instantly |
spring(el, { scale: 1.1 }, 'snappy')Interruptibility
If you call spring() on an element that's already animating, the new spring picks up the current position and velocity — no snap, no pop:
// User hovers → scale up
el.addEventListener('mouseenter', () => spring(el, { scale: 1.05 }, 'snappy'))
// User leaves mid-animation → continues from wherever it is
el.addEventListener('mouseleave', () => spring(el, { scale: 1 }, 'snappy'))Accessibility
Respects prefers-reduced-motion automatically. When the user has reduced motion enabled, spring() writes the target value instantly and resolves the Promise — no rAF loop, no animation.
How it works
The solver uses the closed-form solution to the damped harmonic oscillator (mx'' + bx' + kx = 0). At every animation frame it calculates exact position and velocity from the analytical formula — not Euler stepping. This means:
- No accumulated numerical error over long animations
- Exact velocity is available at any time for interruptibility
- No fixed duration — the spring runs until it physically settles
Three cases are handled: underdamped (oscillates), critically damped (fastest settle), and overdamped (slow creep).
Performance
Only transform and opacity (and filter for blur) are animated — the GPU-composited properties. will-change is set automatically on animation start and cleared when all springs on the element settle.
