# Gestures

> Use when motion follows a finger or cursor: drag, swipe-to-dismiss, sheets, momentum, velocity thresholds, rubber-banding, and interrupting a spring mid-flight.

- Skill: `agentsorg/gestures` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add agentsorg/gestures`
- Raw SKILL.md: https://api.skillmd.com/api/skills/agentsorg/gestures/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: agentsorg (https://skillmd.com/u/agentsorg)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/agentsorg/gestures

---



# 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](references/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](references/drag-recipes.md) |

## The three numbers this skill owns

```js
// 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

