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: DOMHighResTimeStamp via e.timeStamp provides sub-millisecond accuracy
  • Capture guarantee: setPointerCapture prevents 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

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 setPointerCapture and a 3-5 sample history buffer to compute release speed accurately
  • Hand off to springs via the velocity parameter 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →