@worsethan/airemotion
v0.2.2
Published
Spring-physics animation library — damped harmonic oscillator, gesture velocity binding, and FLIP layouts. A lighter alternative to Framer Motion focused on Apple-native motion.
Downloads
109
Maintainers
Readme
@worsethan/airemotion
Spring-physics animation for React — a lighter, more accurate alternative to Framer Motion focused on Apple-native motion.
Install
npm install @worsethan/airemotionOverview
@worsethan/airemotion uses a damped harmonic oscillator (F = −kx − bv) for all animations — no duration, no easing curves. Every animation is interruptible: changing the target mid-flight preserves the current position and velocity so motion never pops or restarts abruptly.
Quick start
import { useSpringValue, useDrag } from '@worsethan/airemotion/react';
function DraggableCard() {
const { value: y, setTarget, jump } = useSpringValue(0, { preset: 'snappy' });
const { onPointerDown } = useDrag({
axis: 'y',
onDrag: ({ deltaY }) => jump(deltaY), // 1:1 finger tracking
onDragEnd: ({ velocityY }) => setTarget(0, velocityY), // spring back with momentum
});
return (
<div onPointerDown={onPointerDown} style={{ transform: `translateY(${y}px)` }}>
Drag me
</div>
);
}Hooks
useSpringValue(initial, options)
Manual spring control — you set the target imperatively.
const { value, velocity, setTarget, jump, stop } = useSpringValue(0, {
preset: 'snappy',
});
setTarget(100); // animate to 100
setTarget(0, 800); // animate to 0, seeded with 800 px/s velocity
jump(50); // instantly reposition (no animation)
stop(); // freeze at current positionuseSpring(options)
Declarative — re-animates whenever to changes.
const x = useSpring({ from: 0, to: isOpen ? 200 : 0, preset: 'smooth' });useSprings(targets, options)
Animate multiple named values simultaneously.
const { x, opacity } = useSprings(
{ x: isOpen ? 100 : 0, opacity: isOpen ? 1 : 0 },
{ preset: 'smooth' }
);useDrag(options)
Tracks pointer/touch velocity for gesture-to-spring binding. Velocities are in px/s — feed directly into setTarget(target, velocity).
const { onPointerDown } = useDrag({
axis: 'y',
onDragStart: (state) => {},
onDrag: (state) => {},
onDragEnd: ({ velocityY }) => setTarget(0, velocityY),
threshold: 8, // px before drag activates
});usePressable(options)
Apple-style press feedback: scale down on touch, spring back on release.
const { scale, isPressed, handlers } = usePressable({
pressedScale: 0.94,
preset: 'snappy',
onPress: () => console.log('pressed'),
});
<div {...handlers} style={{ transform: `scale(${scale})` }} />useBottomSheet(options)
iOS-style draggable, dismissible sheet with rubber-band resistance and velocity-aware dismiss detection.
const { translateY, handleProps, openSheet, closeSheet } = useBottomSheet({
height: 400,
open: isOpen,
onOpenChange: setIsOpen,
preset: 'snappy',
});<Motion> component
Familiar Framer Motion-style API, backed by real spring physics.
import { Motion } from '@worsethan/airemotion/react';
<Motion
animate={{ x: isOpen ? 100 : 0, opacity: isOpen ? 1 : 0 }}
initial={{ x: 0, opacity: 0 }}
transition={{ type: 'spring', preset: 'snappy' }}
>
<Card />
</Motion>Exit animations with <AnimatePresence>
Wrap conditionally-rendered <Motion> elements in <AnimatePresence> so they animate out (exit) before being removed from the DOM, instead of vanishing instantly.
import { AnimatePresence, Motion } from '@worsethan/airemotion/react';
<AnimatePresence>
{isOpen && (
<Motion
key="modal"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
>
<Modal />
</Motion>
)}
</AnimatePresence>Each child needs a stable key. <AnimatePresence> keeps a removed child mounted just long enough to run its exit animation, then unmounts it.
Fade presets
Ready-made initial/animate/exit triples for the common fade variants — spread directly onto <Motion>. Each is also callable with a distance/scale override, e.g. fadeUp(40).
import { Motion, fadeIn, fadeUp, fadeDown, fadeLeft, fadeRight, fadeScale } from '@worsethan/airemotion/react';
<Motion {...fadeUp}><Card /></Motion> {/* fades in, sliding up 16px */}
<Motion {...fadeUp(40)}><Card /></Motion> {/* same, but 40px */}
<Motion {...fadeScale()}><Card /></Motion> {/* fades in while scaling from 0.9 → 1 */}fadeIn, fadeUp, fadeDown, fadeLeft, fadeRight, fadeScale are all available.
Staggered animations with <Stagger>
Wrap a list of <Motion> children in <Stagger> to cascade their entrance (and, via transition.delay, exit) with an incrementing delay.
import { AnimatePresence, Stagger, Motion, fadeUp } from '@worsethan/airemotion/react';
<AnimatePresence>
{isOpen && (
<Stagger key="list" staggerDelay={60}>
{items.map((item) => (
<Motion key={item.id} {...fadeUp(12)}>{item.label}</Motion>
))}
</Stagger>
)}
</AnimatePresence><Stagger> clones its children and injects transition.delay = index * staggerDelay (plus an optional delayChildren base offset), preserving each child's own preset/config. reverse staggers from the last child instead of the first.
<Slider> — swipeable carousel
A drag-and-snap carousel. Release momentum carries into the page-snap spring, so a fast flick keeps going.
import { Slider } from '@worsethan/airemotion/react';
<Slider>
<Slide1 />
<Slide2 />
<Slide3 />
</Slider>Works uncontrolled, or controlled via index + onIndexChange. showDots (default true) renders tap-to-jump page indicators.
<StackedSheet> — iOS notification-stack
A collapsed pile of peeking cards that expands into a scrollable list on tap or drag-up, and collapses back on backdrop tap or drag-down — the iOS notification-grouping interaction.
import { StackedSheet } from '@worsethan/airemotion/react';
<StackedSheet peekCount={3}>
{notifications.map((n) => (
<NotificationCard key={n.id} {...n} />
))}
</StackedSheet>Works uncontrolled, or controlled via expanded + onExpandedChange.
Shared-element transitions with layoutId
Give two <Motion> elements the same layoutId and the one that mounts (or moves) second will spring from the first one's last screen position/size — e.g. a thumbnail expanding into a full-screen view.
// list item
<Motion layoutId={`card-${id}`}><Thumbnail /></Motion>
// full-screen view, mounted after the list item unmounts
<Motion layoutId={`card-${id}`}><FullCard /></Motion>Components
import { Pressable, BottomSheet } from '@worsethan/airemotion/react';
<Pressable onPress={() => {}} pressedScale={0.94}>
<Button />
</Pressable>
<BottomSheet open={open} onOpenChange={setOpen} height={360}>
<SheetContent />
</BottomSheet>Spring presets
| Preset | Stiffness | Damping | Feel |
|-----------|-----------|---------|-----------------------------|
| snappy | 400 | 30 | Fast, responsive — icon tap |
| bouncy | 300 | 15 | Energetic, overshoots |
| smooth | 200 | 28 | Balanced, no overshoot |
| gentle | 80 | 14 | Slow, floating |
| stiff | 700 | 50 | Near-instant, system chrome |
Custom config:
useSpringValue(0, {
config: { mass: 1, stiffness: 350, damping: 25 },
});Framework-agnostic core
The physics engine has no React dependency — use it in Vue, Svelte, or vanilla JS:
import { SpringAnimation, SPRING_PRESETS } from '@worsethan/airemotion/core';
const anim = new SpringAnimation({
from: 0,
to: 100,
preset: 'snappy',
onUpdate: (value) => (el.style.transform = `translateX(${value}px)`),
onComplete: () => console.log('settled'),
});
anim.start();
// Interrupt mid-flight
anim.setTarget(50);
// Retarget with gesture velocity
anim.setState(currentPos, gestureVelocity);
anim.setTarget(0);Import paths
import { ... } from '@worsethan/airemotion' // everything
import { ... } from '@worsethan/airemotion/react' // React bindings only
import { ... } from '@worsethan/airemotion/core' // physics core only (no React)License
MIT
