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 transform and opacity exclusively (GPU-composited properties)
  • Apply will-change: transform before the gesture begins
  • Remove will-change after animation completes to free GPU memory

Summary

  • Velocity formula: Math.abs(distance) / elapsedMs yields pixels per millisecond, implemented in skills/emil-design-eng/SKILL.md
  • Dismiss threshold: 0.11 px/ms is the standard cutoff for intentional flicks
  • Distance fallback: Combine with 100px minimum for accessibility and slow drags
  • Spring animations: Required per skills/review-animations/STANDARDS.md for 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:

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 →