@sigx/motion
v0.1.0
Published
Animation primitives for SignalX on Lynx — spring/tween drivers built on AnimatedValue
Maintainers
Readme
@sigx/motion
Spring and tween animation drivers for SignalX on Lynx, built directly on SharedValue from @sigx/lynx. One customer of the cross-thread bridge alongside gestures and scroll.
The differentiator: animation progress is observable from the background thread for free. Mutate a SharedValue on MT via withSpring(sv, target) — the existing diff/publish bridge ships every frame to a BG-side sigx signal, so effect(() => sv.value) re-runs reactively. Neither framer-motion nor @lynx-js/motion offers this today; their value primitives are MT-only.
Installation
npm install @sigx/motionQuick start
The animation tick runs on MT — all withSpring / withTiming / animate calls must sit inside a 'main thread' context. The simplest path is main-thread-bindtap with a 'main thread' directive on the handler:
import { component, useSharedValue, useAnimatedStyle, useMainThreadRef } from '@sigx/lynx';
import { withSpring, withTiming } from '@sigx/motion';
const App = component(() => {
const x = useSharedValue(0);
const boxRef = useMainThreadRef(null);
useAnimatedStyle(boxRef, x, 'translateX', { factor: 1 });
return () => (
<view>
<view main-thread:ref={boxRef} style={{ width: 60, height: 60, backgroundColor: '#facc15' }} />
<view main-thread-bindtap={() => {
'main thread';
withSpring(x, 200, { stiffness: 200, damping: 20 });
}}>
<text>spring</text>
</view>
<view main-thread-bindtap={() => {
'main thread';
withTiming(x, 0, { duration: 0.4 });
}}>
<text>reset (tween)</text>
</view>
{/* BG-reactive — updates per animation frame for free */}
<text>x = {x.value.toFixed(0)}px</text>
</view>
);
});useAnimatedStyle(elRef, sv, 'translateX') is required for the bound element to actually move — withSpring only writes the SharedValue; the style binding registry (Phase 2.5) is what applies the transform.
API
animate(sv, target, options?)
Animate a SharedValue<number> toward target. Returns { stop, finished } controls. Marked 'main thread' — must be called from within a 'main thread' context.
const ctrl = animate(tx, 200, { type: 'spring', stiffness: 300, damping: 25 });
// later
ctrl.stop();
await ctrl.finished; // resolves on completion or cancelAnimateOptions extends both SpringOptions and TimingOptions:
interface AnimateOptions {
type?: 'spring' | 'tween'; // default: 'spring' if no duration is set
// spring physics
stiffness?: number; // default 100
damping?: number; // default 10
mass?: number; // default 1
velocity?: number; // initial velocity, units/sec, default 0
restSpeed?: number;
restDelta?: number;
// tween
duration?: number; // seconds, default 0.3
}Default mode is spring. Pass { type: 'tween' } or { duration } to get a tween (always uses easeOut).
withSpring(sv, target, options?) / withTiming(sv, target, options?)
Promise-returning sugar. Use these when you don't need cancellation — they call animate() with the type pinned and return .finished.
await withSpring(tx, 200, { stiffness: 200, damping: 20 });
await withTiming(tx, 0, { duration: 0.4 });Composition
Sigx-native idioms cover the patterns motion's onUpdate / onComplete callbacks would handle in a callback-shaped library — and they integrate cleanly with sigx's reactivity:
Per-frame side effects (replaces onUpdate):
withSpring(tx, 200);
// BG side — fires reactively per frame via the SharedValue bridge, zero extra wiring:
effect(() => updateUI(tx.value));Run code on completion (replaces onComplete):
'main thread';
await withSpring(sv, 200);
runOnBackground(() => doNext())();Concurrent animations:
'main thread';
await Promise.all([
withSpring(x, 200),
withSpring(y, 100),
withTiming(opacity, 1, { duration: 0.3 }),
]);Sequential / chained:
'main thread';
await withSpring(x, 200);
await withTiming(opacity, 0, { duration: 0.2 });Mass cancellation (drop down to animate() for the controls handle):
'main thread';
const ctrls = [
animate(a, 100),
animate(b, 200),
animate(c, 50),
];
// later: stop them all
ctrls.forEach((c) => c.stop());The __FlushElementTree() calls each tick performs are coalesced via a microtask flag, so N concurrent animations produce one flush per frame, not N. Same pattern upstream's MTElementWrapper.flushElementTree uses.
spring(options) — solver factory
Underneath animate(). Exposed for advanced use (driving non-SharedValue values, mocking). Returns a { next(elapsedMs): { done, value } } solver. See src/spring.ts.
Easings
Built-in: linear, easeIn, easeOut, easeInOut, circIn, circOut, circInOut, backIn, backOut, backInOut, anticipate. Plus cubicBezier(x1, y1, x2, y2) and the mirrorEasing / reverseEasing modifiers.
Tween animations always use the built-in easeOut. Custom easing functions can't be passed directly: function references don't survive the worklet _c capture across the MT/BG bridge — they'd arrive on MT as undefined. If you need a non-built-in curve, either pick a different built-in or wait for registerEasing(name, fn) (filed as a follow-up in NEXT-STEPS.md).
Cancellation behavior
Each SharedValue has at most one in-flight animation. Calling animate() (or withSpring/withTiming) on a value that already has an animation in flight cancels the previous one before starting the new one. The previous animation's .finished promise still resolves (cancellation is not an error).
This matches motion's behavior and avoids the race where two ticks fight over the same value.
Tick scheduling
Animations tick via requestAnimationFrame. Lynx's worklet runtime installs globalThis.requestAnimationFrame on MT (Lynx SDK ≥ 2.16). Where rAF isn't available — older SDKs, Node test environments — @sigx/motion falls back to setTimeout(tick, 16) (≈60 fps).
Limitations / out of scope
- Scalar
SharedValue<number>only for v0.1. 2D values ({x, y}) need parallelanimate()calls (one per axis) for now. - No velocity-carry across animations. When a new
animate()cancels an in-flight one, the new one starts at velocity 0. Motion'sMotionValuetracks velocity to support seamless gesture-to-spring handoff; sigx'sSharedValuedoesn't yet (would require extendingSharedValueState<T>from{ value }to{ value, velocity }). Add iff a real use case needs it. - No duration→physics resolution (motion's
findSpring). Spring options are physics-only (stiffness/damping/mass).{ duration, bounce }not supported. Add iff users want it. - No keyframes / sequences / stagger. Spring + tween cover gesture-driven UI. The richer orchestration surface (variants, layout, presence, scroll-driven) lives in upstream
@lynx-js/motionif you need it.
Why we ported instead of using upstream
@lynx-js/motion ships two entry points: . (full, ~200 LOC of thin wrappers around framer-motion + motion-dom) and ./mini (lean, ~250 LOC self-contained). Phase 2.6 spiked hosting motion-mini directly on top of sigx via a shim and reverted because the bring-up required eight cumulative pipeline concessions, two with silent-fragility risk (semantic-approximation useEffect/useMemo shims and an rspack sideEffects override of motion's metadata).
Porting motion-mini's algorithmic content (spring solver, easings, animate orchestration — ~250 LOC of Apache-2.0 math) was the cleaner path: faithful semantics we own, a sigx-shaped API, and the BG-observable SharedValue integration becomes first-class. See PHASE-2.6-LAYERING-PLAN.md "What we tried and reverted" for the spike write-up and PHASE-2.7-MOTION-PLAN.md "Why mini, not full motion" for the design rationale.
Attribution
Spring solver and easing functions are ported from @lynx-js/motion v0.0.3, motion-dom v12.23.12, and motion-utils v12.23.6 — all Apache-2.0. The cubic bezier code is in turn modified from Gaëtan Renaudeau's bezier-easing (MIT). Full attribution in THIRD_PARTY_NOTICES.md.
License
MIT (sigx adaptation). Ported portions remain under their upstream licenses (Apache-2.0 and MIT, see attribution above).
