Gestures, sheets and trays
advanced·10 min·Markdown for agents
What this lesson teaches
Make drags, flicks and bottom sheets feel native, from velocity on release to presses that land on moving things.
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.
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 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 :
// 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.
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:
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 .
- 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 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.
User-initiated
Trays open because someone tapped something. Never on load or on a timer; that's a banner's job.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.One job each
One question, one choice, one form. A second question is a second tray.Title and dismiss, always
A clear title, an × button, tap-outside and swipe-down to close (with velocity, not "more than 50px").Overlay, don't navigate
The previous screen stays visible, dimmed, behind. You've risen a layer, not left.Short tasks only
Confirm, enter a code, pick a person, rename. Onboarding, settings and long forms get real screens.
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 neversetStateinside 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.97in 100–150ms; targets 44pt (48dp on Android), grown withhitSloprather 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.
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.Quick check
A user flicks a sheet downward quickly but releases it only 20% of the way down. What should happen?
In the skill
The nodes your agent reads when it does this work.
- Gesture momentumVelocity-based dismissal, damping at boundaries, pointer capture. The mobile-feel rules.
motion/gesture-momentum - Momentum projectionOn release, project where the flick would come to rest (current + (v/1000)·d/(1−d), d ≈ 0.998), snap to the target nearest that point, hand the release velocity to the spring, decide by velocity sign, run X and Y on separate springs, and always start from the on-screen value.
motion/momentum-projection - Animated hit testingWhile something animates, a press goes to where it is heading, not where it is drawn — except when the press starts a drag. Pin a dragged element's spring to the pointer, keep the just-released item above the rest until it settles, and select a row in a scroll list on release, not on press.
motion/animated-hit-testing - Tray rulesSix rules for tray-style modal stacks — user-initiated, varied heights, single focus, title + dismiss, context preservation, transient actions.
motion/tray-rules - React Native motionMotion in React Native and Expo lives on the UI runtime. Reanimated not core Animated, no setState per frame, springs for anything a finger touched, native navigation instead of hand-rolled transitions, haptics on the causal frame, judged on a release build.
motion/react-native-motion
Connected lessons
What this lesson builds on and leads to, across every course.