Gesture Feedback Motion

Gesture feedback là loại animation phức tạp nhất — element phải di chuyển đồng bộ với ngón tay, rồi tiếp tục theo quán tính khi thả ra, rồi spring về vị trí đúng. Mỗi bước sai tạo ra cảm giác “lag” hoặc “disconnect” không thể bỏ qua.

← Kéo card sang trái hoặc phải →
Spring Physics
Drag me
Skip ✗

Kéo card sang trái hoặc phải — thả sớm sẽ spring về giữa

Ba giai đoạn của gesture animation

1. Active tracking: element follow ngón tay 1:1, không delay, không easing.

2. Release: thả ra → spring/bezier nhận velocity từ gesture và tiếp tục.

3. Settle: về vị trí đích với spring physics — bounce nhẹ hoặc overdamped tùy context.

const [{ x }, api] = useSpring(() => ({ x: 0 }));

const bind = useDrag(({ active, movement: [mx], velocity: [vx] }) => {
  api.start({
    x: active ? mx : 0,           // follow finger khi active, snap back khi không
    immediate: active,             // giai đoạn 1: không easing khi đang kéo
    config: active
      ? { tension: 800 }           // stiff để follow finger
      : { velocity: vx, tension: 200, friction: 25 } // giai đoạn 2-3: spring với velocity
  });
});

immediate: true khi đang kéo — element ở đúng vị trí ngón tay, không có easing lag.

velocity: vx khi thả ra — spring bắt đầu từ vận tốc ngón tay lúc rời màn hình.

Overscroll resistance

Khi người dùng kéo element quá giới hạn cho phép, element vẫn di chuyển — nhưng với lực cản. Tạo cảm giác “rubber band” tự nhiên.

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 ngoài boundary
}
const bind = useDrag(({ movement: [mx], active }) => {
  const clamped = rubberBand(mx, MAX_DRAG);
  api.start({ x: clamped, immediate: active });
});

0.3 là hệ số rubber band phổ biến. iOS dùng khoảng 0.25–0.35. Dưới 0.2 trông quá cứng, trên 0.5 trông quá lỏng.

Velocity threshold để dismiss

Bottom sheet, drawer, card stack — thường có logic: kéo đủ xa HOẶC swipe đủ nhanh → 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 } });
  }
});

Hai điều kiện OR là quan trọng: người dùng có thể dismiss bằng cách kéo chậm xa (ý định rõ ràng) hoặc flick nhanh ngắn (gesture tự nhiên).

Card swipe (Tinder-style)

const bind = useDrag(({ active, movement: [mx, my], velocity: [vx], direction: [dx] }) => {
  const trigger = vx > 0.3 && !active; // swipe đủ nhanh khi thả ra
  const dir = dx > 0 ? 1 : -1;         // trái hay phải

  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 tỉ lệ với displacement (mx / 20) tạo cảm giác card tilt theo chiều kéo — thêm vật lý.

Pinch-to-zoom

Pinch cần xử lý hai ngón tay đồng thời:

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 thay đổi theo mid-point của hai ngón — element zoom vào đúng điểm giữa hai ngón tay.

Haptic feedback

Khi có threshold (dismiss point, snap point), trigger haptic để reinforcement.

if (my > DISMISS_THRESHOLD && !hapticFired.current) {
  navigator.vibrate?.(10); // subtle, 10ms
  hapticFired.current = true;
}

Dùng ? optional chaining vì vibrate không available trên tất cả device và iOS Safari.

Tham khảo: Spring Parameters, Modal & Sheet Animation, Ease vs Spring.