# Gestures, sheets and trays

> Make drags, flicks and bottom sheets feel native, from velocity on release to presses that land on moving things.

Module: Motion · Level: advanced · 10 min · https://school.agents.org.in/design/motion/gestures

Drag the sheet down slowly, about a third of the way, and let go. It snaps back. Now flick it down from the same spot. It's gone.

*[Interactive demo: Drag slowly, then flick. Switch to 'position only' to feel what's missing.]*

With "position only", the sheet ignores how you let go. A quick flick near the top snaps back, and it feels like the app didn't listen. With velocity, a throw is a throw. When a drag "feels off", it's almost always one of three things: **no velocity on release, no resistance at the edges, or no pointer capture.**

## The three fixes

**Velocity on release.** Measure how fast the finger was moving when it lifted. Above roughly 0.11 pixels per millisecond, treat it as a flick and dismiss in that direction; below, snap to whichever position is closer. Don't compute the velocity (How fast and in which direction something is moving, often in pixels per millisecond. Measured when a finger lifts, it tells a flick from a slow release) from the last two events; one late event can halve or double it. Keep the last ~20 pointer samples and measure across the final ~100ms, using each event's own timestamp.

**Resistance at the edges.** Drag the sheet *up* past its open position: it moves, but less and less. That's Apple's rubber band (The resistance you feel when dragging past an edge on iOS: the content follows less and less the further you pull, then springs back when you let go):

```ts
// x = distance past the edge, d = the container's size
const rubberband = (x: number, d: number, c = 0.55) => (x * d * c) / (d + c * Math.abs(x));
```

Near the edge it follows at about 55% of the finger; far past it the offset levels off, so no drag can fling content off-screen. A hard stop feels like hitting a wall.

**Pointer capture.** Call `setPointerCapture(e.pointerId)` on pointer down, and the drag keeps tracking even when a fast finger leaves the element. Also ignore second fingers (`if (!e.isPrimary) return`) so a stray touch doesn't break the gesture.

> **In plain words.** **Velocity** is speed with a direction, here in pixels per millisecond. **Pointer capture** tells the browser "keep sending this finger's moves to me until it lifts".

## After the finger lifts

A flick is a small input asking for a big output. The sheet above does two more things, both from Apple's *Designing Fluid Interfaces* talk as Emil Kowalski distilled it.

**Project, then pick.** Work out where the flick would come to rest, the way scrolling decelerates, then snap to the target nearest *that* point, not the release point:

```js
const project = (v /* px/s */, d = 0.998) => (v / 1000) * d / (1 - d);
const target = nearestSnapPoint(current + project(releaseVelocity));
```

At 1000px/s that's about 500px ahead; a gentle 200px/s stays on its current snap. Decide commit-or-reverse by the velocity's **direction**: a sheet dragged 70% open and flicked down closes.

**Hand the speed to the spring.** Start the spring with the release velocity so there's no seam between finger and animation. Projection without the hand-off picks the right target and then starts from rest, which looks like a stall. A few more rules for interruptible motion:

- Start any new animation from where the element is **drawn**, never from where it was heading.
- Never lock input during a transition. Grab a closing sheet and it follows your finger at once.
- Keep the **grab offset**: the element stays where your finger took it, never recentred.
- Two-dimensional throws get **one spring per axis**.

## Presses on moving things

A stack of toasts slides up after you dismiss one. You press the same spot three times quickly. Which toasts close?

Cheng Lou's answer, measured in his handwired project: test presses against where each item is **heading**, not where it's drawn. Then three quick presses close three toasts, the way rapid clicks close Safari tabs one after another. Test against the drawn position and only the first press lands; the rest hit the gap a toast is still sliding into. This is called hit testing (Working out which element a press landed on. For things still animating, test against where they are heading, so quick repeated presses land where people expect).

- **A press** goes to the destination. **The start of a drag** grabs what's drawn under the finger.
- **While dragging**, pin the spring to the pointer (position and target both follow it), so there's no springy lag.
- **After release**, keep the item above its neighbours until it settles, or it slides *under* them on the way home.
- **In a scrolling list**, select on release, not on press: a press that starts a scroll would otherwise select every row it touches.

## Trays: Benji Taylor's six rules

A tray (A sheet that rises from the bottom over the current screen for one short task; the next step stacks over it. Each tray has a title, a dismiss and its own height) is a sheet that rises from the bottom over the current screen, and the next one stacks over it. Family, the crypto wallet Benji Taylor designed, is built on them.

*[Interactive demo: A send flow in trays: three heights, one job each, the screen still there behind.]*

  **Step: User-initiated.** Trays open because someone tapped something. Never on load or on a timer; that's a banner's job.

  **Step: Heights vary.** Each tray in a stack is noticeably taller or shorter than the last. Same-height trays look like nothing happened. This is the rule teams break most.

  **Step: One job each.** One question, one choice, one form. A second question is a second tray.

  **Step: Title and dismiss, always.** A clear title, an × button, tap-outside and swipe-down to close (with velocity, not "more than 50px").

  **Step: Overlay, don't navigate.** The previous screen stays visible, dimmed, behind. You've risen a layer, not left.

  **Step: Short tasks only.** Confirm, enter a code, pick a person, rename. Onboarding, settings and long forms get real screens.

*(The tray in the demo is one tall sheet moved with transform, so its edge glides without animating height.)*

## On a phone app

If you build for React Native or Expo, the same rules apply with three changes: there's no hover, there are two threads, and a finger is on the element. Emil Kowalski's digest:

- Run motion on the UI thread with **Reanimated**, gestures with `Gesture.Pan()`, and never `setState` inside a gesture handler.
- **Use the platform's navigation** (native stack, form sheets, native tabs). Tabs never slide.
- Springs for anything a finger touched, with the release velocity: settle `{ duration: 400, dampingRatio: 1 }`, sheet `{ duration: 300, dampingRatio: 0.8, velocity }`.
- Press is `scale: 0.97` in 100–150ms; targets 44pt (48dp on Android), grown with `hitSlop` rather than visually.
- **Haptics on the causal frame**, once per action: a light impact when something snaps home, a notification for success or error. Never as the only feedback.
- Judge feel on a **release build** on your slowest phone. The simulator hides exactly the problems that matter.

**A sheet that feels native (paste into your agent):**

```text
Make the bottom sheet draggable with pointer events: setPointerCapture on pointerdown, ignore non-primary pointers, keep the grab offset. Past the open position apply a rubber band (x*d*0.55 / (d + 0.55*|x|)). On release, compute velocity over the last ~100ms of samples (event timestamps). Dismiss if velocity > 0.11 px/ms downward or it's past halfway; reverse if flicked up. Hand the release velocity to a spring { stiffness: 380, damping: 30 } and start from the drawn position. Never block input during the animation.
```

**Check yourself:** A user flicks a sheet downward quickly but releases it only 20% of the way down. What should happen?

- [ ] Snap back open, because it's less than 50% down
- [x] Dismiss it, because the release velocity says the user threw it
- [ ] Stop exactly where it was released
- [ ] Bounce several times, then settle open

*Why:* Above roughly 0.11 px/ms the release reads as a throw, so the direction of the velocity decides, not the position. Ignoring it is why position-only sheets feel like they didn't listen.

## In the skill

- https://school.agents.org.in/skill/motion/gesture-momentum.md
- https://school.agents.org.in/skill/motion/momentum-projection.md
- https://school.agents.org.in/skill/motion/animated-hit-testing.md
- https://school.agents.org.in/skill/motion/tray-rules.md
- https://school.agents.org.in/skill/motion/react-native-motion.md
