Skip to content

fix(text): keep motion-ticker in place across a hover pause - #3

Open
felixchen-wordup wants to merge 4 commits into
tgomilar:mainfrom
felixchen-wordup:fix/ticker-hover-resume
Open

fix(text): keep motion-ticker in place across a hover pause#3
felixchen-wordup wants to merge 4 commits into
tgomilar:mainfrom
felixchen-wordup:fix/ticker-hover-resume

Conversation

@felixchen-wordup

@felixchen-wordup felixchen-wordup commented Aug 13, 2026

Copy link
Copy Markdown

Fixes #2.

<motion-ticker> with pause-on-hover (the default) snapped the track back to x: 0 on mouse leave, and stopped dead on hover instead of decelerating. Both come from the same place.

Root cause

onEnter paused before ramping:

if (this.playState === 'running') this.pause()   // ctrls.pause() on frame 1
this.lerpRate(0)                                 // then ramp speed 1 -> 0
  1. No deceleration. this.pause()handle.pause()ctrls.pause() happens first, so the lerpRate(0) ramp that follows runs against an already-paused animation and renders nothing.

  2. The jump. The ramp still keeps assigning ctrls.speed = currentRate down to the < 0.003 threshold. Resuming from that speed is what loses the position — MainThreadAnimation.play() rebases as

    this.startTime = now - this.holdTime      // not holdTime / speed

    while tick() reads it back as

    this.currentTime = Math.round(timestamp - this.startTime) * this.speed

    so the resumed time comes out multiplied by the current speed. At speed ≈ 0.003 that turns ~0.7 s of progress into ~0.002 s, i.e. the first frame of x: [0, -w].

Measured on the fixture before the fix — the track is frozen through the whole hover, then snaps:

running       transform=translateX(-41.94px)  playState=running  time=0.699  speed=1
hover +100ms  transform=translateX(-41.94px)  playState=paused   time=0.699  speed=0.282
hover settled transform=translateX(-41.94px)  playState=paused   time=0.699  speed=0.0027
leave +50ms   transform=translateX(-0.79px)   playState=running  time=0.0131 speed=0.469

The hover fix (ca20f57)

  • onEnter no longer pauses up front. It just calls lerpRate(0), and the ramp pauses through the playback controller once the rate reaches MIN_RATE. The animation stays live while it decelerates, so the ramp is visible and time keeps meaning something. playState still ends up 'paused', just at the end of the ramp rather than at the start — which also seems more truthful, since it is genuinely still running while it slows down.
  • resumeCtrls() restores time around play(), the same way the resize path does. This is what actually guarantees the position survives, independent of what play() does to startTime.
  • ctrls.speed is floored at MIN_RATE (0.05) rather than being driven toward 0, so the degenerate range is never entered in the first place.
  • The paused field is gone — playState was already tracking the same thing, and keeping both in sync was what made the two handlers hard to follow.

The keyboard path (Space / Enter) had the inverted order too, with a different symptom: there lerpRate(0) ran first and this.pause() then cancelled rateRaf outright, so the ramp never got a frame and Space stopped the ticker dead. It now goes through the same path as hover.

Review follow-up: the rebuild path (a40a93d, 245b17c, 71b6ceb)

The review pointed out that attributeChangedCallback rebuilds the animation at full speed while the component still reports paused. Auditing that path as a whole (every observed attribute against every playback state) surfaced a family of defects, all reproducible on main:

  • Rebuilds ignored the pause state and the rate — and onResize had the same flaw as attributeChangedCallback in a different costume (it wrote ctrls.speed = 0 while hover-paused, which makes time read back 0 and loses the position on resume). Both callbacks now share one rebuildMarquee() that floors the speed at MIN_RATE, holds the fresh animation directly via ctrls.pause() (the controller's own pause() is a no-op when already paused), and reads ctrls.duration once before assigning time so motion's async keyframe resolver is flushed — without that, the next frame's play() restarts the rebuilt animation from 0.
  • gap wasn't actually live: its styles were written once in build(), so a live change altered the animation maths but not the rendering, and the loop seam drifted by the delta. One applyGap() now runs before every measurement.
  • A direction flip teleported the track — the progress fraction maps to mirrored positions on [0,-w] vs [-w,0]. Rebuilds now derive their time from the rendered offset, which holds the on-screen position under any change of duration or direction.
  • The wave desynced permanently after any rebuild: startWave() captured speed, direction, stride and item positions in its closure and nothing refreshed them. Geometry now lives in refreshWave(), run on every rebuild; while paused it only re-measures and the resume path picks the fresh values up.
  • A pointer visit destroyed a keyboard pause: onLeave resumed unconditionally and left the keyboardPaused flag inverted, so Space appeared dead on its next press. onLeave now respects the keyboard pause, keeping the aria-label's "Press Space to pause" promise.
  • fillSet() only ran at build, so a container that grew later was left with a hole after the duplicated sets. Rebuilds now top the track up.

Tests

Twelve added to motion-ticker.test.ts, all driving the public surface and asserting on the rendered translateX/translateY. Each was mutation-checked — reverting the source while keeping the tests makes exactly the corresponding tests fail:

  • keeps scrolling while it decelerates on hover
  • holds its position for as long as the pointer stays
  • resumes from where it stopped when the pointer leaves
  • decelerates and resumes in place when toggled by keyboard
  • does not pause on hover when pause-on-hover is false
  • stays parked in place when a live attribute changes while hover-paused
  • scrolls on without a jump across a live attribute change while running
  • re-applies the gap to the track when the attribute changes
  • keeps its rendered position when direction flips
  • keeps a keyboard pause across a pointer visit
  • tops the track back up when the container grows
  • re-times the wave when speed changes

Full gate is green: typecheck, lint, format:check, check:preload, test (198 passed / 27 files), build, check:exports, size (motion-ticker 2.52 kB of a 3 kB budget).

Known leftovers, deliberately out of scope

Two smaller findings from the same audit change documented behaviour rather than fix broken behaviour, so they are not in this PR: the four read-once attributes (pause-on-hover, wave, wave-amplitude, wave-length) sit in observedAttributes and trigger pointless rebuilds, and startMarquee retries on rAF unboundedly while the element has no width. Happy to file issues for either.

🤖 Generated with Claude Code

pause-on-hover made the marquee snap back to its starting position on
mouse leave, and stopped it dead instead of decelerating (tgomilar#2).

onEnter paused the animation before lerpRate(0) ran, so the ramp
rendered nothing and the deceleration was never visible. The ramp then
drove ctrls.speed down to ~0.003 against the already-paused animation.
On resume, MainThreadAnimation.play() rebases startTime as
`now - holdTime` while tick() reads back `(timestamp - startTime) *
speed`, so the elapsed time is multiplied by the current speed — at
0.003 that collapses ~0.7s of progress to ~0.002s, restarting the loop.

Let the ramp own the stop: onEnter only calls lerpRate(0), which pauses
through the playback controller once the rate reaches MIN_RATE, so the
animation stays live while it decelerates and its time stays meaningful.
Resuming now restores ctrls.time explicitly after play(), the same way
onResize() already does, and the rate never goes below MIN_RATE.

The keyboard path had the inverted order too — there pause() cancelled
the rate ramp outright, so Space stopped the ticker dead. It now shares
the same path as hover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@tgomilar tgomilar left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Necessary improvement: attributeChangedCallback must respect the paused state

When the ticker is stopped on hover and an attribute such as speed changes, attributeChangedCallback (lines 153-165) builds a new animation that runs at full speed. The ticker scrolls again while the component still reports paused. This is the same hover path this PR fixes, so I think it belongs in this PR.

Fix: after rebuilding, set the speed to currentRate and call pause() when the rate is zero, in the same way as onResize (line 288), which already sets the speed.

* Long enough for the rate ramp to bottom out, so a resume happens from a
* fully stopped ticker rather than from one still coasting near full speed.
*/
const HOVER_DWELL = 900

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

The fixed 900ms wait for the speed ramp to finish can fail on a slow machine. Use the until helper (lines 30-36) to wait until playState becomes paused instead of a fixed time.

dreamsworker and others added 3 commits August 16, 2026 02:26
A live attribute change or resize rebuilt the ticker animation running at
full speed, so a hover-paused ticker started scrolling while still
reporting 'paused'. Rebuilds from both paths now go through one
rebuildMarquee() that floors ctrls.speed at MIN_RATE, holds the fresh
animation when the controller is paused, flushes motion's async keyframe
resolver so the carried-over time survives the first frame, and derives
progress from the outgoing animation's own duration so the position no
longer scales with a speed change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gap styles were written once in build(), so changing the gap
attribute only altered the animation math while the rendered columnGap
and marginRight kept their original values — and the wrap width was
measured against the new gap, leaving the loop seam off by the delta.
Gap styling now lives in one applyGap() that build, startMarquee and
rebuildMarquee all run before measuring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d fill intact

Four defects on the same rebuild path, found by auditing every observed
attribute against every playback state:

- The wave loop captured speed, direction, stride and item positions at
  start and was never refreshed, so any live attribute change or resize
  desynced the wave from the scroll. Geometry now lives in refreshWave(),
  run on every rebuild; while paused it only re-measures and the resume
  path restarts the loop.
- A direction flip mapped the progress fraction onto mirrored keyframes
  and teleported the track. The rebuild now derives its time from the
  rendered offset, which holds the position under any change of duration
  or direction.
- A pointer or focus leaving resumed a ticker the user had paused with
  Space, and left the flag inverted so the next press did nothing.
  onLeave now respects the keyboard pause.
- fillSet() only ran at build, so a container that grew later was left
  with a gap after the duplicated sets. Rebuilds now top the track up,
  and fillSet skips the setB rebuild when nothing changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@felixchen-wordup

felixchen-wordup commented Aug 15, 2026

Copy link
Copy Markdown
Author

Done — and you were right that it belongs in this PR, thanks. The fix itself is in a40a93d, but the branch has grown by three commits since your review, so here is an honest account of why each change is there.

The fix you asked for (a40a93d)

attributeChangedCallback and onResize were near-duplicate code, and onResize turned out to have the same defect in a different costume: it rebuilds while hover-paused and writes ctrls.speed = 0 (currentRate is 0 by then), and a running animation at speed 0 reads time back as 0, so the next resume loses the position anyway. Fixing one callback and not the other would have left the reported behaviour reachable through a window resize. Both now go through a single rebuildMarquee() that:

  • floors ctrls.speed at MIN_RATE instead of writing currentRate verbatim — same discipline as resumeCtrls(), so the degenerate near-zero speed range is never entered;
  • holds the fresh animation via ctrls.pause() directly when the controller reports 'paused' — the controller's own pause() is a no-op in that state, so it cannot do the holding;
  • reads ctrls.duration once before assigning time: motion's keyframe resolver is async, and until it settles the time assignment can't rebase a running animation, so the next frame's play() restarted it from 0. This was the remaining position-jump on the running rebuild path.

Why two more commits followed

Your comment demonstrated that the rebuild path had never been audited as a whole — you found a second consumer of the same flaw one day after I fixed the first. Rather than wait for the third report, I swept every observed attribute against every playback state (running, decelerating, hover-paused, keyboard-paused, externally paused, detached) and fixed what fell out. Everything below reproduces on main and each fix lands with a test that fails without it.

245b17cgap was not actually live. The gap styles were written once in build(), so a live gap change altered the animation maths while the rendered columnGap/marginRight kept their old values — and the wrap width was measured against the new gap, leaving the loop seam off by the delta. Styling now lives in one applyGap() that build, start and rebuild all run before measuring.

71b6ceb — four more on the same path:

  • Wave desync. startWave() captured phaseRate (speed, direction), setStride (width, gap) and the item positions in its closure, and nothing ever refreshed them — so with wave on, any live attribute change or resize permanently desynced the wave from the scroll. Geometry now lives in refreshWave(), run on every rebuild; while paused it only re-measures, and the resume path picks the fresh values up.
  • Direction flip teleported the track. The progress fraction maps to mirrored positions on [0,-w] vs [-w,0]. The rebuild now derives its time from the rendered offset (renderedProgress()) instead of the outgoing animation's clock — which holds the position under any change of duration or direction, and quietly upgrades speed/gap/resize from "preserve the time fraction" to "preserve the on-screen position".
  • A pointer visit destroyed a keyboard pause. onLeave resumed unconditionally, so Space-pause → mouse enters and leaves → the ticker resumes on its own, and the inverted flag made the next Space press do nothing. Given the element advertises "Press Space to pause" in its aria-label, this seemed worth fixing while the pause paths were open. onLeave now respects keyboardPaused.
  • fillSet() only ran at build. A container that grows later (resize, rotation) was left with a hole after the duplicated sets. Rebuilds now top the track up; fillSet skips the setB rebuild when nothing changed to avoid DOM churn on every rebuild.

Verification

Seven more tests added in these commits, all driving the public surface and asserting on the rendered translateX/translateY. Each was mutation-checked: reverting the source while keeping the tests makes exactly the corresponding tests fail. Full gate is green (198 tests / 27 files, typecheck, lint, format, exports); motion-ticker is 2.52 kB of the 3 kB budget.

Two smaller findings from the same audit were left out as they change documented behaviour rather than fix broken behaviour: the four read-once attributes sit in observedAttributes and trigger pointless rebuilds, and startMarquee retries on rAF unboundedly while the element has no width. Happy to file issues for those — and equally happy to split 71b6ceb out into its own PR if you'd rather keep this one scoped to the hover path.

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.

<motion-ticker> jumps back to the start on mouse leave when pause-on-hover is enabled (v0.5.0)

3 participants