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