Skip to content

Modal: velocity-aware release spring and rubber band for the swipe gesture - #670

Merged
blvdmitry merged 6 commits into
mainfrom
claude/animation-performance-j38gm8-modal-drag-physics
Sep 12, 2026
Merged

blvdmitry merged 6 commits into
mainfrom
claude/animation-performance-j38gm8-modal-drag-physics

Conversation

@blvdmitry

Copy link
Copy Markdown
Contributor

Summary

The swipe-to-close gesture had three physics gaps:

  1. Release ignored the finger speed. Closing always ran the base 150ms accelerate transition from the dragged position, so a flick started slow (ease-in from rest) and a slow release jumped to a much higher speed.
  2. Returning to the open position snapped. resetDragData() wrote --rs-modal-drag: 0px synchronously in touchend, while the --dragging class (transition: none) was still in the DOM until React committed, so there was no transition at all.
  3. Overdrag was hard-clamped to 0 in both JS and CSS (max(var(--rs-modal-drag), 0px)), so pulling a bottom sheet up or a drawer inward did nothing.

Velocity-aware release

  • The release velocity is measured over the last 100ms of touch samples.
  • Close decision: a flick faster than 0.4px/ms in the closing direction closes regardless of distance (same threshold Vaul uses); a drag past the existing 32px threshold closes unless the finger was moving back on release; everything else returns.
  • The release animation is resolved from a critically damped spring (ω = 20/s) that starts at the measured velocity, sampled at 120Hz into a CSS linear() easing with a matching duration (resolveSpringTransition in utilities/animation.ts). The transition is still a plain CSS transform transition on the compositor; JS only computes the easing once on release. Velocity fed to the spring is capped at 3px/ms to bound the overshoot.
  • Browsers without linear() support (CSS.supports check, cached) fall back to a velocity-scaled duration clamped to 100–300ms with the decelerate easing.
  • Inline transition-duration/transition-timing-function are applied on release and cleared on transitionend and on the next drag start, so they never leak into a regular close.
  • The return-to-open reset now runs in a layout effect after the --dragging class is gone, so it animates through the same spring.

Rubber band

  • Dragging past the open position applies limit · x · r / (x · r + limit) with a 40px limit and 0.55 initial resistance (the iOS-style curve: follows the finger at first, then saturates). The drag distance is no longer clamped, so the band tracks the finger back the way it came.
  • It is only enabled when no element between the touch target and the modal root can scroll in that direction (scrollHeight > clientHeight, or scrollWidth > clientWidth for drawers). On a scrollable sheet the content scrolls exactly as before.
  • The CSS max()/min() clamps are removed, and a solid box-shadow bleed (100px, modal background color) extends the sheet past its trailing edge so neither the rubber band nor a spring overshoot reveals a gap. A shadow is used instead of Vaul's ::after bleed because the modal root is the scroll container, where an absolutely positioned pseudo-element would be clipped or add scrollable overflow.

Related Issue

N/A

Screenshots / Recordings

Verified in headless Chromium with synthetic touch gestures on the position and edge cases stories:

Scenario Result
Fast flick 60px in ~40ms (v≈1.5px/ms) closes, 375ms spring, keeps moving down immediately after release
Flick at 150px (v≈3px/ms) closes, 192ms spring
Slow drag 60px then hold closes, 408ms spring from rest
Slow drag 30px (under threshold) returns, no leftover inline styles
Drag 120px then flick back up stays open, returns through the spring
Pull up 200px on a non-scrollable sheet rubber band lifts the sheet 27px, no gap at the bottom edge, springs back
Pull up on a scrollable sheet (edge cases story) no rubber band, content scrolls (scrollTop 135)
Start drawer: drag inward / drag outward closes / rubber bands 21px and returns

Notes for Reviewers

  • Modal and Overlay story tests pass locally (23 tests).
  • Independent of #669 (touches different lines of the same file) and #667. Once Overlay: animate backdrop opacity instead of background-color #667 lands, the overlay backdrop could get the same release transition; today it restores its opacity instantly on return, as it did before this PR.
  • A quick flick's very first touchmove can still be missed because the document listeners are attached in an effect after setDragging(true) commits; that's pre-existing and only affects the initial sample.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EAi9QQVSFuJ7Gkf8SUTYJq


Generated by Claude Code

…sture

The release animation ignored the gesture: closing used a fixed 150ms
accelerate transition regardless of the finger speed, and returning to
the open position reset the drag offset while the dragging class still
disabled transitions, so it snapped back.

On release the velocity is measured over the last 100ms of the gesture
and the animation is resolved from a critically damped spring starting
at that velocity, rendered as a css linear() easing with a matching
duration. A flick above 0.4px/ms closes the modal regardless of the
distance, a drag past the threshold closes unless the finger was moving
back, and everything else returns to the open position through the
same spring. Browsers without linear() support fall back to a
velocity-scaled duration with the decelerate easing.

Dragging past the open position now applies a rubber band with a 40px
limit, but only when nothing under the finger could scroll in that
direction instead. The css clamps on the drag offset are removed and a
solid box-shadow bleed extends the background past the trailing edge so
neither the rubber band nor a spring overshoot reveals a gap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAi9QQVSFuJ7Gkf8SUTYJq
@github-actions

github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

size-limit report 📦

Path Size Loading time (3g) Running time (snapdragon) Total time
Library / JS 51.53 KB (+1.71% 🔺) 1.1 s (+1.71% 🔺) 1.8 s (-15.96% 🔽) 2.9 s
Library / CSS 23.77 KB (0%) 476 ms (0%) 0 ms (+100% 🔺) 476 ms
Theming / JS 7.78 KB (0%) 156 ms (0%) 128 ms (+58.04% 🔺) 283 ms
Theming with a default theme definition / JS 8.69 KB (0%) 174 ms (0%) 208 ms (-14.63% 🔽) 382 ms

@blvdmitry
blvdmitry marked this pull request as ready for review September 12, 2026 17:55
@blvdmitry
blvdmitry merged commit cf7ac31 into main Sep 12, 2026
11 checks passed
@blvdmitry
blvdmitry deleted the claude/animation-performance-j38gm8-modal-drag-physics branch September 12, 2026 18:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants