Repository navigation
Modal: velocity-aware release spring and rubber band for the swipe gesture - #670
Merged
blvdmitry merged 6 commits intoSep 12, 2026
Merged
Conversation
…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
size-limit report 📦
|
blvdmitry
marked this pull request as ready for review
September 12, 2026 17:55
blvdmitry
deleted the
claude/animation-performance-j38gm8-modal-drag-physics
branch
September 12, 2026 18:25
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The swipe-to-close gesture had three physics gaps:
150msacceleratetransition from the dragged position, so a flick started slow (ease-in from rest) and a slow release jumped to a much higher speed.resetDragData()wrote--rs-modal-drag: 0pxsynchronously intouchend, while the--draggingclass (transition: none) was still in the DOM until React committed, so there was no transition at all.max(var(--rs-modal-drag), 0px)), so pulling a bottom sheet up or a drawer inward did nothing.Velocity-aware release
0.4px/msin 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.linear()easing with a matching duration (resolveSpringTransitioninutilities/animation.ts). The transition is still a plain CSStransformtransition 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.linear()support (CSS.supportscheck, cached) fall back to a velocity-scaled duration clamped to 100–300ms with thedecelerateeasing.transition-duration/transition-timing-functionare applied on release and cleared ontransitionendand on the next drag start, so they never leak into a regular close.--draggingclass is gone, so it animates through the same spring.Rubber band
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.scrollHeight > clientHeight, orscrollWidth > clientWidthfor drawers). On a scrollable sheet the content scrolls exactly as before.max()/min()clamps are removed, and a solidbox-shadowbleed (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::afterbleed 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
positionandedge casesstories:edge casesstory)Notes for Reviewers
touchmovecan still be missed because the document listeners are attached in an effect aftersetDragging(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