How to Implement Gesture-Based Drag Dismissal with Momentum in React

Use pointer capture to track drag velocity, then apply a spring animation that preserves momentum when the release velocity exceeds 0.11 px/ms.

Gesture-based drag dismissal with momentum creates fluid, physics-based interactions where dragged elements continue moving naturally after release. The emilkowalski/skills repository documents this pattern extensively in its design-engineering skill files, providing concrete thresholds and implementation strategies used in production-grade interfaces.

Core Architecture for Momentum Drag Dismissal

The repository outlines six essential steps in skills/emil-design-eng/SKILL.md:

1. Capture the Drag with Pointer Events

Pointer capture ensures the gesture continues even when the cursor leaves the element's bounds. Call setPointerCapture() on pointerdown and store the initial state.

2. Track Start Time and Distance

Record the timestamp when dragging begins and accumulate the total drag distance throughout the gesture.

3. Calculate Velocity on Release

Compute velocity as |dragDistance| / elapsedTime. According to the skill file, the dismissal threshold is 0.11 px/ms—if velocity exceeds this value, dismiss regardless of distance traveled.

4. Apply a Spring Animation

Springs outperform fixed-duration CSS transitions because they preserve release velocity and support interruption. The recommended configuration from the source:

{
  type: "spring",
  duration: 0.5,
  bounce: 0.2
}

5. Implement Damping at Bounds

When users drag past natural edges (e.g., pulling a drawer already at top), apply progressive friction rather than hard stops. This "rubber-band" effect is documented in skills/review-animations/STANDARDS.md at lines 140-143.

6. Ensure Interruptibility

Spring-based animations allow instant retargeting if the user reverses direction mid-dismissal.

React Implementation with Framer Motion

This complete hook implements the velocity-threshold logic from the design-engineering skill:

import { useRef, useState } from "react";
import { motion, useSpring } from "framer-motion";

const DRAG_THRESHOLD = 30; // px
const VELOCITY_THRESHOLD = 0.11; // px/ms

export function DragDismiss({ children }) {
  const dragStart = useRef<Date | null>(null);
  const dragDelta = useRef(0);
  const [dismissed, setDismissed] = useState(false);

  const spring = useSpring(0, {
    type: "spring",
    duration: 0.5,
    bounce: 0.2,
  });

  const onPointerDown = (e: React.PointerEvent) => {
    e.currentTarget.setPointerCapture(e.pointerId);
    dragStart.current = new Date();
    dragDelta.current = 0;
  };

  const onPointerMove = (e: React.PointerEvent) => {
    if (!dragStart.current) return;
    dragDelta.current = e.movementX;
    spring.set(dragDelta.current);
  };

  const onPointerUp = (e: React.PointerEvent) => {
    if (!dragStart.current) return;
    const elapsed = new Date().getTime() - dragStart.current.getTime();
    const velocity = Math.abs(dragDelta.current) / elapsed;

    const shouldDismiss =
      Math.abs(dragDelta.current) > DRAG_THRESHOLD || velocity > VELOCITY_THRESHOLD;

    if (shouldDismiss) {
      setDismissed(true);
    } else {
      spring.set(0); // Snap back
    }

    dragStart.current = null;
  };

  if (dismissed) return null;

  return (
    <motion.div
      style={{ x: spring }}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
    >
      {children}
    </motion.div>
  );
}

Key implementation details from the source:

  • setPointerCapture() guarantees uninterrupted dragging outside element bounds
  • The 0.11 px/ms velocity threshold matches the specification in skills/emil-design-eng/SKILL.md lines 44-52
  • The spring retains release velocity for natural momentum

Vanilla JavaScript Alternative

For projects without React, implement the same physics using the Web Animations API with Apple's recommended easing curve:

<div class="dismissable" id="dragTarget">
  Drag me
</div>

<script>
const el = document.getElementById("dragTarget");
let startX = 0;
let startTime = 0;

el.addEventListener("pointerdown", e => {
  el.setPointerCapture(e.pointerId);
  startX = e.clientX;
  startTime = performance.now();
});

el.addEventListener("pointermove", e => {
  if (e.pressure === 0) return;
  const delta = e.clientX - startX;
  el.style.setProperty("--drag-x", `${delta}px`);
});

el.addEventListener("pointerup", e => {
  const elapsed = performance.now() - startTime;
  const delta = e.clientX - startX;
  const velocity = Math.abs(delta) / elapsed;
  const shouldDismiss = Math.abs(delta) > 30 || velocity > 0.11;

  if (shouldDismiss) {
    el.animate(
      [{ transform: `translateX(${delta}px)` }, { transform: "translateX(100vw)" }],
      {
        duration: 500,
        easing: "cubic-bezier(0.23, 1, 0.32, 1)", // Apple-style spring
        fill: "forwards",
      }
    );
  } else {
    el.animate(
      [{ transform: `translateX(${delta}px)` }, { transform: "translateX(0)" }],
      { duration: 300, easing: "cubic-bezier(0.32, 0.72, 0, 1)", fill: "forwards" }
    );
  }
});
</script>

<style>
.dismissable {
  --drag-x: 0px;
  transform: translateX(var(--drag-x));
  transition: transform 0.2s ease-out;
  touch-action: none;
}
</style>

The cubic-bezier(0.23, 1, 0.32, 1) curve corresponds to "Apple's approach (recommended)" documented in skills/emil-design-eng/SKILL.md lines 81-84.

Spring vs. Fixed-Duration: Why Physics Matters

Approach Interruptibility Momentum Preservation Recommended Use
CSS transitions No—must complete or jump No—fixed duration Simple state changes
Spring physics Yes—retargets instantly Yes—carries velocity Drag gestures, dismissals

The skills/improve-animations/AUDIT.md file specifically warns against fixed-duration keyframes for gesture-based interactions, noting that springs prevent the "jarring interruption" common in poorly implemented dismissals.

Handling Edge Cases

Vertical Dismissal

Replace movementX with movementY and adjust the exit transform:

dragDelta.current = e.movementY;
// In dismissal animation:
spring.set(window.innerHeight); // Animate off-screen downward

Bidirectional Dragging

Track both axes independently and dismiss toward the dominant direction:

const velocityX = Math.abs(e.movementX) / elapsed;
const velocityY = Math.abs(e.movementY) / elapsed;
const dominant = velocityX > velocityY ? "x" : "y";

Accessibility Considerations

Add keyboard dismissal as a fallback—momentum physics should not block users who cannot perform drag gestures.

Summary

  • Pointer capture (setPointerCapture) enables reliable drag tracking across element boundaries
  • Velocity threshold of 0.11 px/ms determines dismissal eligibility regardless of distance
  • Spring animations with { duration: 0.5, bounce: 0.2 } preserve momentum and allow interruption
  • Damping at bounds creates physical rubber-band resistance when over-dragging
  • Apple-style easing curves (cubic-bezier(0.23, 1, 0.32, 1)) match platform-native feel

Frequently Asked Questions

What is the minimum velocity required to trigger momentum dismissal?

The emilkowalski/skills repository specifies 0.11 pixels per millisecond as the velocity threshold. This value appears in skills/emil-design-eng/SKILL.md as the standard for determining whether a quick flick should dismiss an element even if the total drag distance is small.

Why use springs instead of CSS transitions for drag dismissal?

Springs maintain the velocity from the user's release gesture and can be retargeted mid-animation if the user reverses direction. CSS transitions run on fixed durations and cannot preserve momentum or respond to interruption. The repository's animation standards in skills/review-animations/STANDARDS.md explicitly recommend springs for all gesture-based interactions.

How do I prevent drag dismissal from feeling too sensitive?

Increase the DRAG_THRESHOLD constant for distance-based gating, or reduce the velocity threshold below 0.11 px/ms. You can also add a time minimum—require at least 100ms of drag time before evaluating velocity to filter out accidental taps.

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 →