Skip to content

Carousel Physics: Velocity Sampling, Edge Resistance, and PointerEvent API

8/11/2025Web DevelopmentKUIreact7 min read

Carousel Physics: Velocity Sampling, Edge Resistance, and PointerEvent API

A slide doesn't just track distance. It tracks velocity. setPointerCapture, a 100ms sampling window, and rubber-band edge resistance in a single PointerEvent handler.

The first version of a carousel looks like this: onMouseDownonMouseMove → compute offset → onMouseUp → if threshold exceeded, change slide. It works. But it doesn't feel natural, because real swipe interactions look at speed too — a slow drag past the threshold might not advance a slide, and a fast flick well short of the threshold should.

kui-react's Slider implements this in modules/ui/Slider/hooks/useDrag.ts with three independent mechanisms working together.

Mechanism 1: setPointerCapture

The first problem: when the user moves the mouse outside the element during a drag, mousemove stops firing and the drag breaks.

setPointerCapture solves this:

const onPointerDown = useCallback(
  (e: React.PointerEvent<HTMLDivElement>) => {
    if (e.pointerType === 'mouse' && e.button !== 0) return;
    if (total <= 1) return;

    const target = e.currentTarget;
    const rect = target.getBoundingClientRect();
    trackWidthRef.current = rect.width;
    startXRef.current = e.clientX;
    activePointerRef.current = e.pointerId;

    try {
      target.setPointerCapture(e.pointerId);
    } catch {
      /* some test environments don't support this */
    }

    setDragState({ offsetPx: 0, trackWidth: rect.width, isDragging: true });
  },
  [total]
);

setPointerCapture(e.pointerId) routes all subsequent pointer events for that pointer to this element — regardless of where the pointer moves on the page. releasePointerCapture is called on pointerup.

The PointerEvent API unifies mouse, touch, and stylus into one interface. No separate touchstart / mousedown handlers needed. pointerType === 'mouse' && e.button !== 0 blocks right-click and middle-click drags.

Mechanism 2: Velocity Sampling Window

const VELOCITY_SAMPLE_WINDOW_MS = 100;
const VELOCITY_PER_EXTRA_SLIDE = 0.5; // px/ms

type PointerSample = { x: number; t: number };
const samplesRef = useRef<PointerSample[]>([]);

Each pointermove adds a sample and trims samples older than 100ms:

const onPointerMove = useCallback(
  (e: React.PointerEvent<HTMLDivElement>) => {
    if (activePointerRef.current !== e.pointerId) return;

    let delta = e.clientX - startXRef.current;

    // Edge resistance at boundaries
    if (!loop) {
      const atFirst = current === 0 && delta > 0;
      const atLast = current === total - 1 && delta < 0;
      if (atFirst || atLast) delta *= EDGE_RESISTANCE; // × 0.4
    }

    const now = e.timeStamp;
    samplesRef.current.push({ x: e.clientX, t: now });
    while (
      samplesRef.current.length > 1 &&
      now - samplesRef.current[0].t > VELOCITY_SAMPLE_WINDOW_MS
    ) {
      samplesRef.current.shift();
    }

    setDragState((s) => ({ ...s, offsetPx: delta }));
  },
  [current, loop, total]
);

Why 100ms? A user can drag slowly and then flick at the end. If you measure velocity over the full drag, you get the average movement speed — not the release speed. The 100ms window captures only the final movement, which is the flick that should drive momentum.

Mechanism 3: Release and Momentum Calculation

const endDrag = useCallback(
  (e: React.PointerEvent<HTMLDivElement>) => {
    if (activePointerRef.current !== e.pointerId) return;
    activePointerRef.current = null;

    try {
      e.currentTarget.releasePointerCapture(e.pointerId);
    } catch {}

    const samples = samplesRef.current;
    const last = samples[samples.length - 1] ?? { x: e.clientX, t: e.timeStamp };
    const first = samples[0] ?? last;

    const totalDelta = e.clientX - startXRef.current;
    const dt = Math.max(1, last.t - first.t);
    const velocity = (last.x - first.x) / dt; // px/ms, signed

    const distance = Math.abs(totalDelta);
    const direction = totalDelta < 0 ? 1 : -1;

    // Reset visual drag state first — clean snap animation
    setDragState({ offsetPx: null, trackWidth: 0, isDragging: false });
    samplesRef.current = [];

    if (distance < dragThreshold && Math.abs(velocity) < VELOCITY_PER_EXTRA_SLIDE) {
      return; // no threshold, no flick → snap back
    }

    let step = distance >= dragThreshold ? 1 : 0;

    const flickDir = velocity < 0 ? 1 : -1;
    const flickStep = Math.floor(Math.abs(velocity) / VELOCITY_PER_EXTRA_SLIDE);

    let signedStep = direction * step;
    if (flickStep > 0 && flickDir === direction) {
      signedStep = direction * (step + flickStep);
    } else if (flickStep > 0 && step === 0) {
      signedStep = flickDir * flickStep;
    }

    if (signedStep === 0) return;
    goTo(current + signedStep);
  },
  [current, dragThreshold, goTo]
);

The decision tree at release:

  1. distance < dragThreshold and velocity < 0.5 px/ms → snap back, nothing changes
  2. distance >= dragThreshold → advance at least 1 slide
  3. velocity >= 0.5 px/ms → every additional 0.5 px/ms adds 1 extra slide

At 3 px/ms velocity: Math.floor(3 / 0.5) = 6 extra slides. A fast swipe skips many slides — the expected behavior for a flick gesture.

Edge Resistance: EDGE_RESISTANCE = 0.4

const EDGE_RESISTANCE = 0.4;

// When at boundary and not looping:
if (atFirst || atLast) delta *= EDGE_RESISTANCE;

At the first slide, dragging right (trying to go past the beginning) moves the track at 40% of normal speed. It gives visual feedback that you've hit a boundary without a hard stop. Release triggers a snap back. This matches iOS scroll overscroll behavior — the rubber-band effect.

The Render Side

When dragState.offsetPx is non-null, the Track component applies a CSS transform:

const translateX = isDragging && offsetPx !== null
  ? `calc(-${current * (100 / total)}% + ${offsetPx}px)`
  : `translateX(-${current * (100 / total)}%)`;

When offsetPx returns to null, CSS transition fires — the snap animation. During drag, transitions are disabled so every frame updates smoothly without easing.

Trade-off

VELOCITY_PER_EXTRA_SLIDE = 0.5 and EDGE_RESISTANCE = 0.4 are tuned by feel. They work differently across devices — touch screens have higher pointer sampling rates than mouse, which affects the velocity calculation. Proper polish would need per-device calibration.

Math.floor(velocity / VELOCITY_PER_EXTRA_SLIDE) can produce very large step counts on fast flicks. In a 6-slide carousel, a 3 px/ms flick theoretically produces a 6+ step advance. A Math.min(flickStep, total - 1) clamp is absent — the step gets clamped by goTo's bounds checking, but the intent isn't explicit.

The velocity-based momentum feels most natural on touch devices. Mouse users rarely generate enough velocity to trigger momentum — they effectively use the distance-based path.

Business Impact

Swipe quality affects whether users perceive your product as polished or rough. An iOS-native carousel feel versus a clunky slide change is sometimes this mechanism alone.

Landing pages with feature carousels or client logo strips benefit most. When users swipe quickly, advancing multiple slides matches their intent — missing it feels broken.

Something to Try

Add setPointerCapture(e.pointerId) to your carousel's pointerdown handler. Then drag and intentionally move the mouse outside the carousel boundary. Without capture, the drag breaks. With it, the interaction continues seamlessly. That one line is the most impactful improvement for desktop carousel reliability.

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox