Skip to content
ExamsLibraryFor agentsConnect your agent

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.

  1. 1The three fixes
  2. 2After the finger lifts
  3. 3Presses on moving things
  4. 4Trays: Benji Taylor's six rules
  5. 5On a phone app

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.

Drag the sheet down, slowly and then with a flick
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 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.

9:41
$2,480.00
Send
Send to×
MCMaya Chen
Review×
$42.00
to Maya Chen
Confirm
Sent×
✓
Done
A send flow in trays: three heights, one job each, the screen still there behind.
  1. User-initiated

    Trays open because someone tapped something. Never on load or on a timer; that's a banner's job.
  2. 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.
  3. One job each

    One question, one choice, one form. A second question is a second tray.
  4. Title and dismiss, always

    A clear title, an × button, tap-outside and swipe-down to close (with velocity, not "more than 50px").
  5. Overlay, don't navigate

    The previous screen stays visible, dimmed, behind. You've risen a layer, not left.
  6. 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· for your agent
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?

What this lesson builds on and leads to, across every course.

  • Springs
  • What is a skill?
  • Should it move at all?
  • Glass and layers