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

← Drag the card left or right →
Spring Physics
Drag me
Skip ✗

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.