SuperAPI AI Directory

gestures

Agents & models

View repository

Updated 10-15 19:52

About this skill

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

  1. 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().top on pointerdown and 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.

  2. 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.11 dismisses 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 a 100ms timeout so momentum scroll is never read as a dismissal flick.

  3. 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-motion the interruption resolves as an immediate opacity swap; there is no presentation value to preserve.

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

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

  6. 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.55 constant 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.

  7. 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.998 for scroll-like feel, d = 0.99 for 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.

  8. 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 it 0.1–0.3. Exception: a drawer that can only ever be opened by a button and never dragged is time-driven — it goes back to motion and --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1).

  9. Guard every drag: pointer capture, one pointer, hysteresis. Without setPointerCapture the drag dies when the pointer leaves the element; without if (isDragging) return a second finger teleports it; without a ~10px threshold every tap becomes a micro-drag. Exception: a drag surface containing a scroller — shouldDrag must return false unless the scroller is at the top, or the sheet steals the scroll (Vaul).

  10. Feedback lives on pointerdown and continues through the gesture. Waiting for click to 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, ~10px hysteresis 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: 0 unless the gesture carried momentum, then 0.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