@zakkster/lite-signal-spring
v1.0.0
Published
Reactive spring values for @zakkster/lite-signal: springSignal(target, config) springs toward a (reactive) target, velocity-preserved on re-target, driven on the lite-raf loop with a fixed sub-step for stability. Zero-GC; rests to zero cost.
Maintainers
Readme
@zakkster/lite-signal-spring
Reactive spring values for @zakkster/lite-signal.
springSignal(target, config) returns a read accessor whose value springs toward
target. If target is itself a signal, the spring re-targets when it changes,
preserving velocity — so redirecting mid-flight is smooth, not a jump. While the
spring is in motion it ticks the value once per frame on the
lite-raf loop; the instant it
reaches rest it snaps exactly to target and unsubscribes — a settled spring costs
nothing. The integrator is your own lite-spring;
this is the reactive bridge, in the lite-signal-gsap / lite-signal-dom family.
npm install @zakkster/lite-signal-springDepends on
@zakkster/lite-spring. Peers:@zakkster/lite-signaland@zakkster/lite-raf. ESM only. MIT.
Use
import { signal, effect } from "@zakkster/lite-signal";
import { startFrames } from "@zakkster/lite-raf";
import { springSignal } from "@zakkster/lite-signal-spring";
startFrames(); // start the loop once, at app init
const x = springSignal(0, { preset: "bouncy" });
effect(() => { box.style.transform = `translateX(${x()}px)`; });
x.setTarget(300); // box springs to 300, overshoots, settlesPoint a spring at a signal and it follows that signal, springing:
const targetX = signal(0);
const x = springSignal(targetX, { stiffness: 210, damping: 20 });
// anywhere you move the target, the spring chases it — velocity preserved on each change
targetX.set(300);
targetX.set(120); // redirects smoothly mid-flight, no snap-backDefault damping is critical (fastest approach, no overshoot). Override with a
preset (snappy, bouncy, gentle, wobbly, stiff, slow) or explicit
stiffness / damping. Give from for an entrance animation:
const scale = springSignal(1, { from: 0, preset: "snappy" }); // pops in from 0How it flows
flowchart LR
T["target<br/>(number or signal)"] -->|on change, velocity preserved| RT["re-target"]
RT --> S["lite-spring integrator<br/>(fixed sub-step accumulator)"]
F["lite-raf frameDelta"] -->|dt each frame, while moving| S
S --> V["value signal"]
V --> C["effects / lite-scene props / DOM"]
S -.->|at rest| STOP["snap to target,<br/>unsubscribe from loop"]The value is an ordinary signal, so anything reactive — an effect, a
lite-scene transform prop,
the DOM — re-derives from it automatically.
Stability under frame drops
lite-spring's update() is one semi-implicit Euler step at the raw dt. Hand a
stiff spring a 100 ms dropped-frame delta in a single step and it can overshoot.
This layer runs a fixed sub-step accumulator (default 1/120 s) around the
integrator, so the spring stays stable at any frame delta; pathological gaps (tab
refocus) are clamped to 250 ms. A bare single step at dt = 1s on a stiff spring
overshoots past 200; through the accumulator it stays bounded and converges.
Driving it yourself
The default driver needs the lite-raf loop running (startFrames(), browser
rAF). For a fixed-step simulation, headless use, lite-rollback, or tests, pass
{ manual: true } and step it from your own loop:
const y = springSignal(0, { manual: true });
y.setTarget(100);
// in your fixed-timestep update:
y.step(deltaMs); // advances and returns the new valueZero-GC
A frame step integrates scalars and sets one signal — no allocation. Verified at 5,000 steps flat on the engine's pool counters with negligible heap growth. At rest there is no subscription and no work.
API
springSignal(target: number | (() => number), config?: SpringConfig): SpringSignal
createSpringFactory(registry): { springSignal } // bind the value signal to a non-default registry
interface SpringConfig {
stiffness?: number // default 170
damping?: number // default: critical damping (no overshoot)
preset?: string // snappy | bouncy | gentle | wobbly | stiff | slow
from?: number // initial value; animates in if != target
restThreshold?: number // default 0.01
fixedStep?: number // seconds, default 1/120
manual?: boolean // default false (auto-drive on lite-raf)
}
interface SpringSignal {
(): number // tracked read
peek(): number
velocity(): number
target(): number
setTarget(v: number): void // velocity-preserved re-target
snap(v: number): void
step(dtMs: number): number // manual drive
isResting(): boolean
dispose(): void
}Created inside an owning scope, a spring auto-disposes with it (onCleanup);
otherwise call dispose().
Not in v1 (on purpose)
Orchestration — timelines, stagger, gestures, FLIP / auto-animate — is not
here. springSignal is the primitive; those are compositions on top, kept out so
this stays a few hundred lines instead of a motion framework. (Auto-animating a
reordered list, for instance, is a lite-signal-dom + springSignal demo, not a
feature of this package.)
License
MIT (c) 2026 Zahary Shinikchiev <[email protected]>
