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/packages/motion-dom/src/animation/NativeAnimation.ts b/packages/motion-dom/src/animation/NativeAnimation.ts index b8bfefd865..8ee336901a 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,11 +227,11 @@ export class NativeAnimation } get startTime() { - return Number(this.animation.startTime) + return this.manualStartTime ?? Number(this.animation.startTime) } 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 c97a1187f4..e0a3455a4b 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" @@ -43,7 +44,7 @@ export class NativeAnimationExtended< super(options) - if (options.startTime) { + if (options.startTime !== undefined) { this.startTime = options.startTime } @@ -74,12 +75,18 @@ export class NativeAnimationExtended< autoplay: false, }) - const sampleTime = secondsToMilliseconds(this.finishedTime ?? this.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 = Math.max(sampleDelta, time.now() - this.startTime) + 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..7f6dc937c8 100644 --- a/tests/animate/animate.spec.ts +++ b/tests/animate/animate.spec.ts @@ -511,3 +511,4 @@ test.describe("NativeAnimation", () => { expect(await box.innerText()).toBe("finished") }) }) +