Description
A single MX Mouse (ratchet mode) wheel tick produces exactly one WheelEvent. The plugin correctly starts a gesture and dispatches mousedown + mousemove (+120px), but the slide never advances — it snaps back to the current position.
Root cause: timing mismatch between wheel-gestures and Embla's DragTracker
The failure chain:
-
Single wheel event arrives → wheel-gestures starts a gesture, publishes it, then schedules end() via willEnd() with willEndTimeout = WILL_END_TIMEOUT_DEFAULT = 400ms (because updateWillEndTimeout is only called after WHEELEVENTS_TO_MERGE = 2 events are merged — a single-event gesture never updates the timeout).
-
The plugin dispatches mousedown + mousemove (+120px) synchronously during the wheel event handler. Both events have nearly identical timeStamp values.
-
400ms later (no further wheel events), end() fires → isEnding: true → plugin dispatches mouseup.
-
Embla's DragTracker.pointerUp:
const expired = readTime(evt) - readTime(lastEvent) > logInterval; // logInterval = 170ms
const isFlick = diffTime && !expired && mathAbs(force) > 0.1;
return isFlick ? force : 0;
readTime(mouseup) - readTime(lastMousemove) ≈ 400ms > 170ms → expired = true → returns 0.
rawForce = 0 × forceBoost(300) = 0. allowedForce(0) falls into the position-only branch:
if (dragFree || mathAbs(force) < goToNextThreshold) return baseForce(force);
baseForce(0) finds the closest snap to the current drag position (120px on a 600px slide = 20%). Closest snap = current slide → snaps back.
The 50% position threshold is a red herring
The slide would advance if velocity (isFlick = true) were available — allowedForce would go to scrollTarget.byIndex(next, 0).distance directly. The position being < 50% only matters because velocity is already 0.
If willEndTimeout were < 170ms (e.g. 50ms), the mouseup would arrive before expired triggers, DragTracker would compute force = 120px / 50ms = 2.4px/ms, rawForce = 720, and allowedForce(720) > goToNextThreshold → slide advances. The timing mismatch is the sole cause.
Affected scenario
- Discrete mouse wheel (ratchet mode): one click = one
WheelEvent, no follow-up events
WILL_END_TIMEOUT_DEFAULT = 400ms is necessary for trackpad gesture detection, but fatal for single-event gestures
- Any Embla carousel where the user expects one tick → one slide advance
Potential fixes
In embla-carousel-wheel-gestures: On isEnding && !isMomentum, if only a single wheel event was observed (no merged scroll points), call embla.scrollNext() / embla.scrollPrev() directly instead of dispatching mouseup through the drag pipeline — the drag physics path is irreparably broken for this case.
In wheel-gestures: Detect single-event gestures and use a short willEndTimeout (< 170ms) to fire end() before Embla's logInterval expires, preserving the velocity reading.
Versions
embla-carousel-wheel-gestures@9.0.0-rc01
embla-carousel@9.0.0-rc02
wheel-gestures@2.2.48
Description
A single MX Mouse (ratchet mode) wheel tick produces exactly one
WheelEvent. The plugin correctly starts a gesture and dispatchesmousedown+mousemove(+120px), but the slide never advances — it snaps back to the current position.Root cause: timing mismatch between wheel-gestures and Embla's DragTracker
The failure chain:
Single wheel event arrives →
wheel-gesturesstarts a gesture, publishes it, then schedulesend()viawillEnd()withwillEndTimeout = WILL_END_TIMEOUT_DEFAULT = 400ms(becauseupdateWillEndTimeoutis only called afterWHEELEVENTS_TO_MERGE = 2events are merged — a single-event gesture never updates the timeout).The plugin dispatches
mousedown+mousemove(+120px) synchronously during the wheel event handler. Both events have nearly identicaltimeStampvalues.400ms later (no further wheel events),
end()fires →isEnding: true→ plugin dispatchesmouseup.Embla's
DragTracker.pointerUp:readTime(mouseup) - readTime(lastMousemove) ≈ 400ms > 170ms→expired = true→ returns0.rawForce = 0 × forceBoost(300) = 0.allowedForce(0)falls into the position-only branch:baseForce(0)finds the closest snap to the current drag position (120px on a 600px slide = 20%). Closest snap = current slide → snaps back.The 50% position threshold is a red herring
The slide would advance if velocity (
isFlick = true) were available —allowedForcewould go toscrollTarget.byIndex(next, 0).distancedirectly. The position being < 50% only matters because velocity is already 0.If
willEndTimeoutwere < 170ms (e.g. 50ms), the mouseup would arrive beforeexpiredtriggers, DragTracker would computeforce = 120px / 50ms = 2.4px/ms,rawForce = 720, andallowedForce(720) > goToNextThreshold→ slide advances. The timing mismatch is the sole cause.Affected scenario
WheelEvent, no follow-up eventsWILL_END_TIMEOUT_DEFAULT = 400msis necessary for trackpad gesture detection, but fatal for single-event gesturesPotential fixes
In
embla-carousel-wheel-gestures: OnisEnding && !isMomentum, if only a single wheel event was observed (no merged scroll points), callembla.scrollNext()/embla.scrollPrev()directly instead of dispatchingmouseupthrough the drag pipeline — the drag physics path is irreparably broken for this case.In
wheel-gestures: Detect single-event gestures and use a shortwillEndTimeout(< 170ms) to fireend()before Embla'slogIntervalexpires, preserving the velocity reading.Versions
embla-carousel-wheel-gestures@9.0.0-rc01embla-carousel@9.0.0-rc02wheel-gestures@2.2.48