Gesture Feedback Motion
Gesture feedback is the most complex type of animation — the element must move in sync with the finger, then continue with inertia when released, then spring back to the correct position. Each wrong step produces a “lag” or “disconnect” feeling you can’t ignore.
Drag the card left or right — release early and it springs back to center
The three phases of gesture animation
1. Active tracking: the element follows the finger 1:1, no delay, no easing.
2. Release: on release → the spring/bezier inherits velocity from the gesture and continues.
3. Settle: arrives at the target position with spring physics — a slight bounce or overdamped depending on context.
const [{ x }, api] = useSpring(() => ({ x: 0 }));
const bind = useDrag(({ active, movement: [mx], velocity: [vx] }) => {
api.start({
x: active ? mx : 0, // follow finger when active, snap back when not
immediate: active, // phase 1: no easing while dragging
config: active
? { tension: 800 } // stiff to follow the finger
: { velocity: vx, tension: 200, friction: 25 } // phases 2-3: spring with velocity
});
});
immediate: true while dragging — the element sits exactly at the finger position, with no easing lag.
velocity: vx on release — the spring starts from the finger’s velocity at the moment it left the screen.
Overscroll resistance
When the user drags the element past the allowed limit, the element still moves — but with resistance. This creates a natural “rubber band” feel.
function rubberBand(distance: number, max: number): number {
if (Math.abs(distance) <= max) return distance;
const sign = distance > 0 ? 1 : -1;
const excess = Math.abs(distance) - max;
return sign * (max + excess * 0.3); // 30% rate beyond the boundary
}
const bind = useDrag(({ movement: [mx], active }) => {
const clamped = rubberBand(mx, MAX_DRAG);
api.start({ x: clamped, immediate: active });
});
0.3 is a common rubber band coefficient. iOS uses around 0.25–0.35. Below 0.2 looks too stiff; above 0.5 looks too loose.
Velocity threshold for dismissal
Bottom sheet, drawer, card stack — these usually share the logic: drag far enough OR swipe fast enough → dismiss.
const bind = useDrag(({ last, movement: [, my], velocity: [, vy] }) => {
if (!last) {
api.start({ y: my, immediate: true });
return;
}
const shouldDismiss = my > DISMISS_THRESHOLD || vy > DISMISS_VELOCITY;
if (shouldDismiss) {
api.start({
y: window.innerHeight,
config: { velocity: vy, tension: 150, friction: 20 }
});
onClose();
} else {
api.start({ y: 0, config: { tension: 300, friction: 30 } });
}
});
The two OR conditions matter: the user can dismiss either by dragging slowly but far (a clear intent) or with a quick short flick (a natural gesture).
Card swipe (Tinder-style)
const bind = useDrag(({ active, movement: [mx, my], velocity: [vx], direction: [dx] }) => {
const trigger = vx > 0.3 && !active; // swiped fast enough on release
const dir = dx > 0 ? 1 : -1; // left or right
if (trigger) {
api.start({
x: dir * (window.innerWidth + 100),
rotate: dir * 15,
config: { velocity: vx * dir, tension: 200, friction: 20 }
});
onSwipe(dir);
} else {
api.start({
x: active ? mx : 0,
rotate: active ? mx / 20 : 0,
immediate: active,
config: { tension: 300, friction: 30 }
});
}
});
rotate proportional to displacement (mx / 20) makes the card tilt in the drag direction — adding physicality.
Pinch-to-zoom
Pinch needs to handle two fingers simultaneously:
const bind = useGesture({
onPinch: ({ offset: [scale], origin: [ox, oy], active }) => {
api.start({
scale: Math.max(1, scale),
transformOrigin: `${ox}px ${oy}px`,
immediate: active
});
},
onPinchEnd: ({ offset: [scale] }) => {
if (scale < 1.05) {
api.start({ scale: 1, config: { tension: 300, friction: 30 } });
}
}
});
transformOrigin changes with the mid-point of the two fingers — the element zooms toward the exact center between them.
Haptic feedback
When there’s a threshold (dismiss point, snap point), trigger a haptic for reinforcement.
if (my > DISMISS_THRESHOLD && !hapticFired.current) {
navigator.vibrate?.(10); // subtle, 10ms
hapticFired.current = true;
}
Use ? optional chaining because vibrate is not available on all devices or on iOS Safari.
See also: Spring Parameters, Modal & Sheet Animation, Ease vs Spring.