RSSAmplifier

Karl Koch · Mar 30, 2026

On velocity gating

0
Sign in to vote or save

Karl Koch · Karl Koch

In the vinyl shelf post I mentioned velocity gating: fast cursors should not trigger hovers. The implementation combines smoothed velocity tracking, directional hysteresis, and separate engage and disengage thresholds. Each layer solves a different problem.

The raw problem

Without any velocity awareness, a mousemove handler has one job: find the closest item and hover it. Move your cursor across five records at any speed and each one flickers into its lifted state for a single frame before the cursor leaves. The shelf becomes a strobe.

Debouncing waits for cursor stillness before activating anything, so the shelf feels dead for the first N milliseconds after your cursor slows. The delay between “I’ve stopped here” and “the record lifts” trades responsiveness for calm.

Smoothed velocity

The first layer is an exponential moving average on cursor speed. Every mousemove, we compute the instantaneous velocity (pixels moved divided by milliseconds elapsed) and blend it with the previous smoothed value:

const VELOCITY_SMOOTHING = 0.3;
const instant = Math.abs(e.clientX - lastMouse.x) / dt;
smoothVelocity =
  VELOCITY_SMOOTHING * instant + (1 - VELOCITY_SMOOTHING) * smoothVelocity;

VELOCITY_SMOOTHING = 0.3 combines 30% of the new sample with 70% of the accumulated history. This low-pass filter prevents one slow frame during a fast sweep from registering as a stop, or one fast twitch during a slow browse from ejecting you. The smoothed value filters input noise.

A rolling window average needs more machinery, while EMA is stateless: one number and one multiply, with no array allocation per frame. On a 120Hz display that fires 120 mousemoves per second, every allocation counts.

Engage vs disengage

The smoothed velocity feeds into two separate thresholds, not one:

const VELOCITY_ENGAGE = 0.4; // px/ms, must be slower than this to pick up a new card
const VELOCITY_DISENGAGE = 0.9; // px/ms, must be faster than this to drop the current card

Engage is strict: you need to slow right down, under 0.4 px/ms, before a new record will lift, preventing drive-by hovers on records you’re passing through.

Disengage is lenient, so you have to accelerate past 0.9 px/ms to force-drop the record you’re currently focused on. This creates a velocity dead zone between 0.4 and 0.9 where you can gently move your cursor around the focused record without losing it.

A single threshold forces a trade-off: too low and drive-by hovers return; too high and records feel sticky. Two thresholds give you crisp acquisition with comfortable retention.

Directional hysteresis

The third layer accounts for which way you’re heading. When your cursor is actively moving toward a record, we relax the engage threshold because your intent is clearer:

const moveDir = Math.sign(e.clientX - lastMouse.x);
const movingToward = moveDir !== 0 && moveDir * (cx - localX) > 0;
const engageRadius = movingToward ? itemWidth * 0.85 : itemWidth * 0.6;
const engageVelocity = movingToward ? VELOCITY_ENGAGE * 1.4 : VELOCITY_ENGAGE;

Two things change when you’re moving toward a record:

  1. The spatial radius expands from 60% to 85% of the item width. You can start engaging earlier because the direction confirms you meant to go there.
  2. The velocity ceiling rises by 40% (from 0.4 to 0.56 px/ms). You’re allowed to be moving a bit faster and still engage, because the trajectory makes your intent unambiguous.

When you’re moving away from a record, both values tighten. You need to be closer and slower. This prevents the cursor from “catching” a record behind it as it leaves.

Approaching a record from the side feels immediate, while overshooting it and returning requires more care. The shelf uses cursor trajectory alongside position.

The retention loop

Once a record is hovered, the disengage check runs a different path. Instead of finding the closest record, we check whether the cursor is still within the bounds of the currently hovered record:

if (prev !== null) {
  const prevCx = getItemCenterX(prev, total, containerWidth, size);
  if (Math.abs(localX - prevCx) <= size / 2) {
    // Still inside the current record's footprint
    if (v > VELOCITY_DISENGAGE) return null; // moving too fast, drop it
    return prev; // hold
  }
}

This means a hovered record is only dropped for two reasons: the cursor physically left its bounds, or the cursor accelerated past the disengage threshold while still inside it. No other record can “steal” focus from the current one; the system must first release before it can acquire.

You can drift your cursor within a record’s column without neighbouring records competing for attention.

The full pipeline

Every mousemove runs this sequence:

  1. Compute instantaneous velocity from the delta
  2. Blend into smoothed velocity via EMA
  3. If a record is currently hovered, check retention (bounds + disengage threshold)
  4. If not retained, find closest record
  5. Check directional hysteresis to pick thresholds
  6. Check spatial radius and velocity ceiling
  7. Engage or return null

Steps 3–7 are all inside a single setHoveredIndex updater, so React batches the state transition. No intermediate renders. The cursor position, velocity, and direction flow through a single synchronous pipeline and produce exactly one hover decision per frame.

Why not pointer events?

PointerEvent.movementX gives the delta since the last pointer event, which can jitter between sub-pixel values on high-refresh displays. EMA smoothing provides a cleaner signal and preserves smoothed velocity across events through its running state.

When to steal this

Velocity gating is worth adding whenever you have a dense row of hover targets and fast cursor traversal causes visual noise. Music shelves, tab bars, thumbnail strips, carousel dots. The exact thresholds depend on your item size and spacing, but the architecture (EMA smoothing, split engage/disengage, directional hysteresis) transfers directly.

Read the original on karlkoch.me

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.