Motion That Follows a Finger
A gesture-driven element is glued to the pointer from the first pixel and stays glued until release, at which point the animation that finishes the job starts at the pointer's exact velocity — so there is no seam between dragging and animating. That is the whole discipline. Benji Taylor's model is the one to hold in your head: "a fluid interface is akin to moving through water — you float rather than walk through it", and the app has "unbreakable physical rules" that every surface obeys. The default tool is therefore a spring, not a curve: { type: "spring", duration: 0.5, bounce: 0 }, dismissing on velocity rather than distance. The dividing line with motion is what drives the clock: a timer means motion, a position means gestures. Hit-target size, tap latency, and hover-on-touch belong to touch-input; whether the app as a whole reads as installed belongs to native-feel.
Adopt the project's gesture layer; do not add a second one. Grep for vaul, @use-gesture, embla, Motion's drag / useDragControls / dragConstraints, a bare pointerdown handler, touch-action, or CSS scroll-snap. Two gesture systems on one surface fight over pointer capture and produce a drag that sometimes scrolls. If the project solves sheets with Vaul, the next sheet is a Vaul sheet — and if the interaction is genuinely a snap carousel, scroll-snap plus overscroll-behavior beats any library.
Quick Reference
| When | Open |
|---|---|
| Choosing spring parameters, or handing a release velocity to the animation that finishes a drag | springs.md |
| Before building a drawer, bottom sheet, swipeable row, or pull-to-refresh — each has thresholds and guards you should not invent | drag-recipes.md |
The three numbers this skill owns
// 1. Dismiss on velocity, not distance — a flick should be enough (Emil Kowalski)
const velocity = Math.abs(swipeAmount) / timeTaken; // px per ms
if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) dismiss();
// 2. Project where the throw is going before choosing a snap target (Apple, WWDC 2018)
const project = (v, d = 0.998) => (v / 1000) * d / (1 - d); // d = 0.99 for a shorter throw
const target = nearestSnapPoint(current + project(releaseVelocity));
// 3. Resist past a boundary instead of stopping dead (Apple's rubber-band)
const rubberband = (overshoot, dimension, c = 0.55) =>
(overshoot * dimension * c) / (dimension + c * Math.abs(overshoot));
Everything else derives from these three plus the element's own dimensions. If you reach for a fourth magic number, express it as a fraction of the element instead.
Core Principles
Track 1:1 from the grab point, not the element's centre. Snapping to the pointer on grab breaks the illusion in the first frame, and people grab sheets by their edges. Capture
grabOffset = e.clientY - el.getBoundingClientRect().toponpointerdownand subtract it for the whole drag. Exception: a slider clicked on its track issues a set-value command rather than grabbing a thing — jumping the thumb there is correct.Dismiss on velocity, not distance. Requiring a long drag makes a flick fail, and a flick is what people actually do.
velocity = |swipeAmount| / timeTaken > 0.11dismisses regardless of how far the element travelled (Emil Kowalski, from Vaul and Sonner). Exception: a drag that began while the container was still scrolling fast — Vaul suppresses it with a100mstimeout so momentum scroll is never read as a dismissal flick.Interrupt from the presentation value, never the target value. Read the element's live on-screen transform and start the new animation there; starting from the logical target produces a visible jump. This is why springs beat curves for anything grabbable — a spring animates from the current value, a keyframe restarts at frame one. Exception: under
prefers-reduced-motionthe interruption resolves as an immediate opacity swap; there is no presentation value to preserve.Blend velocity through a re-target; never hard-cut it. Replacing one animation with another at a reversal creates a velocity discontinuity that reads as a brick wall — the thing was moving, then instantly was not. Use a spring library that carries velocity through a re-target (iOS does this natively with additive animations). Exception: a reversal that passes through zero velocity — the user stopped, then went the other way — has nothing to blend; start the new spring at rest.
Decompose 2D drags into independent X and Y springs. A single spring driving a 2D distance desyncs the moment the axes have different velocities, and the element curves where it should not. Exception: an axis-locked surface — a drawer that only moves vertically — needs exactly one spring; a second is overhead and one more thing to interrupt.
Rubber-band at every boundary. A hard stop reads as frozen, as if input stopped being received; progressive resistance reads as responsive with nothing more to see. Apply the
0.55constant above, so the further past the bound the user drags, the less the element follows. Exception: a locked boundary with genuinely nothing beyond it, where a hard stop plus an explicit message beats resistance that leads nowhere.Project the resting point before snapping, and snap to the point nearest the projection — not to the release position. Snapping from where the finger left makes a hard flick and a slow drag behave identically. Use
d = 0.998for scroll-like feel,d = 0.99for a shorter throw. Exception: a two-state surface where velocity sign alone decides — a fast flick towards closed closes it even if the projection lands short.Springs for anything grabbable; curves for anything that is not. Springs retain velocity across interruption; CSS transitions and keyframes do not. Default
{ duration: 0.5, bounce: 0 }, adding bounce only when the gesture itself carried momentum and keeping it0.1–0.3. Exception: a drawer that can only ever be opened by a button and never dragged is time-driven — it goes back tomotionand--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1).Guard every drag: pointer capture, one pointer, hysteresis. Without
setPointerCapturethe drag dies when the pointer leaves the element; withoutif (isDragging) returna second finger teleports it; without a~10pxthreshold every tap becomes a micro-drag. Exception: a drag surface containing a scroller —shouldDragmust returnfalseunless the scroller is at the top, or the sheet steals the scroll (Vaul).Feedback lives on
pointerdownand continues through the gesture. Waiting forclickto acknowledge a press is the moment directness falls off a cliff, and a drag that only animates once it completes is not a drag. Exception: a destructive control may withhold commitment until release — never the acknowledgement of the press.
Smell / Fix
| Smell | Fix |
|---|---|
| Dismissal requires crossing a distance threshold | Add the velocity test, > 0.11 |
| Element jumps to centre on grab | Subtract the grab offset for the whole drag |
| Reversing mid-flight snaps or restarts | Spring from the presentation value, blend velocity |
| Drag dies when the pointer leaves the element | setPointerCapture(e.pointerId) |
| Second finger teleports the element | Ignore new pointers once dragging |
| Hard stop at the edge | Rubber-band with the 0.55 constant |
| Flick and slow drag land in the same place | Project the resting point, then snap |
@keyframes or a CSS transition on a draggable surface |
A spring — keyframes restart from zero |
| A drag inside a scrollable area fights the scroll | shouldDrag gate on scroll-top, plus touch-action |
setProperty('--swipe-amount') on the container |
Write element.style.transform — custom properties inherit and recalc every child |
| Diagonal drag curves oddly | Independent X and Y springs |
| Bounce on a dialog or a menu | bounce: 0; bounce belongs to momentum, not to interruptions |
Output
Specify a gesture as a contract before writing it: the tracked axis, the guard conditions, the commit test (velocity and distance), the spring that finishes it, the boundary behaviour, and the reduced-motion path. Give the numbers — 0.11, bounce: 0.2, d = 0.998. "Feels draggy" is not a specification.
Checklist
- 1:1 tracking from the grab point, feedback on
pointerdown -
setPointerCapture, multi-touch ignored,~10pxhysteresis before committing to an axis - Commit test includes velocity
> 0.11, not distance alone - Release velocity is handed to the finishing animation
- Resting point projected before snapping
- Boundaries rubber-band; nothing hard-stops without a reason
- Interruption starts from the presentation value and blends velocity
- 2D motion decomposed into independent X and Y springs
-
bounce: 0unless the gesture carried momentum, then0.1–0.3 - Scroll-versus-drag arbitration resolved (
shouldDrag,touch-action) - Reduced-motion path: opacity resolution, no travel
- Tested on a real device, not only a desktop pointer