Fix jumpy scroll-linked animations during native smooth scrolling#3756
Fix jumpy scroll-linked animations during native smooth scrolling#3756mattgperry wants to merge 1 commit into
Conversation
Scroll progress was measured only in response to `scroll` events. Native smooth scrolling (mouse wheel, trackpad momentum) advances the scroll position on the compositor and delivers `scroll` events coalesced or a frame late, so some frames rendered a stale scroll progress, producing the visible jumps reported in #2716. Read the scroll position on the frame loop while the container is scrolling instead of relying solely on event delivery, stopping once the position settles so the frameloop can sleep again. Fixes #2716 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Greptile SummaryThis PR fixes jumpy scroll-linked animations during native smooth scrolling (mouse wheel / trackpad) by switching from pure scroll-event-driven measurement to a per-frame measurement loop that runs while the container is scrolling, then sleeps once the position has been stable for
Confidence Score: 4/5Safe to merge for the vast majority of users; one edge case in reversed-direction scroll containers warrants a one-line fix. The frame-loop approach correctly addresses the compositor-timing race for the common case, the idle-sleep mechanism is sound, and the regression test is well-constructed. The one live defect is that packages/framer-motion/src/render/dom/scroll/track.ts — the prevTop/prevLeft sentinel initialisation on lines 88-89. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["scrollInfo() called"] --> B["First time for container?\n(scrollListeners check)"]
B -- Yes --> C["Create measureLoop closure\n(settleFrames, prevTop, prevLeft)"]
C --> D["Attach scroll / resize listeners"]
D --> E["listener() called\n(scroll or resize event)"]
B -- No --> F["frame.read(listener, immediate)"]
F --> E
E --> G["settleFrames = 0\nframe.read(measureLoop, keepAlive)"]
G --> H["measureLoop runs each frame"]
H --> I{"scrollTop / scrollLeft changed?"}
I -- Yes --> J["settleFrames = 0\nUpdate prevTop/prevLeft"]
J --> K["measureAll() / ++settleFrames"]
I -- No --> K
K --> L{"settleFrames > maxSettleFrames (10)?"}
L -- No --> H
L -- Yes --> M["cancelFrame(measureLoop)\nFrame loop sleeps"]
M --> N{"New scroll/resize event?"}
N -- Yes --> E
N -- No --> M
Reviews (1): Last reviewed commit: "Fix jumpy scroll-linked animations durin..." | Re-trigger Greptile |
| let settleFrames = 0 | ||
| let prevTop = -1 | ||
| let prevLeft = -1 |
There was a problem hiding this comment.
Sentinel value
-1 is a valid scrollTop/scrollLeft
In reversed-direction layouts (e.g. column-reverse, row-reverse, writing-mode: vertical-rl) some browsers report scrollTop / scrollLeft as negative values, and -1 is a legitimate initial position. If a container starts at exactly scrollTop = -1, the first invocation of measureLoop evaluates top !== prevTop as -1 !== -1 → false, so prevTop is never updated and settleFrames immediately starts counting toward maxSettleFrames. After 10 frames with the position "unchanged," the loop cancels. Any subsequent native-smooth-scroll movement (without a new scroll event) goes undetected — which is the exact regression this PR fixes. Using NaN as the sentinel (or a separate initialized boolean) guarantees the first run always treats the position as changed.
| let settleFrames = 0 | |
| let prevTop = -1 | |
| let prevLeft = -1 | |
| let settleFrames = 0 | |
| let prevTop = NaN | |
| let prevLeft = NaN |
Bug
When an animation is driven by scroll progress (
useScroll+useTransform), scrolling with a mouse wheel is intermittently jumpy, while dragging the scrollbar is smooth. Reported on Chrome / Windows 11.Reproduction (from the issue): a tall container with a box whose
ytracksscrollYProgress, so it should stay pinned as you scroll. With the wheel, it visibly jumps now and then.Cause
Motion measured the scroll position only in response to
scrollevents (track.tsattached ascrolllistener that scheduled a one-offframe.read(measureAll)).Native smooth scrolling — mouse wheel and trackpad momentum — advances the scroll offset on the compositor thread and delivers
scrollevents to the main thread coalesced or a frame late relative to the position that is actually being painted. Dragging the scrollbar scrolls synchronously on the main thread, so events line up with frames and there's no jump.Because measurement was event-driven, any painted frame whose scroll change wasn't accompanied by a delivered
scrollevent rendered a stale progress (the page moved but the scroll-linked value didn't), then snapped to the correct value on the next event — the visible jump.Fix
Read the scroll position on the frame loop while the container is scrolling, instead of relying solely on
scroll-event delivery. This is the standard approach for scroll-linked animation (e.g. GSAP ScrollTrigger, Locomotive) and decouples updates from event timing, so every painted frame uses the latest scroll position.To preserve Motion's idle-sleep behaviour, the per-frame read stops once the scroll position has been stable for a short window (
maxSettleFrames), and a subsequentscroll/resizeevent restarts it. There is no perpetual rAF when the page is static.Tests
Added a regression test in
scroll/__tests__/index.test.tsthat advances the frame loop without dispatching ascrollevent after the scroll position changes, and asserts the reported progress stays in sync. It fails against the previous event-driven implementation (progress stays stale) and passes with the frame-loop read.The visual jitter itself is a compositor/main-thread timing effect that can't be reproduced deterministically in JSDOM/Electron (programmatic scrolling sets
scrollTopsynchronously, like the scrollbar path), so the unit test is the regression gate for the underlying behaviour.motion/__tests__/componentandtypes— fail identically on a clean checkout because the package's owndistisn't built in this worktree; unrelated to this change.)Fixes #2716
🤖 Generated with Claude Code