Momentum-Based Swipe Dismissal with Velocity Calculations: A Complete Implementation Guide
Momentum-based swipe dismissal uses drag velocity (pixels per millisecond) rather than fixed distance thresholds to determine whether to dismiss an element, creating natural, physics-inspired interactions.
This guide walks through implementing velocity-based swipe gestures in JavaScript and React, based on the animation standards and design engineering principles from the emilkowalski/skills repository. The approach combines precise velocity calculations with spring animations for interruptible, responsive UI patterns.
Core Algorithm: Recording Velocity for Swipe Dismissal
The velocity calculation follows a three-phase pattern documented in skills/emil-design-eng/SKILL.md (lines 441–452). Understanding this flow is essential before implementation.
Phase 1: Capture Gesture Start
Record the initial pointer position and timestamp when the user begins dragging. Store these as references to avoid re-renders and ensure accurate timing.
Phase 2: Measure on Release
When the drag ends, compute:
const velocity = Math.abs(dragDistance) / elapsedMs; // px / ms
The dragDistance is the total horizontal (or vertical) displacement from start to end. The elapsedMs is Date.now() - startTime. The absolute value ensures velocity is always positive regardless of swipe direction.
Phase 3: Apply Threshold Logic
Compare against the standard threshold:
const VELOCITY_THRESHOLD = 0.11; // px / ms
if (velocity > VELOCITY_THRESHOLD) {
// Dismiss with momentum
}
As implemented in the repository, 0.11 px/ms translates to roughly 110 pixels per second—fast enough to feel intentional, slow enough to avoid accidental triggers.
Why Springs Are Required for Momentum Gestures
The skills/review-animations/STANDARDS.md file (lines 61–73) mandates spring-based animations for all momentum interactions. Unlike duration-based transitions, springs preserve velocity when interrupted.
When a user flicks an element, then grabs it mid-flight, the spring continues from the current velocity rather than resetting. This creates the "physical" feel that distinguishes polished interfaces.
Key spring parameters from the standards:
- Damping: Higher values reduce oscillation (recommended: 25–40)
- Stiffness: Controls responsiveness (recommended: 300–500)
- Mass: Virtual weight of the element (typically 1)
Minimal React Implementation
This hook-based approach works with any rendering layer—no animation library required.
import { useRef, useCallback } from "react";
function useMomentumDismiss({ onDismiss, direction = "x" }) {
const startPos = useRef(0);
const startTime = useRef(0);
// Thresholds from emilkowalski/skills standards
const VELOCITY_THRESHOLD = 0.11; // px / ms
const DISTANCE_FALLBACK = 100; // px, for accessibility
const handlePointerDown = useCallback((e) => {
const point = direction === "x" ? e.clientX : e.clientY;
startPos.current = point;
startTime.current = Date.now();
// Capture pointer for reliable drag tracking
e.currentTarget.setPointerCapture(e.pointerId);
}, [direction]);
const handlePointerUp = useCallback((e) => {
const point = direction === "x" ? e.clientX : e.clientY;
const distance = point - startPos.current;
const elapsed = Date.now() - startTime.current;
// Avoid division by zero on instantaneous taps
if (elapsed === 0) return;
const velocity = Math.abs(distance) / elapsed;
// Velocity-primary check with distance fallback
const shouldDismiss = velocity > VELOCITY_THRESHOLD ||
Math.abs(distance) >= DISTANCE_FALLBACK;
if (shouldDismiss) {
// Pass velocity and direction for animation continuity
onDismiss({
velocity,
direction: distance > 0 ? 1 : -1,
distance
});
}
}, [direction, onDismiss]);
return {
onPointerDown: handlePointerDown,
onPointerUp: handlePointerUp,
// Prevent scrolling while swiping
style: { touchAction: "none", userSelect: "none" }
};
}
// Usage
function SwipeCard({ children, onRemove }) {
const dismissProps = useMomentumDismiss({
onDismiss: ({ velocity, direction }) => {
// Animate out using velocity-preserved spring
console.log(`Dismissing at ${velocity.toFixed(3)} px/ms`);
onRemove();
}
});
return (
<div {...dismissProps}>
{children}
</div>
);
}
The touchAction: "none" CSS property is critical—it prevents the browser's default touch behaviors (scrolling, zooming) while maintaining accessibility via pointer events.
Framer Motion Integration with Velocity Continuity
For production React applications, Framer Motion's useMotionValue and useSpring provide hardware-accelerated animations with automatic velocity preservation. This implementation mirrors the gesture standards in skills/review-animations/STANDARDS.md (lines 37–44).
import { motion, useMotionValue, useSpring, useTransform } from "framer-motion";
import { useRef } from "react";
interface SwipeableProps {
onDismiss: (direction: number) => void;
children: React.ReactNode;
}
export function Swipeable({ onDismiss, children }: SwipeableProps) {
const x = useMotionValue(0);
const startTime = useRef(0);
// Spring configuration from animation standards
const spring = useSpring(x, {
stiffness: 400,
damping: 30,
mass: 1
});
// Visual feedback: reduce opacity as card moves away
const opacity = useTransform(
spring,
[-300, 0, 300],
[0.2, 1, 0.2]
);
const handleDragStart = () => {
startTime.current = Date.now();
};
const handleDragEnd = (_: any, info: { offset: { x: number }; velocity: { x: number } }) => {
const elapsed = Date.now() - startTime.current;
const distance = info.offset.x;
// Calculate velocity manually for consistency with standards
const calculatedVelocity = Math.abs(distance) / elapsed;
const VELOCITY_THRESHOLD = 0.11;
// Combine calculated velocity with Framer's instantaneous velocity
const effectiveVelocity = Math.max(
calculatedVelocity,
Math.abs(info.velocity.x) / 1000 // Framer uses px/s, convert to px/ms
);
if (effectiveVelocity > VELOCITY_THRESHOLD || Math.abs(distance) > 150) {
// Animate to dismissal with momentum
const dismissDirection = distance > 0 ? 1 : -1;
spring.set(dismissDirection * 500); // Off-screen
setTimeout(() => onDismiss(dismissDirection), 200);
} else {
// Spring back to center
spring.set(0);
}
};
return (
<motion.div
style={{ x: spring, opacity }}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.2}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{children}
</motion.div>
);
}
Notice the dual velocity calculation: Framer Motion provides instantaneous velocity via info.velocity, while the manual calculation matches the repository's standardized approach. Using the maximum of both ensures reliability across different gesture speeds.
Handling Edge Cases and Accessibility
Low-Velocity, Long-Distance Swipes
Some users drag slowly but deliberately. The distance fallback (DISTANCE_FALLBACK = 100) catches these interactions without requiring excessive velocity.
const shouldDismiss = velocity > VELOCITY_THRESHOLD ||
Math.abs(distance) >= DISTANCE_FALLBACK;
Direction-Agnostic Implementation
The same algorithm applies to vertical swipes—simply swap clientX for clientY:
const isVertical = true;
const dragDistance = isVertical
? e.clientY - startY.current
: e.clientX - startX.current;
Reduced Motion Support
Respect prefers-reduced-motion by skipping the spring animation and using instant dismissal:
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
if (prefersReducedMotion && shouldDismiss) {
onDismiss(); // No animation
}
Performance Considerations
Velocity calculation is lightweight—two Date.now() calls and a division operation. However, avoid storing motion values in React state during the drag, as this triggers re-renders every frame. Use useRef for start values and motion libraries for the animated values.
For maximum performance on low-end devices:
- Use
transformandopacityexclusively (GPU-composited properties) - Apply
will-change: transformbefore the gesture begins - Remove
will-changeafter animation completes to free GPU memory
Summary
- Velocity formula:
Math.abs(distance) / elapsedMsyields pixels per millisecond, implemented inskills/emil-design-eng/SKILL.md - Dismiss threshold:
0.11 px/msis the standard cutoff for intentional flicks - Distance fallback: Combine with
100pxminimum for accessibility and slow drags - Spring animations: Required per
skills/review-animations/STANDARDS.mdfor interruptible, physics-based motion - Implementation pattern: Record start position/time → calculate on release → compare to thresholds → animate with spring
Frequently Asked Questions
How do I convert velocity units for different animation libraries?
Most libraries use pixels per second (px/s) rather than px/ms. Multiply by 1000: 0.11 px/ms = 110 px/s. Framer Motion's info.velocity already returns px/s, so divide by 1000 to compare against the repository's standard threshold.
What if the user's device has a slow refresh rate?
Velocity calculation is time-based, not frame-based, so it remains accurate regardless of refresh rate. However, the perceived smoothness of the spring animation benefits from 60fps or higher. Consider reducing spring stiffness on low-power mode detection for consistent perceived performance.
Can I use this approach with native mobile apps?
The algorithm translates directly to React Native using Animated or react-native-reanimated. The velocity threshold remains 0.11, though you may adjust for typical mobile screen sizes. The skills/review-animations/STANDARDS.md guidelines apply cross-platform—springs and velocity-based dismissal are framework-agnostic principles.
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 →