How to Implement Swipe-to-Dismiss with Velocity Detection in React

A swipe-to-dismiss interaction requires calculating velocity on pointer release and dismissing the element if either the drag distance exceeds a threshold (typically 80 px) or the velocity exceeds 0.11 px/ms.

Implementing tactile, native-feeling swipe gestures in web applications demands more than simple distance tracking. According to the emilkowalski/skills repository design guidelines, effective swipe-to-dismiss logic must account for both deliberate drags and quick flicks. The approach documented in skills/emil-design-eng/SKILL.md establishes a velocity-based dismissal pattern that mirrors native mobile physics, ensuring users can efficiently clear items with a single gesture regardless of travel distance.

Core Principles of Velocity-Based Dismissal

The repository outlines a six-step pattern for implementing momentum-aware dismissal. This approach separates the concern of gesture distance from gesture speed, creating a responsive interface that adapts to user intent.

Capturing Initial Gesture Data

On pointerdown (or touchstart), record the initial X coordinate and timestamp. This baseline allows the system to calculate displacement and duration on release. The implementation in skills/emil-design-eng/SKILL.md emphasizes using pointer capture to ensure the element continues receiving events even if the user's finger moves outside the component bounds during a rapid swipe.

Tracking Movement and Calculating Velocity

During the pointermove phase, update the element's transform position to follow the finger. When the gesture ends on pointerup, compute velocity using the formula:


velocity = |distance| / elapsedTime

The repository specifies a velocity threshold of approximately 0.11 px/ms as the cutoff for dismissal. This means a user can swipe just 55 pixels in 500 milliseconds and still trigger dismissal because the speed indicates clear intent to dismiss.

Combining Distance and Velocity Thresholds

The dismissal decision logic follows an OR condition:

  • Distance threshold: The user drags the element at least SWIPE_THRESHOLD pixels (typically 80 px)
  • Velocity threshold: The calculated velocity exceeds 0.11 px/ms

As implemented in skills/emil-design-eng/SKILL.md (lines 441-450), this dual-threshold system accommodates both slow, deliberate drags and quick flicks that barely move the element before release.

React Implementation with Framer Motion

Below is a production-ready React hook based on the repository's guidelines. It uses Framer Motion for hardware-accelerated animations and pointer events for broad device compatibility.

import { useRef, useCallback } from "react";
import { motion, useAnimation } from "framer-motion";

const SWIPE_THRESHOLD = 80; // px
const VELOCITY_THRESHOLD = 0.11; // px/ms

export function useSwipeToDismiss(onDismiss: () => void) {
  const startX = useRef(0);
  const startTime = useRef(0);
  const controls = useAnimation();

  const onPointerDown = useCallback((e: React.PointerEvent) => {
    e.currentTarget.setPointerCapture(e.pointerId);
    startX.current = e.clientX;
    startTime.current = Date.now();
  }, []);

  const onPointerMove = useCallback((e: React.PointerEvent) => {
    const delta = e.clientX - startX.current;
    controls.set({ x: delta });
  }, [controls]);

  const onPointerUp = useCallback(
    (e: React.PointerEvent) => {
      const delta = e.clientX - startX.current;
      const elapsed = Date.now() - startTime.current;
      const velocity = Math.abs(delta) / elapsed;

      const shouldDismiss =
        Math.abs(delta) >= SWIPE_THRESHOLD || velocity > VELOCITY_THRESHOLD;

      if (shouldDismiss) {
        controls.start({
          x: delta > 0 ? 1000 : -1000,
          transition: { type: "spring", bounce: 0.2, stiffness: 300 },
        }).then(onDismiss);
      } else {
        controls.start({ x: 0, transition: { type: "spring", bounce: 0 } });
      }

      e.currentTarget.releasePointerCapture(e.pointerId);
    },
    [controls, onDismiss]
  );

  return {
    controls,
    handlers: {
      onPointerDown,
      onPointerMove,
      onPointerUp,
    },
  };
}

Usage Example

Apply the hook to any dismissible component:

import { motion } from "framer-motion";
import { useSwipeToDismiss } from "./useSwipeToDismiss";

function NotificationCard({ message, onClose }) {
  const { controls, handlers } = useSwipeToDismiss(onClose);

  return (
    <motion.div
      animate={controls}
      {...handlers}
      className="notification"
      style={{ touchAction: "pan-y" }}
    >
      {message}
    </motion.div>
  );
}

The touchAction: "pan-y" CSS property ensures vertical scrolling remains functional while horizontal swipes control dismissal.

Handling Critical Edge Cases

Robust swipe-to-dismiss implementations must address several interaction failures that degrade user experience.

Hysteresis and Gesture Recognition

To prevent accidental dismissals during scrolling or tapping, implement a hysteresis threshold of approximately 10 pixels. Ignore movements below this threshold until the user clearly intends to swipe. This prevents jitter when the user initially touches the screen with slight lateral drift.

Pointer Capture for Reliability

As noted in skills/emil-design-eng/SKILL.md (lines 58-62), pointer capture is essential. Without setPointerCapture, a rapid swipe that exits the element's bounds before pointerup fires will lose the event listener, leaving the element stuck in a half-dragged state. Always capture the pointer on pointerdown and release it on pointerup.

Multi-Touch Protection

After initiating a drag with the first touch point, ignore additional pointerdown events. Multiple simultaneous touch points can cause the startX reference to jump erratically, breaking the velocity calculation and creating jarring visual glitches.

Spring-Based Dismissal Animations

When dismissing, use spring physics rather than linear easing. Springs preserve the gesture's momentum and allow interruption if the user starts another interaction. The repository recommends springs with stiffness: 300 and bounce: 0.2 for the exit animation, and bounce: 0 for the snap-back animation when dismissal criteria aren't met.

Summary

  • Calculate velocity on every gesture end using |distance| / elapsedTime and compare against the 0.11 px/ms threshold defined in skills/emil-design-eng/SKILL.md
  • Combine thresholds so dismissal triggers if either distance exceeds 80 px OR velocity exceeds 0.11 px/ms
  • Use pointer capture to maintain event tracking when swipes exit element bounds
  • Animate with springs to preserve momentum and enable gesture interruption
  • Implement hysteresis (10 px minimum) to distinguish swipes from taps and scrolls

Frequently Asked Questions

What is the optimal velocity threshold for swipe-to-dismiss?

The emilkowalski/skills repository specifies 0.11 px/ms as the optimal velocity threshold for swipe-to-dismiss interactions. This value strikes a balance between sensitivity and precision, allowing quick flicks to dismiss items while preventing accidental dismissals during normal scrolling or tapping. Calculated as absolute distance divided by elapsed time, this threshold means a 100-pixel swipe completed in 909 milliseconds will trigger dismissal on velocity alone.

Should I use distance or velocity to determine dismissal?

You should use both. Relying solely on distance forces users to drag items completely across the screen, while velocity-only detection can trigger accidentally during fast scrolling. The recommended pattern checks if either the drag distance exceeds a static threshold (typically 80 px) or the velocity exceeds 0.11 px/ms. This dual-threshold approach accommodates both deliberate dragging and quick flicking gestures.

Why does my swipe-to-dismiss stop working when I drag too fast?

This typically occurs due to missing pointer capture. When a user swipes rapidly, their finger often leaves the element's bounding box before the pointerup event fires. Without setPointerCapture(e.pointerId) called during pointerdown, the browser stops sending events to your handler once the pointer exits the element. Always capture the pointer on gesture start and release it on end, as demonstrated in skills/emil-design-eng/SKILL.md (lines 58-62).

How do I prevent swipe-to-dismiss from interfering with vertical scrolling?

Apply the CSS property touch-action: pan-y to the swipeable element. This declaration tells the browser to handle vertical scrolling natively while allowing your JavaScript to process horizontal pointer events. Without this setting, the browser may interpret horizontal swipes as scroll attempts or, conversely, block vertical scrolling when the user touches a swipeable card.

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 →