How to Implement Velocity‑Based Gesture Dismissal with Momentum Projection

Pass the pointer's release velocity directly to a spring animation and project the resting position using exponential decay to create fluid, interruptible gesture dismissal.

Velocity‑based gesture dismissal with momentum projection creates interfaces that feel physically responsive—elements that can be "thrown" and caught mid‑flight. This article draws from the Apple Design skill in emilkowalski/skills to show you how to capture pointer history, compute release velocity, and hand it off to a spring for natural deceleration.


Capture Pointer History for Accurate Velocity

The foundation of momentum gestures is a sliding window of recent pointer events. Per skills/apple-design/SKILL.md, you must "track a short velocity/position history" to avoid noisy velocity calculations from single‑sample deltas.

Store the last 5–10 pointermove events with timestamps:

let history = []; // {x, y, time}[]
const MAX_HISTORY = 5;

function recordPointer(e) {
  const now = performance.now();
  history.push({ x: e.clientX, y: e.clientY, time: now });
  if (history.length > MAX_HISTORY) history.shift();
}

This capped array ensures velocity reflects recent motion intent rather than stale positions.


Compute Release Velocity at Pointer Up

On pointerup, calculate velocity from the final two samples. The Apple Design skill emphasizes using the exact release velocity to avoid visual seams between gesture and animation.

function getReleaseVelocity() {
  if (history.length < 2) return 0;
  
  const [prev, last] = history.slice(-2);
  const dt = (last.time - prev.time) || 1; // ms
  
  const vx = (last.x - prev.x) / dt; // px/ms
  const vy = (last.y - prev.y) / dt;
  
  return {
    x: vx * 1000, // convert to px/s for Motion
    y: vy * 1000,
    magnitude: Math.hypot(vx, vy) * 1000
  };
}

Multiply by 1000 to match Motion's pixel‑per‑second velocity units.


Project Momentum to Find the Resting Target

Raw release position alone produces abrupt stops. Instead, project where velocity would carry the element under exponential decay, then snap to the nearest valid boundary.

skills/apple-design/SKILL.md provides the reference projection function using decelerationRate:

const DECELERATION_RATE = 0.998; // UIScrollView‑like feel

function projectDistance(velocityPxPerMs) {
  // Exponential decay: distance = (v / 1000) * r / (1 - r)
  return (velocityPxPerMs / 1000) * DECELERATION_RATE / (1 - DECELERATION_RATE);
}

function getTargetY(currentY, velocityY) {
  const projected = projectDistance(Math.abs(velocityY) / 1000);
  const direction = Math.sign(velocityY);
  const restingY = currentY + (projected * direction);
  
  return nearestSnapPoint(restingY); // your snap logic
}

This projection ensures the element "overshoots" naturally when thrown fast, matching native iOS sheet behavior.


Hand Off Velocity to a Spring Animation

The critical hand‑off: pass the measured velocity as the spring's initial condition. The Apple Design skill states the spring must "start from the presentation value and receive the finger's exact velocity."

import { animate } from 'motion';

function dismissSheet(element, startY, velocity) {
  const targetY = getTargetY(startY, velocity.y);
  
  animate(element, { y: targetY }, {
    type: 'spring',
    
    // Stiffness/damping tuned for gesture momentum
    stiffness: 300,
    damping: 25,
    
    // Hand off the exact release velocity
    velocity: velocity.y,
    
    // Add bounce only for momentum gestures (not taps)
    bounce: Math.abs(velocity.y) > 500 ? 0.2 : 0
  });
}

Notice the conditional bounce: skills/apple-design/SKILL.md warns to "add bounce only when the gesture itself carried momentum"—fast swipes feel lively, slow drags settle quietly.


Complete Working Implementation

Combine all stages into a reusable gesture controller:

import { animate } from 'motion';

function createMomentumDismissal(element, options = {}) {
  const {
    snapPoints = [0, 300, 600], // Y positions
    decelerationRate = 0.998,
    maxHistory = 5
  } = options;
  
  let history = [];
  let currentY = 0;
  let isDragging = false;
  
  const record = (e) => {
    history.push({
      x: e.clientX,
      y: e.clientY,
      time: performance.now()
    });
    if (history.length > maxHistory) history.shift();
  };
  
  const nearestSnap = (y) => snapPoints.reduce((closest, p) => 
    Math.abs(p - y) < Math.abs(closest - y) ? p : closest
  );
  
  const project = (v) => (v / 1000) * decelerationRate / (1 - decelerationRate);
  
  element.addEventListener('pointerdown', (e) => {
    isDragging = true;
    element.setPointerCapture(e.pointerId);
    animate(element, { y: currentY }, { duration: 0 }); // stop any animation
    history = [];
    record(e);
  });
  
  element.addEventListener('pointermove', (e) => {
    if (!isDragging) return;
    record(e);
    
    // Direct manipulation: follow finger 1:1
    const dy = e.clientY - history[0].y;
    currentY = Math.max(0, dy); // resist pulling up
    element.style.transform = `translateY(${currentY}px)`;
  });
  
  element.addEventListener('pointerup', (e) => {
    if (!isDragging) return;
    isDragging = false;
    record(e);
    
    // Compute velocity from last two samples
    const [a, b] = history.slice(-2);
    const dt = Math.max(1, b.time - a.time);
    const vy = ((b.y - a.y) / dt) * 1000; // px/s
    
    // Project and snap
    const projectedY = currentY + Math.sign(vy) * project(Math.abs(vy) / 1000);
    targetY = nearestSnap(projectedY);
    
    // Spring with velocity hand-off
    animate(element, { y: targetY }, {
      type: 'spring',
      stiffness: 300,
      damping: 25,
      velocity: vy,
      bounce: Math.abs(vy) > 500 ? 0.2 : 0
    }).then(() => { currentY = targetY; });
  });
}

Key Design Principles from the Source

Principle Source Reference Implementation Impact
Track short velocity history skills/apple-design/SKILL.md lines 42–50 maxHistory: 5 prevents noise
Exact velocity hand‑off skills/apple-design/SKILL.md lines 100–108 velocity: vy in spring config
Exponential decay projection skills/apple-design/SKILL.md lines 121–128 project() function for target calculation
Conditional bounce skills/apple-design/SKILL.md lines 76–79 bounce: 0.2 only when |vy| > 500

Summary

  • Capture 5–10 recent pointer events with timestamps to compute stable release velocity.
  • Calculate velocity in px/s from the final two samples, handling edge cases like single‑tap releases.
  • Project the resting position using exponential decay (decelerationRate ≈ 0.998) rather than snapping from the release point.
  • Hand exact velocity to the spring—the animation inherits momentum and remains interruptible.
  • Apply bounce selectively to distinguish momentum gestures from precise positioning.

Frequently Asked Questions

Why track multiple pointer events instead of just the last one?

Single‑sample velocities are noisy due to input latency and sub‑pixel movement. The Apple Design skill recommends "a short velocity/position history" (lines 42–50) to average out jitter while remaining responsive to intentional fast swipes.

What deceleration rate should I use for momentum projection?

A decelerationRate of 0.998 matches UIScrollView physics and provides familiar iOS‑like feel. Lower values (0.99) stop faster; higher values (0.9995) glide longer. Adjust based on your element's perceived mass.

Can I use this with libraries other than Motion?

Yes—the pattern is framework‑agnostic. Pass velocity to Framer Motion's animate() with velocity, React Spring's useSpring with config.velocity, or WAAPI with custom spring integration. The core requirement remains: hand off the exact release velocity to maintain momentum continuity.

How do I make the gesture reversible mid‑animation?

Springs are inherently interruptible. On a new pointerdown, call animate(element, { y: currentY }, { duration: 0 }) to stop the spring at its present value, then resume tracking. The velocity history resets, but the element never snaps—creating seamless grab‑and‑throw behavior.

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 →