Physics Motion
The default animate() call interpolates from A to B over a fixed duration with an easing curve. For UI that needs to feel natural — pointer flicks, bouncing, rubber-band edges, settling springs — fixed-duration interpolation looks synthetic. Physics motion types model the motion instead of the path, so the result feels like real-world movement.
Pass a motion: config to animate() instead of duration + easing:
// Duration-based — fixed timing, linear energy
$flow.animate({ nodes: { n: { position: { x: 400 } } } }, {
duration: 600,
easing: 'easeInOut',
});
// Physics-based — timing emerges from the motion model
$flow.animate({ nodes: { n: { position: { x: 400 } } } }, {
motion: 'spring.wobbly',
});
<div x-data="flowCanvas({
nodes: [
{ id: 'h', position: { x: 40, y: 100 }, data: { label: 'Motion' } },
],
edges: [],
background: 'dots',
controls: false, pannable: false, zoomable: false,
})" class="flow-container" style="height: 240px;"
x-init="
const reset = () => $flow.update({ nodes: { h: { position: { x: 40, y: 100 } } } });
document.getElementById('demo-physics-hero-spring').addEventListener('click', () => {
reset();
$flow.animate({ nodes: { h: { position: { x: 360, y: 100 } } } }, { motion: 'spring.wobbly' });
});
document.getElementById('demo-physics-hero-decay').addEventListener('click', () => {
$flow.animate(
{ nodes: { h: { position: { x: 0, y: 0 } } } },
{ motion: { type: 'decay', velocity: { x: 1200, y: 0 } } },
);
});
document.getElementById('demo-physics-hero-inertia').addEventListener('click', () => {
$flow.animate(
{ nodes: { h: { position: { x: 0, y: 0 } } } },
{ motion: {
type: 'inertia',
velocity: { x: 1500, y: 600 },
bounds: { x: [0, 400], y: [0, 180] },
bounceStiffness: 300, bounceDamping: 30, timeConstant: 500,
} },
);
});
document.getElementById('demo-physics-hero-keyframes').addEventListener('click', () => {
reset();
$flow.animate({ nodes: { h: { position: { x: 40, y: 100 } } } }, {
motion: { type: 'keyframes', values: [
{ x: 40, y: 100 }, { x: 200, y: 20 }, { x: 360, y: 100 },
{ x: 200, y: 180 }, { x: 40, y: 100 },
], duration: 2800 },
});
});
document.getElementById('demo-physics-hero-reset').addEventListener('click', reset);
">
<div x-flow-viewport>
<template x-for="node in nodes" :key="node.id">
<div x-flow-node="node">
<span x-text="node.data.label"></span>
</div>
</template>
</div>
</div>
Four motion types ship built-in:
| Type | When to use |
|---|---|
spring |
Natural settle behavior — buttons, cards, modal transitions, "snap into place" motion |
decay |
Deceleration from an initial impulse — pointer flicks that coast to a stop, throwable cards |
inertia |
Decay + bounds with bouncing and snapTo — scrollers, swipeable lists, draggable panels with limits |
keyframes |
Tour a node through an explicit sequence of waypoints over a fixed duration |
All four respect maxDuration (default 5000ms) as a safety cap — if the motion hasn't settled by then, the animation force-completes at its latest interpolated value.
Spring
A critically-damped harmonic oscillator. The node is attached to the target via a virtual spring; it accelerates, overshoots slightly (if underdamped), and settles.
Five string presets cover the common cases:
| Preset | Stiffness | Damping | Feel |
|---|---|---|---|
'spring.gentle' |
120 | 14 | Calm, minor overshoot |
'spring.wobbly' |
180 | 12 | Springy, clearly visible overshoot (default when stiffness/damping are omitted) |
'spring.stiff' |
300 | 30 | Fast, almost no overshoot — "snap into place" |
'spring.slow' |
60 | 15 | Drawn-out settle |
'spring.molasses' |
40 | 30 | Slow, overdamped — no overshoot |
$flow.animate({ nodes: { n: { position: { x: 400 } } } }, { motion: 'spring.wobbly' });
Or pass a SpringMotion object for full control:
$flow.animate({ nodes: { n: { position: { x: 400 } } } }, {
motion: {
type: 'spring',
stiffness: 200, // spring constant (higher = faster)
damping: 15, // energy loss per frame (higher = less bounce)
mass: 1, // heavier mass = slower acceleration
restVelocity: 0.01, // threshold below which motion is considered "settled"
restDisplacement: 0.01,
},
});
<div x-data="flowCanvas({
nodes: [
{ id: 'gentle', position: { x: 40, y: 10 }, data: { label: 'spring.gentle' } },
{ id: 'wobbly', position: { x: 40, y: 60 }, data: { label: 'spring.wobbly' } },
{ id: 'stiff', position: { x: 40, y: 110 }, data: { label: 'spring.stiff' } },
{ id: 'slow', position: { x: 40, y: 160 }, data: { label: 'spring.slow' } },
{ id: 'molasses', position: { x: 40, y: 210 }, data: { label: 'spring.molasses' } },
],
edges: [],
background: 'dots',
controls: false, pannable: false, zoomable: false,
})" class="flow-container" style="height: 290px;"
x-init="
let atRight = false;
document.getElementById('demo-spring-run').addEventListener('click', () => {
const x = atRight ? 40 : 320;
atRight = !atRight;
for (const preset of ['gentle', 'wobbly', 'stiff', 'slow', 'molasses']) {
$flow.animate({ nodes: { [preset]: { position: { x } } } }, { motion: 'spring.' + preset });
}
});
document.getElementById('demo-spring-reset').addEventListener('click', () => {
atRight = false;
for (const preset of ['gentle', 'wobbly', 'stiff', 'slow', 'molasses']) {
$flow.update({ nodes: { [preset]: { position: { x: 40 } } } });
}
});
">
<div x-flow-viewport>
<template x-for="node in nodes" :key="node.id">
<div x-flow-node="node">
<span x-text="node.data.label" style="font-family: monospace; font-size: 11px;"></span>
</div>
</template>
</div>
</div>
Decay
Exponential decay from an initial velocity — "throw this node and let it coast to a stop." The target in animate(target, …) is ignored for decay; the motion is driven entirely by the initial velocity and time constant.
velocity is the only required field. Pass a number to decay on the first axis, or { x, y } for separate per-axis velocities:
// Fling right at 1200 units/sec
$flow.animate({ nodes: { puck: { position: { x: 0, y: 0 } } } }, {
motion: { type: 'decay', velocity: { x: 1200, y: 0 } },
});
Two presets:
| Preset | Feel |
|---|---|
'decay.smooth' |
Gentle deceleration, longer glide |
'decay.snappy' |
Sharper stop, shorter glide |
Full DecayMotion shape:
interface DecayMotion {
type: 'decay';
velocity: number | { x: number; y: number };
power?: number; // initial velocity multiplier (default 0.8). Higher = longer glide.
timeConstant?: number; // exponential decay time in ms (default 350). Higher = slower stop.
}
<div x-data="flowCanvas({
nodes: [
{ id: 'puck', position: { x: 200, y: 100 }, data: { label: 'Fling me' } },
],
edges: [],
background: 'dots',
controls: false, pannable: false, zoomable: false,
})" class="flow-container" style="height: 240px;"
x-init="
const fling = (velocity) => $flow.animate(
{ nodes: { puck: { position: { x: 0, y: 0 } } } },
{ motion: { type: 'decay', velocity } },
);
document.getElementById('demo-decay-left').addEventListener('click', () => fling({ x: -1200, y: 0 }));
document.getElementById('demo-decay-right').addEventListener('click', () => fling({ x: 1200, y: 0 }));
document.getElementById('demo-decay-diag').addEventListener('click', () => fling({ x: 900, y: 600 }));
document.getElementById('demo-decay-reset').addEventListener('click', () => $flow.update({ nodes: { puck: { position: { x: 200, y: 100 } } } }));
">
<div x-flow-viewport>
<template x-for="node in nodes" :key="node.id">
<div x-flow-node="node">
<span x-text="node.data.label"></span>
</div>
</template>
</div>
</div>
Inertia
Decay plus bounds and snap targets. When the node hits a bound, it bounces back; when the motion settles, it can optionally snap to the nearest configured value.
$flow.animate({ nodes: { ball: { position: { x: 0, y: 0 } } } }, {
motion: {
type: 'inertia',
velocity: { x: 1500, y: 200 },
bounds: { x: [0, 400], y: [0, 180] }, // bouncing walls
bounceStiffness: 300, // bounciness (normalized around 500)
bounceDamping: 30, // velocity loss per bounce, %
snapTo: [{ x: 0 }, { x: 200 }, { x: 400 }], // settle points
},
});
Two presets:
| Preset | Feel |
|---|---|
'inertia.momentum' |
Glides and decelerates — good for swipe-to-pan |
'inertia.rails' |
Stiff bounce off bounds, moderate damping |
Full InertiaMotion shape:
interface InertiaMotion {
type: 'inertia';
velocity: number | { x: number; y: number };
bounds?: Record<string, [number, number]>; // [min, max] per property
bounceStiffness?: number; // default 200
bounceDamping?: number; // default 40 (percent velocity loss)
snapTo?: Array<Record<string, number>>; // settle candidates
power?: number; // default 0.8
timeConstant?: number; // default 350
}
bounds keys can be anything — not restricted to 'x'/'y'. You could animate { scrollTop: [0, 800] } on a scroll container, or { volume: [0, 100] } on a slider.
<div x-data="flowCanvas({
nodes: [
{ id: 'ball', position: { x: 200, y: 100 }, data: { label: 'Bounce' } },
],
edges: [],
background: 'dots',
controls: false, pannable: false, zoomable: false,
})" class="flow-container" style="height: 240px;"
x-init="
const fling = (velocity) => $flow.animate(
{ nodes: { ball: { position: { x: 0, y: 0 } } } },
{ motion: {
type: 'inertia',
velocity,
bounds: { x: [0, 400], y: [0, 180] },
bounceStiffness: 300,
bounceDamping: 30,
timeConstant: 500,
} },
);
document.getElementById('demo-inertia-right').addEventListener('click', () => fling({ x: 1500, y: 0 }));
document.getElementById('demo-inertia-diag').addEventListener('click', () => fling({ x: 1200, y: 800 }));
document.getElementById('demo-inertia-reset').addEventListener('click', () => $flow.update({ nodes: { ball: { position: { x: 200, y: 100 } } } }));
">
<div x-flow-viewport>
<template x-for="node in nodes" :key="node.id">
<div x-flow-node="node">
<span x-text="node.data.label"></span>
</div>
</template>
</div>
</div>
Keyframes
Tour a node through an explicit list of waypoints over a fixed duration. Unlike spring/decay/inertia, keyframes has a defined duration — the timing is not emergent.
$flow.animate({ nodes: { n: { position: { x: 40, y: 100 } } } }, {
motion: {
type: 'keyframes',
values: [
{ x: 40, y: 100 }, // start
{ x: 200, y: 20 }, // top middle
{ x: 360, y: 100 }, // right
{ x: 200, y: 180 }, // bottom middle
{ x: 40, y: 100 }, // back to start
],
duration: 3000, // total tour time
},
});
Each waypoint is a Record<string, number> — keys match the property names you're animating. Waypoints are evenly spaced across duration by default; pass offsets: [0, 0.2, 0.5, 0.8, 1] for non-uniform timing.
Full KeyframesMotion shape:
interface KeyframesMotion {
type: 'keyframes';
values: Array<Record<string, number>>;
offsets?: number[]; // 0..1, one per value; default evenly spaced
between?: string | MotionConfig; // easing between waypoints (e.g. 'spring.gentle')
duration?: number; // default 5000ms
loop?: boolean | number; // replay on finish
}
Pass between: 'spring.gentle' to use spring physics to transition between each waypoint pair (instead of default linear interpolation) — useful for organic motion across the tour.
<div x-data="flowCanvas({
nodes: [
{ id: 'traveler', position: { x: 40, y: 100 }, data: { label: 'Tour' } },
],
edges: [],
background: 'dots',
controls: false, pannable: false, zoomable: false,
})" class="flow-container" style="height: 240px;"
x-init="
document.getElementById('demo-kf-tour').addEventListener('click', () => {
$flow.animate({ nodes: { traveler: { position: { x: 40, y: 100 } } } }, {
motion: { type: 'keyframes', values: [
{ x: 40, y: 100 }, { x: 200, y: 20 }, { x: 360, y: 100 },
{ x: 200, y: 180 }, { x: 40, y: 100 },
], duration: 3000 },
});
});
document.getElementById('demo-kf-eight').addEventListener('click', () => {
$flow.animate({ nodes: { traveler: { position: { x: 40, y: 100 } } } }, {
motion: { type: 'keyframes', values: [
{ x: 40, y: 100 }, { x: 120, y: 40 }, { x: 200, y: 100 },
{ x: 280, y: 160 }, { x: 360, y: 100 }, { x: 280, y: 40 },
{ x: 200, y: 100 }, { x: 120, y: 160 }, { x: 40, y: 100 },
], duration: 4000 },
});
});
document.getElementById('demo-kf-reset').addEventListener('click', () => {
$flow.update({ nodes: { traveler: { position: { x: 40, y: 100 } } } });
});
">
<div x-flow-viewport>
<template x-for="node in nodes" :key="node.id">
<div x-flow-node="node">
<span x-text="node.data.label"></span>
</div>
</template>
</div>
</div>
When motion vs duration+easing
Physics motion is natural-feeling but non-deterministic in timing — a spring with the same stiffness/damping will take a different amount of time to settle depending on how far it has to travel. Duration-based interpolation is the opposite: predictable timing, synthetic feel.
Rule of thumb:
- Use physics (
motion) when the feel matters: mouse-driven flicks, cards snapping back after a drag, natural settle after user input, emergent playful effects. - Use duration + easing when timing matters: synchronized sequences (multiple nodes arriving at the same moment), choreographed reveals, deterministic demos.
Both go through the same animator — you can mix them across animations on different nodes, or use keyframes to get both (a fixed-duration tour with optional per-segment springs via between).
maxDuration safety cap
Physics motions terminate when velocity drops below restVelocity AND displacement below restDisplacement. A pathological config (zero damping on a spring, for example) could theoretically never settle. maxDuration is a safety cap — when reached, the animation force-completes at its latest interpolated value.
$flow.animate(..., {
motion: 'spring.wobbly',
maxDuration: 2000, // force-settle after 2s even if still oscillating
});
Default is 5000ms. Lower it for time-sensitive UI; raise it for ambient background motion.
MotionConfig type reference
type MotionConfig = SpringMotion | DecayMotion | InertiaMotion | KeyframesMotion;
interface SpringMotion {
type: 'spring';
stiffness?: number; // default 180
damping?: number; // default 12
mass?: number; // default 1
restVelocity?: number; // default 0.01
restDisplacement?: number; // default 0.01
}
interface DecayMotion {
type: 'decay';
velocity: number | { x: number; y: number };
power?: number; // default 0.8
timeConstant?: number; // default 350 (ms)
}
interface InertiaMotion {
type: 'inertia';
velocity: number | { x: number; y: number };
bounds?: Record<string, [number, number]>;
bounceStiffness?: number; // default 200
bounceDamping?: number; // default 40 (percent)
snapTo?: Array<Record<string, number>>;
power?: number;
timeConstant?: number;
}
interface KeyframesMotion {
type: 'keyframes';
values: Array<Record<string, number>>;
offsets?: number[];
between?: string | MotionConfig;
duration?: number; // default 5000 (ms)
loop?: boolean | number;
}
Preset strings (passed as motion: 'category.preset'):
- Spring:
'spring.gentle','spring.wobbly','spring.stiff','spring.slow','spring.molasses' - Decay:
'decay.smooth','decay.snappy' - Inertia:
'inertia.momentum','inertia.rails'
See also
- Animate & Update — the general
animate()/update()API andAnimateOptions - Path Motion — move nodes along curves instead of interpolating coordinates directly
- Timeline — sequence multiple animations; physics motion works inside timeline steps too