Repository navigation
[BUG] Window scroll is compensated twice during drag, making Reorder auto-scroll run away #3944
Description
1. Read the FAQs 👇
Done.
2. Describe the bug
While dragging, PanSession.handleScroll adds the window scroll delta to lastMoveEventInfo.point, assuming the last pointer point was captured before that scroll. But if a pointermove is handled after the window has scrolled and before the matching scroll event fires, its pageY already includes the new scroll. handleScroll then adds the same delta a second time, so the drag point ends up below the real pointer by one frame's scroll amount.
With Reorder on a page that scrolls the document (no overflow: auto container, supported since #3508), this feeds back into itself:
- Auto-scroll reads the drifted point, decides the pointer is past the edge, and scrolls more than
maxSpeed(25px). The intensity is not capped at 1 when the pointer looks like it is beyond the edge. - The next
pointermovere-captures the real point, and the following scroll event adds that larger delta again. - The drift grows every round. In one Safari drag it went 95 → 156 → 347 → 1,426px over four pointermoves, and the next call scrolled 21,231px.
In practice the list jumps straight to the top or bottom while you drag. Sometimes the dragged item flies off far enough that the document gets taller.
Instrumented log on 12.43.0 (Chromium), with the pointer held still 2px above the bottom edge of an 800px viewport:
pointermove clientY 798, pageY 858 (= 798 + scrollY 60) ← correct
handleScroll delta 15 → point 858 + 15 = 873 ← counted twice, 15px too low
autoScroll viewport point 813 (real 798) → amount 39.7 ← above maxSpeed 25
handleScroll delta 39.5 → drift stays 15 …
The relevant code is unchanged on main (v14.3.0), and the repro below fails on 14.3.0.
3. CodeSandbox reproduction
https://codesandbox.io/s/4lw8kg (preview in a full window: https://4lw8kg.csb.app/)
It's a page-scrolling Reorder.Group with 40 items. It wraps window.scrollBy and counts auto-scroll calls above 25px while the pointer is inside the viewport, which the auto-scroll formula should never produce.
4. Steps to reproduce
- Open the sandbox in a full window.
- Drag "Item 3" down to the bottom edge of the window (stay inside the window).
- Hold still for a second, then wiggle slightly while staying at the edge.
- The readout shows calls above 25px. On wiggle the page jumps to the end, and single
scrollBycalls can reach thousands of px.
5. Expected behavior
The drag point should match the pointer, so a single auto-scroll call stays at or below 25px while the pointer is inside the viewport.
6. Video or screenshots
Measured with the repro on framer-motion 14.3.0 (Playwright, 1280×800, pointer 2px above the bottom edge):
| Hold still | Wiggle at edge | |
|---|---|---|
| Chromium | 38/47 calls > 25px, max 28.1px | max 14,151,139px; document grew from 5,668 to 43,372px |
| WebKit | 37/45 calls > 25px, max 27.0px | max 7,293px, jumped to the end |
On 12.43.0 with a real mouse or touch, the largest single call was 39.7px in Firefox 155, 1,714,438px in Safari 26.6.2 (macOS), and 17,073px with touch on iOS 18.7 (WKWebView). Chrome 155 with a real mouse showed up to 14.5px of drift.
7. Environment details
- framer-motion 14.3.0 and 12.43.0, React 19
- macOS: Chrome 155, Firefox 155, Safari 26.6.2; iOS 18.7 (WKWebView, touch)
Suggested fix
pageX/pageY from a pointer event already include the current window scroll, so reset the window baseline whenever a new pointer point is taken:
private handlePointerMove = (event: PointerEvent, info: EventInfo) => {
+ if (this.scrollPositions.has(window)) {
+ this.scrollPositions.set(window, { x: window.scrollX, y: window.scrollY })
+ }
this.lastMoveEvent = eventElement scroll is unaffected: element scroll doesn't change pageX/pageY, and that branch adjusts the history origin instead.
Results with this change applied to 14.3.0 in the same repro:
| Hold still | Wiggle at edge | |
|---|---|---|
| Chromium | 0/46 calls > 25px, max 23.0px | 0/46, max 23.0px |
| WebKit | 0/45 calls > 25px, max 23.0px | 0/40, max 23.0px |
23.0px is the expected 25 × (1 − 2/50)² for a pointer 2px from the edge, so the drift is gone. With the pointer held completely still, handleScroll still keeps the dragged item under the pointer while the page auto-scrolls. I checked this on 12.43.0: the offset stayed constant while scrollY went from 98 to 640.
A Cypress case could go next to "Drag with window scroll during drag" in drag-scroll-while-drag.ts: scroll the window, then dispatch a pointermove whose pageY already includes that scroll before the scroll event fires, and assert that the element doesn't move by the extra delta. I haven't written it.
Separately, getScrollAmount doesn't cap intensity at 1, so a pointer that really is beyond the edge (for example, a mouse dragged below the window) also scrolls faster than maxSpeed. Capping it would also bound the damage from any future drift.
Activity