Implementing Velocity Handoff Between Gesture Drag and Spring Animation: A Complete Guide
Velocity handoff ensures that a spring animation continues with the exact speed of a released drag gesture, eliminating jarring stops and creating fluid, momentum-driven interfaces.
Fluid gesture-driven interfaces depend on one critical transition: when a user's finger lifts off the screen, the ensuing animation must carry that momentum forward. This article demonstrates how to implement velocity handoff using the Pointer Events API and spring physics, drawing from the implementation patterns documented in the emilkowalski/skills repository.
Capturing Pointer Velocity Before Release
The foundation of velocity handoff lies in accurate velocity measurement during the final moments of a gesture. The Pointer Events API provides the precision needed through setPointerCapture, which ensures continuous event delivery even when the cursor leaves the target element.
Recording Motion History
Maintain a short buffer of position-timestamp pairs to compute a smoothed velocity vector:
const el = document.querySelector('.draggable');
let history = []; // {x, y, t}[]
el.addEventListener('pointerdown', e => {
el.setPointerCapture(e.pointerId);
history = [{x: e.clientX, y: e.clientY, t: e.timeStamp}];
});
el.addEventListener('pointermove', e => {
history.push({x: e.clientX, y: e.clientY, t: e.timeStamp});
if (history.length > 5) history.shift();
const dx = e.clientX - history[0].x;
el.style.transform = `translate(${dx}px, 0)`;
});
Key implementation details from [skills/apple-design/SKILL.md](https://github.com/emilkowalski/skills/blob/main/skills/apple-design/SKILL.md#L100-L110):
- History length: 3-5 samples balances responsiveness with noise reduction
- Timestamp precision:
DOMHighResTimeStampviae.timeStampprovides sub-millisecond accuracy - Capture guarantee:
setPointerCaptureprevents velocity loss from boundary exits
Computing Release Velocity
Calculate the velocity vector on pointerup using the oldest and newest samples in your buffer:
el.addEventListener('pointerup', e => {
if (history.length < 2) return;
const first = history[0];
const last = history[history.length - 1];
const dt = (last.t - first.t) / 1000; // seconds
const vx = (last.x - first.x) / dt; // pixels per second
startSpringAnimation(el, vx);
});
Passing Velocity to Spring Animation
Spring libraries accept an initial velocity parameter that seeds the physical simulation. The Motion library (successor to Framer Motion) implements this through the velocity option in animate().
Basic Velocity Handoff Implementation
import { animate } from 'motion';
function startSpringAnimation(element, vx) {
const targetX = 0; // resting position
animate(element, { x: targetX }, {
type: 'spring',
duration: 0.4,
bounce: 0.2, // momentum-driven feel per repo standards
velocity: vx // ← critical: gesture velocity imported here
});
}
Normalizing Relative Velocity
Some spring APIs expect velocity relative to the remaining displacement. Per [skills/apple-design/SKILL.md](https://github.com/emilkowalski/skills/blob/main/skills/apple-design/SKILL.md#L100-L110):
const currentX = element.getBoundingClientRect().left;
const displacement = targetX - currentX;
const relativeVelocity = vx / displacement; // velocity per unit distance
Recommended Spring Configuration
The repository's animation standards in [skills/review-animations/STANDARDS.md](https://github.com/emilkowalski/skills/blob/main/skills/review-animations/STANDARDS.md#L67-L70) specify:
| Property | Value | Purpose |
|---|---|---|
duration |
0.4 |
Natural settling time |
bounce |
0.2 |
Subtle momentum preservation |
type |
'spring' |
Physical simulation basis |
For critically-damped interactions (snapping without oscillation), reduce bounce toward 0. For under-damped interactions (playful, bouncy feel), increase toward 0.5+.
Ensuring Interruptibility and Continuity
A robust velocity handoff system must handle interruption: the user may re-grab the element while the spring is still in motion. Failure to handle this creates the "brick-wall" effect where the element jumps discontinuously.
Reading Live Presentation Values
Always restart from the current computed position, not the animation target:
let currentSpring = null;
function startSpringAnimation(element, vx) {
// Stop ongoing animation but preserve visual state
if (currentSpring) currentSpring.stop();
// Critical: sample the live rendered position
const currentX = element.getBoundingClientRect().left;
currentSpring = animate(element, { x: 0 }, {
type: 'spring',
duration: 0.4,
bounce: 0.2,
velocity: vx,
onComplete: () => { currentSpring = null; }
});
}
As demonstrated in [skills/emil-design-eng/SKILL.md](https://github.com/emilkowalski/skills/blob/main/skills/emil-design-eng/SKILL.md#L151-L165), the useSpring hook in component implementations follows this same pattern—reading the current motion value rather than assuming target completion.
Decomposing 2D Motion
For diagonal gestures, maintain independent velocity vectors per axis:
// From pointer history
const vx = (last.x - first.x) / dt;
const vy = (last.y - first.y) / dt;
// Separate springs preserve axis-specific momentum
animate(element, { x: targetX, y: targetY }, {
type: 'spring',
velocity: { x: vx, y: vy } // Motion accepts per-axis velocities
});
Per [skills/apple-design/SKILL.md](https://github.com/emilkowalski/skills/blob/main/skills/apple-design/SKILL.md#L100-L110), this prevents velocity blending artifacts where a fast horizontal swipe with slight vertical drift gets incorrectly averaged.
Complete Working Example
import { animate } from 'motion';
class DraggableWithVelocityHandoff {
element = null;
history = [];
currentSpring = null;
constructor(selector) {
this.element = document.querySelector(selector);
this.attachListeners();
}
attachListeners() {
const el = this.element;
el.addEventListener('pointerdown', e => {
el.setPointerCapture(e.pointerId);
// Interrupt any ongoing spring immediately
this.currentSpring?.stop();
this.history = [{
x: e.clientX,
y: e.clientY,
t: e.timeStamp
}];
});
el.addEventListener('pointermove', e => {
this.history.push({
x: e.clientX,
y: e.clientY,
t: e.timeStamp
});
if (this.history.length > 5) this.history.shift();
// Direct manipulation: 1:1 tracking
const dx = e.clientX - this.history[0].x;
el.style.transform = `translate(${dx}px, 0)`;
});
el.addEventListener('pointerup', e => {
if (this.history.length < 2) return;
const v = this.calculateVelocity();
this.startSpring(v.x);
});
el.addEventListener('pointercancel', () => {
this.startSpring(0); // Decay to rest without momentum
});
}
calculateVelocity() {
const first = this.history[0];
const last = this.history[this.history.length - 1];
const dt = (last.t - first.t) / 1000;
return {
x: (last.x - first.x) / dt,
y: (last.y - first.y) / dt
};
}
startSpring(vx) {
this.currentSpring?.stop();
const currentX = this.element.getBoundingClientRect().left;
this.currentSpring = animate(this.element, { x: 0 }, {
type: 'spring',
duration: 0.4,
bounce: 0.2,
velocity: vx,
onComplete: () => { this.currentSpring = null; }
});
}
}
// Initialize
new DraggableWithVelocityHandoff('.draggable');
Key Terminology for Configuration
Understanding these terms from [skills/animation-vocabulary/SKILL.md](https://github.com/emilkowalski/skills/blob/main/skills/animation-vocabulary/SKILL.md#L126-L130) helps tune your springs:
- Stiffness: Spring's resistance to displacement—higher values snap faster
- Damping: Energy dissipation rate—controls oscillation decay
- Bounce: Convenience parameter mapping to damping ratio;
0= critically damped - Velocity: Initial speed at animation start—the handoff value from gestures
Summary
- Capture velocity using
setPointerCaptureand a 3-5 sample history buffer to compute release speed accurately - Hand off to springs via the
velocityparameter in Motion/Framer Motion, normalizing to relative velocity if required by your API - Enable interruption by stopping ongoing springs and restarting from the live
getBoundingClientRect()position, never the target - Decompose 2D motion into independent per-axis springs to preserve directional momentum fidelity
Frequently Asked Questions
What is velocity handoff in gesture animations?
Velocity handoff is the technique of transferring the instantaneous speed of a user's drag gesture directly into a spring animation's initial velocity parameter. This creates continuous motion where the element "coasts" naturally after release rather than starting from zero speed. According to emilkowalski/skills, this pattern is essential for Apple-caliber gesture interfaces.
How do I prevent velocity calculation errors on quick taps?
Require a minimum history length before computing velocity—typically 2-3 samples. For very short interactions, fall back to zero velocity or a default decay. The pointer handler should check if (history.length < 2) return and either skip animation or apply a gentle settle-to-rest spring without initial velocity.
Which spring configuration should I use for different UI patterns?
Per [skills/review-animations/STANDARDS.md](https://github.com/emilkowalski/skills/blob/main/skills/review-animations/STANDARDS.md#L67-L70), use { bounce: 0.2, duration: 0.4 } for standard momentum-driven interactions. For destructive confirmations or dismissals, use critically damped springs (bounce: 0). For playful, exploratory interfaces, increase bounce toward 0.5 to introduce visible oscillation.
Can I implement velocity handoff without the Motion library?
Yes. Any spring physics implementation accepting initial velocity works—including custom requestAnimationFrame loops solving F = -kx - cv numerically, or alternatives like React Spring, Popmotion, or WAAPI with transition-timing-function. The critical requirement is injecting the computed vx/vy values at animation start time.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →