Skip to content

Fix laggy spring animations at 240hz refresh rates#3593

Merged
mattgperry merged 7 commits into
mainfrom
worktree-fix-issue-3265
Mar 9, 2026
Merged

Fix laggy spring animations at 240hz refresh rates#3593
mattgperry merged 7 commits into
mainfrom
worktree-fix-issue-3265

Conversation

@mattgperry
Copy link
Copy Markdown
Collaborator

Summary

  • Fix systematic velocity loss in useSpring/attachFollow at high refresh rates (240hz+)
  • Add analytical velocity derivative to the spring generator, eliminating frame-rate-dependent finite-difference errors
  • Use stable function reference for frame.postRender to deduplicate redundant callbacks from rapid input events

Problem

On 240hz displays, spring animations following a moving target (like useSpring tracking mouse position) appeared laggy and stuttered. The spring element fell ~22% behind compared to the same animation at 60hz.

Root cause: When the spring target changed, attachFollow stopped the animation and created a new one, using value.getVelocity() to preserve velocity. This finite-difference velocity estimate divides the frame-over-frame position change by the frame interval. At 240hz (~4ms frames), the spring barely moves per frame, so the measured velocity severely underestimates the true instantaneous velocity. This systematic velocity loss compounds every frame, causing the spring to progressively fall behind.

Fix

  1. Analytical spring velocity (spring/index.ts): Added a velocity(t) method to the spring generator that returns the exact derivative of the spring equation at time t, rather than approximating via finite difference. This gives accurate velocity regardless of frame rate.

  2. Generator velocity on JSAnimation (JSAnimation.ts): Added a generatorVelocity getter that reads the generator's analytical velocity when available, with finite-difference fallback for non-spring generators.

  3. Accurate velocity in attachFollow (follow-value.ts): When retargeting a running spring, use activeAnimation.generatorVelocity instead of value.getVelocity(). This preserves exact spring momentum across target changes.

  4. Callback deduplication (follow-value.ts): Extract the frame.postRender callback to a stable function reference so the frame loop's Set deduplicates multiple calls from rapid input events within a single frame.

Test plan

  • New test follow-value-framerate.test.ts verifies 240hz and 60hz spring positions are within 15% of each other (was ~22% off before fix)
  • All 18 existing spring-value tests pass
  • All 101 JSAnimation + spring generator tests pass
  • Full yarn test passes (400 motion-dom + 760 framer-motion tests)
  • yarn build succeeds

Fixes #3265

🤖 Generated with Claude Code

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Mar 4, 2026

Greptile Summary

This PR successfully fixes a critical bug where spring animations at 240hz refresh rates appeared laggy and fell ~22% behind compared to 60hz. The root cause was systematic velocity loss due to finite-difference estimation when springs retargeted.

Key Changes

  • Analytical spring velocity: Added exact velocity derivatives for all three spring types (underdamped, critically damped, overdamped) in the spring generator, providing frame-rate-independent velocity calculation
  • JSAnimation velocity getter: New generatorVelocity property uses analytical velocity when available, with finite-difference fallback for non-spring generators
  • Accurate velocity preservation: attachFollow now captures activeAnimation.generatorVelocity before stopping, preserving exact spring momentum across target changes instead of using the lossy value.getVelocity()
  • Callback deduplication: Extracted stable function reference for frame.postRender to prevent redundant callbacks from rapid input events

Test Coverage

New test verifies 240hz and 60hz spring positions are within 15% (was ~22% off before fix). All existing spring and animation tests continue to pass.

Minor Issue

Test helper has a logic bug in delta calculation (line 11-12) but doesn't affect test validity since animations use timestamps directly.

Confidence Score: 5/5

  • Safe to merge - mathematically sound fix with comprehensive test coverage
  • The fix addresses a real performance issue with a mathematically correct solution. The analytical velocity derivatives are properly implemented for all spring damping cases, the integration is clean with proper fallbacks, and test coverage validates the fix. The minor test helper issue doesn't affect correctness.
  • No files require special attention

Important Files Changed

Filename Overview
packages/motion-dom/src/animation/generators/spring/index.ts Added analytical velocity derivatives for underdamped, critically damped, and overdamped springs, eliminating frame-rate-dependent finite-difference errors
packages/motion-dom/src/animation/JSAnimation.ts Added generatorVelocity getter that uses analytical velocity when available with finite-difference fallback for non-spring generators
packages/motion-dom/src/value/follow-value.ts Uses activeAnimation.generatorVelocity instead of value.getVelocity() to preserve accurate velocity across retargets, and extracts stable callback reference for deduplication
packages/motion-dom/src/value/tests/follow-value-framerate.test.ts New test verifying spring consistency across frame rates, but has a minor issue in processFrame delta calculation logic

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Target changes on MotionValue] --> B[attachFollow schedules animation]
    B --> C{currentValue == targetValue?}
    C -->|Yes| D[Stop animation, no work needed]
    C -->|No| E{activeAnimation exists?}
    E -->|Yes| F[Capture activeAnimation.generatorVelocity<br/>analytical derivative]
    E -->|No| G[Use value.getVelocity<br/>finite difference]
    F --> H[Stop old animation]
    G --> H
    H --> I[Create new JSAnimation<br/>with captured velocity]
    I --> J{Generator has velocity method?}
    J -->|Yes - Spring| K[Use analytical spring.velocity<br/>exact derivative at time t]
    J -->|No - Other| L[Use finite difference fallback<br/>sample at t and t-5ms]
    K --> M[Animation runs with<br/>preserved momentum]
    L --> M
    
    style F fill:#90EE90
    style K fill:#90EE90
    style G fill:#FFB6C1
    style L fill:#FFB6C1
Loading

Last reviewed commit: 90c0f87

Comment on lines +11 to +12
frameData.timestamp = timestamp
frameData.delta = timestamp - (frameData.timestamp || 0) || 1000 / 60
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

frameData.timestamp is set before calculating frameData.delta, so delta will always be 0 and fall back to 1000/60. Capture the previous timestamp first:

Suggested change
frameData.timestamp = timestamp
frameData.delta = timestamp - (frameData.timestamp || 0) || 1000 / 60
const previousTimestamp = frameData.timestamp
frameData.timestamp = timestamp
frameData.delta = timestamp - (previousTimestamp || 0) || 1000 / 60

mattgperry and others added 7 commits March 9, 2026 08:56
Spring animations using useSpring/attachFollow were systematically losing
velocity at high frame rates because the animation was stopped and
restarted every frame with velocity estimated via finite difference.
At 240hz (~4ms frames), the finite difference severely underestimated the
spring's true velocity, causing the spring to fall ~22% behind vs 60hz.

Fix: Use the spring generator's analytical velocity derivative instead of
the MotionValue's frame-dependent finite difference. Also use a stable
function reference for frame.postRender to deduplicate redundant callbacks
from rapid input events within a single frame.

Fixes #3265

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace inline finite-difference fallback in generatorVelocity with
  existing calcGeneratorVelocity utility, removing duplicate logic
- Fix processFrame test helper: save previous timestamp before
  overwriting so delta is computed correctly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…y for rest detection

Merge spring/index.ts, spring/find.ts, spring/defaults.ts, and spring/utils.ts
into a single generators/spring.ts file. Replace numerical velocity approximation
(calcGeneratorVelocity) with analytical resolveVelocity in the spring's next()
method for more accurate rest detection. Update test snapshots for the slightly
earlier rest detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Restore isInDelayPhase guard on mixKeyframes (regressed #3351)
- Restore time setter else-branch for driverless case (regressed #3269)
- Inline shared trig computation in next() for underdamped springs,
  eliminating duplicate Math.exp/sin/cos calls per frame

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Change `get generatorVelocity()` to `getGeneratorVelocity()` method
- Tighten 240hz vs 60hz test tolerance from 15% to 10%

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@mattgperry mattgperry force-pushed the worktree-fix-issue-3265 branch from c1ce958 to 2c38dbb Compare March 9, 2026 07:56
@mattgperry mattgperry merged commit c6f2fac into main Mar 9, 2026
1 of 5 checks passed
@mattgperry mattgperry deleted the worktree-fix-issue-3265 branch March 9, 2026 08:02
mattgperry added a commit that referenced this pull request May 9, 2026
pull Bot pushed a commit to kokizzu/motion that referenced this pull request May 9, 2026
…amerate test

The Chrome-spring-feels-stuck behaviour reported in motiondivision#3407 has the same
root cause as motiondivision#3265 (fixed in PR motiondivision#3593, commit e931020): every
target update interrupts the spring animation, and reading
`value.getVelocity()` after `stopAnimation()` collapses to a
cross-animation finite difference of ~0. The fix reads the spring
generator's analytical velocity before stopping the animation.

Annotate the existing follow-value-framerate test so motiondivision#3407 traces back
to the same regression gate.
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] Laggy Animation at 240hz

1 participant