| name | gesture-motion-designer |
| description | This skill should be used when the user wants to implement gesture-driven interactions or motion that responds to touch or pointer input. Trigger when the user mentions "swipe to dismiss", "drag to reorder", "pinch to zoom", "long press", "drag gesture", "touch event", "pan gesture", "flick or fling motion", "momentum-based animation", "spring on release", "pull to refresh", "swipeable card", "draggable element", or wants an interaction to "feel like iOS", "feel native", or "track my finger". Also trigger when the user asks how to handle gesture velocity, inertia, or dismiss thresholds. |
Gesture & Motion Flow Designer
Gesture interactions are complex to implement correctly: touch events, velocity tracking, threshold detection, momentum on release, and spring physics must all cooperate. The goal is interactions that track the user's input with zero lag during the gesture, then spring naturally to their final state on release.
The central rule: During active gesture, animate at 1:1 with pointer position — no easing, no delay. On release, continue at the gesture's final velocity into a spring. This is what makes touch feel native vs. "web-like."
Workflow
Step 1: Identify the gesture type
| Gesture | Motion model | Output type |
|---|
| Swipe-to-dismiss (card, notification) | Velocity threshold → dismiss or spring back | useSwipeToDismiss hook |
| Drag-to-reorder (list, kanban) | Drag 1:1, spring settle on drop | useDraggable + layout animation |
| Pull-to-refresh | Elastic resistance, threshold trigger | Elastic transform + callback |
| Pinch-to-zoom | Scale at midpoint, spring clamp on release | Gesture transform + boundary spring |
| Pan/scroll-linked | 1:1 position tracking | useMotionValue + useTransform |
| Long-press | Delay + scale feedback | Timer + spring scale |
Step 2: Define the physics
Velocity matters at release. When a user flicks a card, the card should continue at the flick velocity and decelerate via spring — not restart from zero. Always capture velocity from the gesture end event.
Threshold decision logic:
| Condition | Action |
|---|
| Displacement > 40% of element size | Dismiss/commit |
| Velocity > 500px/s (regardless of displacement) | Dismiss/commit |
| Neither threshold met | Spring back to origin |
Check velocity or displacement — not both required. Requiring both makes the gesture feel unresponsive.
Step 3: Generate the implementation
Framer Motion — swipe-to-dismiss hook:
import { useMotionValue, useTransform, animate, PanInfo } from "framer-motion";
import { useState } from "react";
export function useSwipeToDismiss(
onDismiss: () => void,
threshold = 0.4
) {
const x = useMotionValue(0);
const opacity = useTransform(x, [-200, 0, 200], [0, 1, 0]);
const rotate = useTransform(x, [-200, 200], [-15, 15]);
const [isDismissed, setIsDismissed] = useState(false);
function handleDragEnd(
event: MouseEvent | TouchEvent | PointerEvent,
info: PanInfo
) {
const width = (event.target as HTMLElement).offsetWidth;
const offset = Math.abs(info.offset.x);
const velocity = Math.abs(info.velocity.x);
if (offset > width * threshold || velocity > 500) {
const direction = info.offset.x > 0 ? 1 : -1;
setIsDismissed(true);
animate(x, direction * window.innerWidth, {
type: "spring",
stiffness: 400,
damping: 40,
velocity: info.velocity.x,
onComplete: onDismiss,
});
} else {
animate(x, 0, {
type: "spring",
stiffness: 500,
damping: 50,
velocity: info.velocity.x,
});
}
}
return { x, opacity, rotate, handleDragEnd, isDismissed };
}
Usage:
function SwipeableCard({ onDismiss, children }) {
const { x, opacity, rotate, handleDragEnd } = useSwipeToDismiss(onDismiss);
return (
<motion.div
style={{ x, opacity, rotate }}
drag="x"
dragConstraints={{ left: 0, right: 0 }} // constraints are elastic, not hard stops
dragElastic={0.7} // resistance when dragging past constraints
onDragEnd={handleDragEnd}
whileDrag={{ cursor: "grabbing" }}
>
{children}
</motion.div>
);
}
Framer Motion — drag-to-reorder:
<Reorder.Group axis="y" values={items} onReorder={setItems}>
{items.map((item) => (
<Reorder.Item key={item} value={item}>
{/* Framer handles drag + layout animation automatically */}
{item}
</Reorder.Item>
))}
</Reorder.Group>
Pull-to-refresh — elastic resistance:
const y = useMotionValue(0);
const clampedY = useTransform(y, (val) =>
val < 0 ? 0 : Math.min(val, 80 + (val - 80) * 0.3)
);
function handleDragEnd(_, info) {
if (info.offset.y > 80) {
onRefresh();
}
animate(y, 0, { type: "spring", stiffness: 400, damping: 40 });
}
Step 4: Accessibility and non-gesture fallback
Every gesture interaction must have a non-gesture equivalent:
- Swipe-to-dismiss → Delete button visible on keyboard focus
- Drag-to-reorder → Arrow key reordering with ARIA
aria-grabbed / aria-dropeffect
- Pull-to-refresh → Explicit "Refresh" button
Gesture-only interactions fail for keyboard users, switch users, and assistive technology. Add role, aria-label, and keyboard handlers alongside every gesture.
Additional Resources
For detailed native gesture patterns, React Native gesture handling, and iOS/Android physics parity, see:
references/gesture-patterns.md — Complete gesture type catalog, native feel guidelines, React Native equivalents, haptic feedback patterns