From b6841817bfa465895c74a6d7e81aec55efad0786 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 7 Jan 2026 18:12:32 +0100 Subject: [PATCH 1/6] Fix transform animation jumping under CPU load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When rapidly interrupting WAAPI animations under CPU load, the element would jump to incorrect positions. This was caused by using WAAPI's currentTime for sampling, which doesn't accurately reflect elapsed time when the main thread is blocked. The fix uses time.now() from the frameloop to track wall-clock elapsed time independently, ensuring accurate sampling regardless of main thread contention. - Add startedAt timestamp using time.now() in NativeAnimationExtended - Use elapsed wall-clock time for animation sampling instead of WAAPI currentTime - Add E2E test reproducing the rapid hover scenario - Document time.now() usage in CLAUDE.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 6 + CLAUDE.md | 4 + .../animate/animate-transform-jump.html | 205 ++++++++++++++++++ .../src/animation/NativeAnimationExtended.ts | 26 ++- tests/animate/animate.spec.ts | 20 ++ 5 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 dev/html/public/playwright/animate/animate-transform-jump.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 3830c5b630..be252e215e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [12.24.10] 2026-01-07 + +### Fixed + +- Fixed transform animation jumping when rapidly interrupting animations under CPU load. + ## [12.24.9] 2026-01-07 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index fe6d39bc11..2008dcbc01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,3 +92,7 @@ async function nextFrame() { - Prefer arrow callbacks - Use strict equality (`===`) - No `var` declarations (use `const`/`let`) + +## Timing + +Use `time.now()` from `motion-dom/src/frameloop/sync-time.ts` instead of `performance.now()` for frame-synced timestamps. This ensures consistent time measurements within synchronous contexts and proper sync with the animation frame loop. diff --git a/dev/html/public/playwright/animate/animate-transform-jump.html b/dev/html/public/playwright/animate/animate-transform-jump.html new file mode 100644 index 0000000000..9fee86a013 --- /dev/null +++ b/dev/html/public/playwright/animate/animate-transform-jump.html @@ -0,0 +1,205 @@ + + + + + +
+
0
+
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
+
+
ready
+
+ + + + diff --git a/packages/motion-dom/src/animation/NativeAnimationExtended.ts b/packages/motion-dom/src/animation/NativeAnimationExtended.ts index c97a1187f4..06371f6436 100644 --- a/packages/motion-dom/src/animation/NativeAnimationExtended.ts +++ b/packages/motion-dom/src/animation/NativeAnimationExtended.ts @@ -1,4 +1,5 @@ -import { secondsToMilliseconds } from "motion-utils" +import { clamp } from "motion-utils" +import { time } from "../frameloop/sync-time" import { JSAnimation } from "./JSAnimation" import { NativeAnimation, NativeAnimationOptions } from "./NativeAnimation" import { AnyResolvedKeyframe, ValueAnimationOptions } from "./types" @@ -20,6 +21,13 @@ export class NativeAnimationExtended< > extends NativeAnimation { options: NativeAnimationOptionsExtended + /** + * Track wall-clock time independently of WAAPI's currentTime. + * This ensures accurate sampling when the main thread is blocked + * and WAAPI's currentTime hasn't kept pace with real elapsed time. + */ + private startedAt: number + constructor(options: NativeAnimationOptionsExtended) { /** * The base NativeAnimation function only supports a subset @@ -43,6 +51,8 @@ export class NativeAnimationExtended< super(options) + this.startedAt = time.now() + if (options.startTime) { this.startTime = options.startTime } @@ -74,12 +84,20 @@ export class NativeAnimationExtended< autoplay: false, }) - const sampleTime = secondsToMilliseconds(this.finishedTime ?? this.time) + /** + * Use wall-clock elapsed time instead of WAAPI's currentTime. + * Under CPU load, WAAPI's currentTime may not reflect actual + * elapsed time, causing incorrect sampling and visual jumps. + */ + const elapsedTime = time.now() - this.startedAt + + const sampleTime = Math.max(sampleDelta, elapsedTime) + const delta = clamp(0, sampleDelta, sampleTime - sampleDelta) motionValue.setWithVelocity( - sampleAnimation.sample(sampleTime - sampleDelta).value, + sampleAnimation.sample(Math.max(0, sampleTime - delta)).value, sampleAnimation.sample(sampleTime).value, - sampleDelta + delta ) sampleAnimation.stop() diff --git a/tests/animate/animate.spec.ts b/tests/animate/animate.spec.ts index 36131f0781..8f095bcb96 100644 --- a/tests/animate/animate.spec.ts +++ b/tests/animate/animate.spec.ts @@ -511,3 +511,23 @@ test.describe("NativeAnimation", () => { expect(await box.innerText()).toBe("finished") }) }) + +test.describe("NativeAnimationExtended", () => { + test("rapid hover should not cause transform jump", async ({ page }) => { + await page.goto("animate/animate-transform-jump.html") + await page.waitForTimeout(100) + + // Trigger rapid hover simulation (uses dispatchEvent + requestAnimationFrame) + await page.evaluate(() => (window as any).simulateRapidHovers()) + + // Wait for simulation to complete (12 hovers + 200ms settle time) + await page.waitForTimeout(800) + + const result = page.locator("#result") + const text = await result.innerText() + + // The test reports "smooth:X" if no jump detected, "jump:X" if jump detected + // We expect smooth behavior (no jump > 80px) + expect(text).toMatch(/^smooth:\d+$/) + }) +}) From 28c39aae5764676d64e8c5c33fa6b800cc208e45 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 7 Jan 2026 19:20:26 +0100 Subject: [PATCH 2/6] Use manualStartTime pattern for accurate elapsed time tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of relying on WAAPI's dynamic startTime (which changes on pause/resume), use a manualStartTime property that gets cleared by play() and time setter, allowing WAAPI to take over timing when appropriate. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../animate/animate-transform-jump.html | 205 ------------------ .../src/animation/NativeAnimation.ts | 9 +- .../src/animation/NativeAnimationExtended.ts | 29 ++- tests/animate/animate.spec.ts | 19 -- 4 files changed, 25 insertions(+), 237 deletions(-) delete mode 100644 dev/html/public/playwright/animate/animate-transform-jump.html diff --git a/dev/html/public/playwright/animate/animate-transform-jump.html b/dev/html/public/playwright/animate/animate-transform-jump.html deleted file mode 100644 index 9fee86a013..0000000000 --- a/dev/html/public/playwright/animate/animate-transform-jump.html +++ /dev/null @@ -1,205 +0,0 @@ - - - - - -
-
0
-
1
-
2
-
3
-
4
-
5
-
6
-
7
-
8
-
9
-
-
-
ready
-
- - - - diff --git a/packages/motion-dom/src/animation/NativeAnimation.ts b/packages/motion-dom/src/animation/NativeAnimation.ts index b8bfefd865..fa391e3f72 100644 --- a/packages/motion-dom/src/animation/NativeAnimation.ts +++ b/packages/motion-dom/src/animation/NativeAnimation.ts @@ -45,6 +45,13 @@ export class NativeAnimation private isPseudoElement: boolean + /** + * Tracks a manually-set start time that takes precedence over WAAPI's + * dynamic startTime. This is cleared when play() or time setter is called, + * allowing WAAPI to take over timing. + */ + protected manualStartTime: number | null = null + constructor(options?: NativeAnimationOptions) { super() @@ -218,7 +225,7 @@ export class NativeAnimation } get startTime() { - return Number(this.animation.startTime) + return this.manualStartTime ?? Number(this.animation.startTime) } set startTime(newStartTime: number) { diff --git a/packages/motion-dom/src/animation/NativeAnimationExtended.ts b/packages/motion-dom/src/animation/NativeAnimationExtended.ts index 06371f6436..157c0d9556 100644 --- a/packages/motion-dom/src/animation/NativeAnimationExtended.ts +++ b/packages/motion-dom/src/animation/NativeAnimationExtended.ts @@ -21,13 +21,6 @@ export class NativeAnimationExtended< > extends NativeAnimation { options: NativeAnimationOptionsExtended - /** - * Track wall-clock time independently of WAAPI's currentTime. - * This ensures accurate sampling when the main thread is blocked - * and WAAPI's currentTime hasn't kept pace with real elapsed time. - */ - private startedAt: number - constructor(options: NativeAnimationOptionsExtended) { /** * The base NativeAnimation function only supports a subset @@ -51,15 +44,27 @@ export class NativeAnimationExtended< super(options) - this.startedAt = time.now() - - if (options.startTime) { - this.startTime = options.startTime + if (options.startTime !== undefined) { + this.manualStartTime = options.startTime } this.options = options } + play() { + this.manualStartTime = null + super.play() + } + + set time(newTime: number) { + this.manualStartTime = null + super.time = newTime + } + + get time() { + return super.time + } + /** * WAAPI doesn't natively have any interruption capabilities. * @@ -89,7 +94,7 @@ export class NativeAnimationExtended< * Under CPU load, WAAPI's currentTime may not reflect actual * elapsed time, causing incorrect sampling and visual jumps. */ - const elapsedTime = time.now() - this.startedAt + const elapsedTime = time.now() - this.startTime const sampleTime = Math.max(sampleDelta, elapsedTime) const delta = clamp(0, sampleDelta, sampleTime - sampleDelta) diff --git a/tests/animate/animate.spec.ts b/tests/animate/animate.spec.ts index 8f095bcb96..7f6dc937c8 100644 --- a/tests/animate/animate.spec.ts +++ b/tests/animate/animate.spec.ts @@ -512,22 +512,3 @@ test.describe("NativeAnimation", () => { }) }) -test.describe("NativeAnimationExtended", () => { - test("rapid hover should not cause transform jump", async ({ page }) => { - await page.goto("animate/animate-transform-jump.html") - await page.waitForTimeout(100) - - // Trigger rapid hover simulation (uses dispatchEvent + requestAnimationFrame) - await page.evaluate(() => (window as any).simulateRapidHovers()) - - // Wait for simulation to complete (12 hovers + 200ms settle time) - await page.waitForTimeout(800) - - const result = page.locator("#result") - const text = await result.innerText() - - // The test reports "smooth:X" if no jump detected, "jump:X" if jump detected - // We expect smooth behavior (no jump > 80px) - expect(text).toMatch(/^smooth:\d+$/) - }) -}) From 7bdbde6e90fd44c6d9a488992bb24c760aac944c Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 7 Jan 2026 19:37:01 +0100 Subject: [PATCH 3/6] Use original time derivation as fallback for sampleTime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/motion-dom/src/animation/NativeAnimation.ts | 2 +- .../src/animation/NativeAnimationExtended.ts | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/motion-dom/src/animation/NativeAnimation.ts b/packages/motion-dom/src/animation/NativeAnimation.ts index fa391e3f72..c7c0e300f7 100644 --- a/packages/motion-dom/src/animation/NativeAnimation.ts +++ b/packages/motion-dom/src/animation/NativeAnimation.ts @@ -225,7 +225,7 @@ export class NativeAnimation } get startTime() { - return this.manualStartTime ?? Number(this.animation.startTime) + return Number(this.animation.startTime) } set startTime(newStartTime: number) { diff --git a/packages/motion-dom/src/animation/NativeAnimationExtended.ts b/packages/motion-dom/src/animation/NativeAnimationExtended.ts index 157c0d9556..17a204e8a5 100644 --- a/packages/motion-dom/src/animation/NativeAnimationExtended.ts +++ b/packages/motion-dom/src/animation/NativeAnimationExtended.ts @@ -1,4 +1,4 @@ -import { clamp } from "motion-utils" +import { clamp, secondsToMilliseconds } from "motion-utils" import { time } from "../frameloop/sync-time" import { JSAnimation } from "./JSAnimation" import { NativeAnimation, NativeAnimationOptions } from "./NativeAnimation" @@ -90,13 +90,15 @@ export class NativeAnimationExtended< }) /** - * Use wall-clock elapsed time instead of WAAPI's currentTime. + * Use wall-clock elapsed time when manualStartTime is set, + * otherwise fall back to the animation's current time. * Under CPU load, WAAPI's currentTime may not reflect actual * elapsed time, causing incorrect sampling and visual jumps. */ - const elapsedTime = time.now() - this.startTime - - const sampleTime = Math.max(sampleDelta, elapsedTime) + const sampleTime = + this.manualStartTime !== null + ? Math.max(sampleDelta, time.now() - this.manualStartTime) + : secondsToMilliseconds(this.finishedTime ?? this.time) const delta = clamp(0, sampleDelta, sampleTime - sampleDelta) motionValue.setWithVelocity( From a6bfc6af07b84a66266ccc0a1f178d0dc1673251 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 7 Jan 2026 19:56:30 +0100 Subject: [PATCH 4/6] Simplify elapsed time calculation using startTime directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/animation/NativeAnimation.ts | 7 ----- .../src/animation/NativeAnimationExtended.ts | 28 ++----------------- 2 files changed, 3 insertions(+), 32 deletions(-) diff --git a/packages/motion-dom/src/animation/NativeAnimation.ts b/packages/motion-dom/src/animation/NativeAnimation.ts index c7c0e300f7..b8bfefd865 100644 --- a/packages/motion-dom/src/animation/NativeAnimation.ts +++ b/packages/motion-dom/src/animation/NativeAnimation.ts @@ -45,13 +45,6 @@ export class NativeAnimation private isPseudoElement: boolean - /** - * Tracks a manually-set start time that takes precedence over WAAPI's - * dynamic startTime. This is cleared when play() or time setter is called, - * allowing WAAPI to take over timing. - */ - protected manualStartTime: number | null = null - constructor(options?: NativeAnimationOptions) { super() diff --git a/packages/motion-dom/src/animation/NativeAnimationExtended.ts b/packages/motion-dom/src/animation/NativeAnimationExtended.ts index 17a204e8a5..7dc32772f3 100644 --- a/packages/motion-dom/src/animation/NativeAnimationExtended.ts +++ b/packages/motion-dom/src/animation/NativeAnimationExtended.ts @@ -1,4 +1,4 @@ -import { clamp, secondsToMilliseconds } from "motion-utils" +import { clamp } from "motion-utils" import { time } from "../frameloop/sync-time" import { JSAnimation } from "./JSAnimation" import { NativeAnimation, NativeAnimationOptions } from "./NativeAnimation" @@ -44,27 +44,9 @@ export class NativeAnimationExtended< super(options) - if (options.startTime !== undefined) { - this.manualStartTime = options.startTime - } - this.options = options } - play() { - this.manualStartTime = null - super.play() - } - - set time(newTime: number) { - this.manualStartTime = null - super.time = newTime - } - - get time() { - return super.time - } - /** * WAAPI doesn't natively have any interruption capabilities. * @@ -90,15 +72,11 @@ export class NativeAnimationExtended< }) /** - * Use wall-clock elapsed time when manualStartTime is set, - * otherwise fall back to the animation's current time. + * Use wall-clock elapsed time for sampling. * Under CPU load, WAAPI's currentTime may not reflect actual * elapsed time, causing incorrect sampling and visual jumps. */ - const sampleTime = - this.manualStartTime !== null - ? Math.max(sampleDelta, time.now() - this.manualStartTime) - : secondsToMilliseconds(this.finishedTime ?? this.time) + const sampleTime = Math.max(sampleDelta, time.now() - this.startTime) const delta = clamp(0, sampleDelta, sampleTime - sampleDelta) motionValue.setWithVelocity( From 5f80908f4b340ed04abce4e0a4e44dca75ddf8f2 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 7 Jan 2026 20:58:01 +0100 Subject: [PATCH 5/6] Use manualStartTime in NativeAnimation for accurate elapsed time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startTime getter returns manualStartTime when set, falling back to WAAPI's startTime. Both play() and time setter clear manualStartTime to allow WAAPI to take over timing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/motion-dom/src/animation/NativeAnimation.ts | 11 ++++++++++- .../src/animation/NativeAnimationExtended.ts | 4 ++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/motion-dom/src/animation/NativeAnimation.ts b/packages/motion-dom/src/animation/NativeAnimation.ts index b8bfefd865..5c30227ef2 100644 --- a/packages/motion-dom/src/animation/NativeAnimation.ts +++ b/packages/motion-dom/src/animation/NativeAnimation.ts @@ -45,6 +45,13 @@ export class NativeAnimation private isPseudoElement: boolean + /** + * Tracks a manually-set start time that takes precedence over WAAPI's + * dynamic startTime. This is cleared when play() or time setter is called, + * allowing WAAPI to take over timing. + */ + protected manualStartTime: number | null = null + constructor(options?: NativeAnimationOptions) { super() @@ -118,6 +125,7 @@ export class NativeAnimation play() { if (this.isStopped) return + this.manualStartTime = null this.animation.play() if (this.state === "finished") { @@ -192,6 +200,7 @@ export class NativeAnimation } set time(newTime: number) { + this.manualStartTime = null this.finishedTime = null this.animation.currentTime = secondsToMilliseconds(newTime) } @@ -218,7 +227,7 @@ export class NativeAnimation } get startTime() { - return Number(this.animation.startTime) + return this.manualStartTime ?? Number(this.animation.startTime) } set startTime(newStartTime: number) { diff --git a/packages/motion-dom/src/animation/NativeAnimationExtended.ts b/packages/motion-dom/src/animation/NativeAnimationExtended.ts index 7dc32772f3..3c067bade8 100644 --- a/packages/motion-dom/src/animation/NativeAnimationExtended.ts +++ b/packages/motion-dom/src/animation/NativeAnimationExtended.ts @@ -44,6 +44,10 @@ export class NativeAnimationExtended< super(options) + if (options.startTime !== undefined) { + this.manualStartTime = options.startTime + } + this.options = options } From 1d94a1fc465ab06f8e51f6f41f8e75ffe15c708f Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Wed, 7 Jan 2026 21:35:58 +0100 Subject: [PATCH 6/6] update startTime assignment --- packages/motion-dom/src/animation/NativeAnimation.ts | 2 +- packages/motion-dom/src/animation/NativeAnimationExtended.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/motion-dom/src/animation/NativeAnimation.ts b/packages/motion-dom/src/animation/NativeAnimation.ts index 5c30227ef2..8ee336901a 100644 --- a/packages/motion-dom/src/animation/NativeAnimation.ts +++ b/packages/motion-dom/src/animation/NativeAnimation.ts @@ -231,7 +231,7 @@ export class NativeAnimation } set startTime(newStartTime: number) { - this.animation.startTime = newStartTime + this.manualStartTime = this.animation.startTime = newStartTime } /** diff --git a/packages/motion-dom/src/animation/NativeAnimationExtended.ts b/packages/motion-dom/src/animation/NativeAnimationExtended.ts index 3c067bade8..e0a3455a4b 100644 --- a/packages/motion-dom/src/animation/NativeAnimationExtended.ts +++ b/packages/motion-dom/src/animation/NativeAnimationExtended.ts @@ -45,7 +45,7 @@ export class NativeAnimationExtended< super(options) if (options.startTime !== undefined) { - this.manualStartTime = options.startTime + this.startTime = options.startTime } this.options = options