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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →