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 — 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.