How to Implement Velocity-Based Swipe Dismissal in Web UI
Velocity-based swipe dismissal combines distance and velocity thresholds to determine when a user intends to dismiss a draggable element, enabling both slow drags past a threshold and quick flicks to close components.
The emilkowalski/skills repository defines this interaction pattern in its Emil Design Engineering guidelines, providing a robust alternative to distance-only swipe detection. By calculating the ratio of displacement to time, you can capture rapid gestures that travel only a short distance but clearly indicate user intent to dismiss.
Core Concepts of Velocity-Based Dismissal
Traditional swipe implementations rely solely on a static distance threshold, forcing users to drag elements significant distances even when they intend a quick "flick" action. The velocity-based approach adds a temporal dimension to gesture recognition.
Calculating Swipe Velocity
According to the implementation details in skills/emil-design-eng/SKILL.md, velocity is computed as a simple ratio of distance over time. When a drag gesture begins, record the start timestamp (dragStartTime). Upon release, calculate the elapsed time (timeTaken) and divide the absolute swipe displacement by this duration:
const velocity = Math.abs(swipeAmount) / timeTaken; // px per millisecond
This yields a velocity measured in pixels per millisecond, providing a quantitative measure of gesture speed regardless of total distance traveled.
The Dual-Threshold Logic
The dismissal decision uses an OR condition that checks both spatial and temporal criteria. As specified in the source guidelines, the element should dismiss when either:
- The absolute distance exceeds
SWIPE_THRESHOLD(typically 30 pixels), or - The calculated velocity exceeds 0.11 px/ms
if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
dismiss();
}
This dual-threshold approach ensures that slow, deliberate drags still work while enabling rapid flicks to dismiss drawers, toasts, or cards without requiring full travel distance.
Implementation Patterns
Plain JavaScript Implementation
For vanilla JavaScript implementations using the Pointer Events API, track the drag state across pointerdown, pointermove, and pointerup events:
let dragStartTime = null;
let swipeAmount = 0;
const SWIPE_THRESHOLD = 30; // px – adjust to your UI
function onPointerDown(e) {
dragStartTime = Date.now();
swipeAmount = 0;
element.setPointerCapture(e.pointerId);
}
function onPointerMove(e) {
if (dragStartTime === null) return;
swipeAmount = e.clientX - e.target.offsetLeft;
// Apply visual feedback using transform for GPU acceleration
element.style.transform = `translateX(${swipeAmount}px)`;
}
function onPointerUp(e) {
if (dragStartTime === null) return;
const timeTaken = Date.now() - dragStartTime;
const velocity = Math.abs(swipeAmount) / timeTaken;
if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
dismiss(); // Your dismissal logic here
} else {
// Snap back to original position
element.style.transition = 'transform 200ms ease-out';
element.style.transform = 'translateX(0)';
}
dragStartTime = null;
}
React Hook Implementation
For React applications, encapsulate the logic in a custom hook using useRef to persist values across renders without triggering re-renders:
import { useRef } from 'react';
function useSwipeDismiss({ onDismiss, threshold = 30 }) {
const startTime = useRef(null);
const swipeAmt = useRef(0);
const ref = useRef(null);
const onPointerDown = (e) => {
startTime.current = Date.now();
swipeAmt.current = 0;
ref.current.setPointerCapture(e.pointerId);
};
const onPointerMove = (e) => {
if (startTime.current === null) return;
swipeAmt.current = e.clientX - ref.current.offsetLeft;
ref.current.style.transform = `translateX(${swipeAmt.current}px)`;
};
const onPointerUp = () => {
if (startTime.current === null) return;
const elapsed = Date.now() - startTime.current;
const velocity = Math.abs(swipeAmt.current) / elapsed;
if (Math.abs(swipeAmt.current) >= threshold || velocity > 0.11) {
onDismiss();
} else {
ref.current.style.transition = 'transform 200ms ease-out';
ref.current.style.transform = 'translateX(0)';
}
startTime.current = null;
};
return { ref, onPointerDown, onPointerMove, onPointerUp };
}
Performance Considerations
The skills/review-animations/STANDARDS.md file emphasizes that swipe animations must maintain 60fps performance. Animate only the transform and opacity properties during the drag operation, as these composite on the GPU without triggering layout recalculations.
Avoid updating CSS custom properties on ancestor elements during the gesture, as this forces style recalculation across the entire subtree. Instead, apply transforms directly to the target element. When the user releases the pointer, use a CSS transition to handle the snap-back animation or dismissal exit, ensuring the browser optimizes the property interpolation.
Summary
- Velocity calculation divides absolute displacement by elapsed time to measure gesture speed in px/ms.
- Dual-threshold logic (
distance >= 30px OR velocity > 0.11) captures both slow drags and quick flicks. - Implementation requires tracking
Date.now()timestamps alongside pointer coordinates inpointerdownandpointerupevents. - Performance depends on animating only
transformandopacityproperties to maintain compositor-only animations. - Reference implementation resides in
skills/emil-design-eng/SKILL.md(lines 447-452) within theemilkowalski/skillsrepository.
Frequently Asked Questions
What is the optimal velocity threshold for swipe dismissal?
The 0.11 px/ms threshold defined in the Emil Design Engineering guidelines provides a balanced sensitivity that catches intentional flicks while ignoring accidental bumps. This equates to roughly 110 pixels per second, a speed that distinguishes deliberate gestures from incidental contact. You may adjust this value based on your specific UI component size and user testing, but values below 0.08 px/ms tend to trigger too easily while values above 0.15 px/ms require uncomfortably fast movements.
Why use velocity instead of just distance for swipe detection?
Distance-only detection creates a false dichotomy where users must choose between moving an element completely off-screen or abandoning the gesture. Velocity recognition respects the physics of real-world object manipulation—just as you can flick a playing card across a table without touching the far edge, users expect to "throw" digital elements away with similar brief, rapid gestures. This approach reduces interaction fatigue and feels more responsive, particularly for large modal drawers or fullscreen cards.
How do I prevent velocity calculation errors on very short drags?
When timeTaken approaches zero (nearly instantaneous clicks), the velocity calculation Math.abs(swipeAmount) / timeTaken could theoretically produce Infinity or extremely high values. In practice, the distance threshold (SWIPE_THRESHOLD) acts as a safety gate—if the user barely moves the element (less than a few pixels), the distance check fails and the velocity check becomes irrelevant. For robust implementations, consider adding a minimum time guard (e.g., if (timeTaken < 50) return) to exclude accidental taps from velocity evaluation.
Can velocity-based dismissal work with vertical swipes?
Yes, the pattern applies identically to vertical orientations by measuring clientY displacement instead of clientX. The velocity calculation remains Math.abs(swipeAmount) / timeTaken, though you may want distinct thresholds for horizontal versus vertical gestures depending on scroll behavior. In skills/emil-design-eng/SKILL.md, the pattern is described agnostically to axis—simply substitute the appropriate coordinate dimension in your onPointerMove handler and ensure your SWIPE_THRESHOLD accounts for the typical aspect ratio of your container.
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 →