Gesture & Drag
12 minGesture & Drag
Drag interactions are the most physically demanding animations in a UI. Every other animation type is fire-and-forget—trigger an event, play a transition. Drag requires continuous, frame-by-frame response to the user's finger or cursor. The element must follow the pointer with zero perceptible lag, respond to boundaries with physical resistance, and settle naturally when released.
This is where spring animations prove their worth and where CSS transitions break down completely.
Pointer capture
The first rule of drag: capture the pointer. Without pointer capture, the dragged element loses track of the cursor the moment it moves outside the element's bounds—which happens constantly during fast drags.
const handlePointerDown = (e: React.PointerEvent) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
// Begin tracking
};
setPointerCapture locks all subsequent pointer events to the element until the pointer is released, regardless of where the cursor moves. Without it, a fast horizontal swipe on a vertically-scrolling list will lose the drag mid-gesture.
Always use pointer events, not mouse events. Pointer events unify mouse, touch, and stylus input under a single API. Mouse events don't fire for touch interactions and require a separate touch event handler—doubling the code and introducing subtle behavioural differences.
Momentum
When the user releases a dragged element, it shouldn't stop dead. Real objects have inertia. A panel flicked quickly should continue moving after release, decelerating to a stop. A panel dragged slowly should settle close to where it was released.
Momentum is calculated from the pointer's velocity at release. Track the last 2–3 pointer positions with timestamps, compute velocity as deltaPosition / deltaTime, and pass that velocity to a spring animation as the velocity parameter.
// On release, pass the gesture velocity to the spring
animate(x, snapPoint, {
type: "spring",
velocity: gesture.velocity,
stiffness: 400,
damping: 40,
});
Framer Motion's drag prop handles this automatically. If you're implementing drag manually, velocity tracking is the single most important detail to get right. Without it, releasing a drag feels like hitting a wall—the element stops instantly, which contradicts every physical intuition the user has.
Boundary damping
When a dragged element hits the edge of its allowed range, it shouldn't stop hard. It should resist—moving slower and slower as the user drags further past the boundary, then springing back when released. This is boundary damping, and it's the same rubber-band effect used in iOS scroll bounce.
The implementation: once the drag position exceeds the boundary, apply a damping function to the overflow distance. A common choice is logarithmic damping—the element moves further but at a diminishing rate.
function rubberBand(offset: number, dimension: number, constant = 0.55) {
return (offset * dimension * constant) / (dimension + constant * offset);
}
The constant controls resistance. 0.55 is iOS's value. Lower values (0.3) feel stiffer—the element barely moves past the boundary. Higher values (0.8) feel loose—the element stretches further before snapping back.
On release, animate back to the boundary using a spring. The rubber-band overshoot followed by a spring settle is one of the most satisfying microinteractions in UI—it communicates "you've reached the edge" without a hard stop.
Swipe-to-dismiss
Swipe-to-dismiss is the most common gesture pattern in mobile UI. Get it wrong and users either can't dismiss things they want gone or accidentally dismiss things they meant to keep. The threshold must balance sensitivity with intentionality.
The rule: use both distance and velocity, never just one.
- Distance threshold: The element must be dragged at least 30–40% of its own width (for horizontal swipes) or height (for vertical swipes).
- Velocity threshold: Alternatively, a fast flick—velocity above ~500px/s—should dismiss regardless of distance. A quick, decisive swipe should always work even if the distance is small.
const shouldDismiss =
Math.abs(offset) > dimension * 0.35 ||
Math.abs(velocity) > 500;
If neither threshold is met, spring back to the origin. The spring-back should feel gentle—{ stiffness: 300, damping: 30 }—to communicate "you didn't drag far enough" without feeling punitive.
On dismiss, animate the element off-screen with the current velocity. The element should continue in the direction it was moving, accelerating slightly as it exits. Don't reverse the direction—if the user swiped right, the element exits to the right.
Touch-action and scroll conflict
Drag interactions conflict with native browser scrolling. A horizontal swipe on a vertically-scrolling page will trigger both the drag handler and the page scroll, producing a broken diagonal motion.
The fix: touch-action. This CSS property tells the browser which touch gestures to handle natively and which to delegate to JavaScript.
/* Horizontal drag: let the browser handle vertical scroll */
.horizontal-drag {
touch-action: pan-y;
}
/* Vertical drag: let the browser handle horizontal scroll */
.vertical-drag {
touch-action: pan-x;
}
/* Full custom gesture: prevent all browser handling */
.custom-gesture {
touch-action: none;
}
Use touch-action: none sparingly—only on elements that handle all gesture directions. For most drag interactions, allow the browser to handle the axis you're not using. This preserves native scroll performance (which is always smoother than JavaScript scroll) and prevents the "trapped" feeling of a component that blocks all touch input.
Axis locking
Some drag interactions should lock to a single axis after the user's initial gesture direction is determined. A swipeable card should move horizontally or vertically, not both. A bottom sheet should only move vertically.
The pattern: on the first pointermove event, check whether deltaX or deltaY is larger. Lock to whichever axis won the race and ignore the other axis for the rest of the gesture.
if (!axisLock) {
if (Math.abs(deltaX) > Math.abs(deltaY)) {
axisLock = "x";
} else {
axisLock = "y";
}
}
Apply a small dead zone (5–8px) before checking axis lock. Fingers don't move in perfectly straight lines, and without a dead zone, a user trying to swipe horizontally will occasionally trigger a vertical lock because their initial movement was slightly diagonal.
The 60fps requirement
Drag animations must run at 60fps. Any dropped frame is visible as a stutter—the element "jumps" to catch up with the pointer rather than tracking it smoothly.
The practical implications:
- Only animate
transformandopacity. These are GPU-composited. Animatingleft,top,width,height,border-radius, or any layout property during a drag will cause layout recalculation on every frame and drop below 60fps on most devices. - Use
will-change: transformon the dragged element to promote it to its own compositor layer. Remove it when the drag ends—leaving it permanently wastes GPU memory. - Avoid React re-renders during drag. Use
useMotionValueor refs to track position rather than state. State updates trigger re-renders, which trigger reconciliation, which drops frames.
A drag interaction that stutters is worse than one with no animation at all. Users have a deep, intuitive expectation that dragged objects follow their finger. A 16ms delay between input and visual update—a single dropped frame—is perceptible and feels broken.