Skip to content

Fix jumpy scroll-linked animations during native smooth scrolling#3756

Closed
mattgperry wants to merge 1 commit into
mainfrom
worktree-fix-issue-2716
Closed

Fix jumpy scroll-linked animations during native smooth scrolling#3756
mattgperry wants to merge 1 commit into
mainfrom
worktree-fix-issue-2716

Conversation

@mattgperry

Copy link
Copy Markdown
Collaborator

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 y tracks scrollYProgress, so it should stay pinned as you scroll. With the wheel, it visibly jumps now and then.

const { scrollYProgress } = useScroll({ target: ref, offset: ['start start', 'end center'] })
const y = useTransform(() => (rootHeight - targetHeight) * scrollYProgress.get())

Cause

Motion measured the scroll position only in response to scroll events (track.ts attached a scroll listener that scheduled a one-off frame.read(measureAll)).

Native smooth scrolling — mouse wheel and trackpad momentum — advances the scroll offset on the compositor thread and delivers scroll events 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 scroll event 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 subsequent scroll/resize event restarts it. There is no perpetual rAF when the page is static.

Tests

Added a regression test in scroll/__tests__/index.test.ts that advances the frame loop without dispatching a scroll event 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 scrollTop synchronously, like the scrollbar path), so the unit test is the regression gate for the underlying behaviour.

  • All 51 scroll-related unit tests pass.
  • Full framer-motion client suite: 776 passed. (The only failing suites — motion/__tests__/component and types — fail identically on a clean checkout because the package's own dist isn't built in this worktree; unrelated to this change.)

Fixes #2716

🤖 Generated with Claude Code

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-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown

Greptile Summary

This 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 maxSettleFrames (10) frames.

  • track.ts: Introduces measureLoop, a closure-per-container that reads scrollTop/scrollLeft every frame, resets a settle counter when the position changes, calls measureAll() unconditionally, and cancels itself when the counter exceeds 10. A scroll or resize event restarts the loop by resetting the counter and re-scheduling via frame.read(measureLoop, true). A new measureProcesses WeakMap ensures the loop is properly cancelled on cleanup.
  • index.test.ts: Adds a regression test (#2716) that advances the frame loop without dispatching a scroll event after mutating scrollTop, asserting the reported progress stays in sync — a test that fails against the old event-driven path and passes with the new loop.

Confidence Score: 4/5

Safe 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 prevTop and prevLeft are initialised to -1, which is itself a valid scroll position in column-reverse/row-reverse/writing-mode: vertical-rl layouts — in those containers the loop cancels after 10 frames of apparent "no change" and re-introduces the stale-measurement window the PR is meant to fix.

packages/framer-motion/src/render/dom/scroll/track.ts — the prevTop/prevLeft sentinel initialisation on lines 88-89.

Important Files Changed

Filename Overview
packages/framer-motion/src/render/dom/scroll/track.ts Introduces per-frame measurement loop (measureLoop) to decouple scroll-progress reads from scroll-event delivery; settler logic uses -1 as a sentinel that is itself a valid scrollTop in reversed-direction layouts.
packages/framer-motion/src/render/dom/scroll/tests/index.test.ts Adds a regression test that advances the frame loop without dispatching a scroll event, correctly asserting that the new measureLoop picks up the stale position; test logic is sound and well-documented.

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
Loading

Reviews (1): Last reviewed commit: "Fix jumpy scroll-linked animations durin..." | Re-trigger Greptile

Comment on lines +87 to +89
let settleFrames = 0
let prevTop = -1
let prevLeft = -1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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 !== -1false, 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.

Suggested change
let settleFrames = 0
let prevTop = -1
let prevLeft = -1
let settleFrames = 0
let prevTop = NaN
let prevLeft = NaN

@mattgperry mattgperry closed this Jun 12, 2026
@mattgperry mattgperry deleted the worktree-fix-issue-2716 branch June 12, 2026 09:08
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.

[BUG] Jumpy Scrolling

1 participant