Hooks

usePointerDrag

One drag gesture, handled properly. The hook owns pointer capture with a window fallback, coalesces moves to one update per frame, and always reports the final position before it ends, so the value you commit matches where the user let go.

Inside the library it drives the ColorPicker saturation area and every Resizable handle.

Basic Usage

Spread handlers onto whatever starts the drag, capture the value the gesture starts from in onStart, then rebuild it from the delta in onMove. Drag past the edge of the box and let go outside it: the gesture keeps up, because the window listeners take over where the element ends.

x 0 ยท y 0 active false
<script lang="ts">
  import { usePointerDrag } from 'sv5ui';

  let x = $state(0);
  let y = $state(0);
  let start = { x: 0, y: 0 };

  const drag = usePointerDrag({
    // Capture the value the gesture starts from, then rebuild it from the
    // delta on every move. Nothing accumulates, so a dropped frame cannot
    // drift the result.
    onStart: () => (start = { x, y }),
    onMove: ({ dx, dy }) => {
      x = start.x + dx;
      y = start.y + dy;
    }
  });
</script>

<div
  {...drag.handlers}
  class="absolute touch-none"
  class:ring-2={drag.active}
  style="translate: {x}px {y}px"
>
  Drag me
</div>

Locking an Axis

axis zeroes the delta on the other direction, so a horizontal control cannot be nudged sideways by an unsteady hand. This is a split panel built by hand, which is what Resizable does for you.

240px
Remaining space
<script lang="ts">
  import { usePointerDrag } from 'sv5ui';

  let width = $state(240);
  let start = 0;

  // axis: 'x' zeroes dy, so a horizontal control cannot be nudged sideways
  // by a shaky hand. 'y' does the same for dx.
  const drag = usePointerDrag({
    axis: 'x',
    onStart: () => (start = width),
    onMove: ({ dx }) => (width = Math.min(480, Math.max(120, start + dx)))
  });
</script>

<div style="width: {width}px">Panel</div>
<div {...drag.handlers} class="w-1.5 cursor-col-resize touch-none"></div>

An Axis That Changes

axis also takes a getter, which is re-read on every update. A component whose orientation is a prop stays correct without recreating the hook. Flip the switch below in the middle of a drag and watch the lock follow.

offset 0
<script lang="ts">
  import { usePointerDrag } from 'sv5ui';

  let orientation = $state<'horizontal' | 'vertical'>('horizontal');

  // A getter is re-read on every update, so a component whose orientation
  // can change while it is mounted stays correct without recreating the
  // hook. This is exactly how Resizable handles its direction prop.
  const drag = usePointerDrag({
    axis: () => (orientation === 'horizontal' ? 'x' : 'y'),
    onMove: ({ dx, dy }) => move(dx, dy)
  });
</script>

Rejecting a Gesture

Returning exactly false from onStart refuses the gesture: the capture is released and active stays false, so nothing downstream believes a drag is running. In the demo, only the grip starts a drag while the switch is on.

accepted 0 rejected 0
<script lang="ts">
  import { usePointerDrag } from 'sv5ui';

  let locked = $state(false);

  const drag = usePointerDrag({
    onStart: ({ event }) => {
      // Return exactly false to reject the gesture: the capture is released
      // and active stays false, so nothing thinks a drag is in flight.
      const target = event.target as HTMLElement;
      if (!target.closest('[data-grip]')) return false;
      if (locked) return false;

      start = value;
    },
    onMove: ({ dx }) => (value = start + dx)
  });
</script>

<!-- The return type is unknown on purpose. A one line arrow such as
     onStart: () => (start = value) returns the assigned value, and a
     boolean | void signature would reject that code. Only an exact false
     cancels the gesture. -->

Disabling Mid-gesture

A disabled getter is tracked, so turning it on during a drag ends the gesture cleanly: onEnd fires with the last known position and active goes back to false. A plain boolean is read once, at the pointer down.

active false onEnd fired 0

Start dragging, then turn the switch on without letting go. The gesture ends by itself and the counter goes up once.

<script lang="ts">
  import { usePointerDrag } from 'sv5ui';

  let disabled = $state(false);

  const drag = usePointerDrag({
    // A getter is tracked, so flipping this to true mid-drag ends the
    // gesture cleanly: onEnd fires with the last known position and active
    // goes back to false. A plain boolean is read once and never changes.
    disabled: () => disabled,
    onMove: ({ dx }) => (offset = start + dx),
    onEnd: () => commit()
  });
</script>

Cancelling

cancel() ends the gesture as though the pointer had been released, so onEnd still runs exactly once. Pair it with Escape and restore the starting value first, which is the behaviour people expect from a drag they changed their mind about.

value 50 committed 50 cancelled 0
<script lang="ts">
  import { usePointerDrag, useEventListener } from 'sv5ui';

  let value = $state(50);
  let start = 0;

  const drag = usePointerDrag({
    axis: 'x',
    onStart: () => (start = value),
    onMove: ({ dx }) => (value = clamp(start + dx / 3)),
    onEnd: () => save(value)
  });

  // Escape aborts the gesture the way a dialog would: cancel() ends it as
  // if the pointer had been released, so onEnd still runs once.
  useEventListener(
    () => window,
    'keydown',
    (event) => {
      if (event.key !== 'Escape' || !drag.active) return;
      value = start;
      drag.cancel();
    }
  );
</script>

Throttling

By default the hook batches moves into one call per animation frame, which is as often as the screen can show a change. Drag both boxes the same distance at the same speed and compare the counters: the unthrottled one runs several times per frame on a high frequency pointer.

throttle: true (default)
throttle: false
<script lang="ts">
  import { usePointerDrag } from 'sv5ui';

  let coalesced = $state(0);
  let raw = $state(0);

  // Default: moves are coalesced into one call per animation frame, which
  // is all a repaint can use anyway.
  const smooth = usePointerDrag({ onMove: () => (coalesced += 1) });

  // throttle: false calls onMove for every pointermove the browser reports.
  // A high frequency mouse can fire several times per frame. Only turn it
  // off when you are recording a path rather than updating the screen.
  const exact = usePointerDrag({ throttle: false, onMove: () => (raw += 1) });
</script>

<!-- throttle is read once when the hook is created, so it cannot be
     toggled at runtime. -->

Turn it off only when you are recording a path rather than painting one, for example capturing a signature. The option is read once when the hook is created, so it cannot be toggled later.

Position Rather Than Delta

The context carries the pointer position as well as the delta, both in client coordinates. For a control where the pointer should land exactly where it is put, measure x against the element instead of accumulating dx. A before and after comparison slider is the clearest case: clicking anywhere on the image should jump straight there.

split 50.0%
<script lang="ts">
  import { usePointerDrag } from 'sv5ui';

  let container = $state<HTMLDivElement | null>(null);
  let split = $state(50);

  // A before and after image comparison: the pointer position matters more
  // than the delta here, so read x from the context and measure it against
  // the element. Both are client coordinates.
  const drag = usePointerDrag({
    axis: 'x',
    onStart: ({ x }) => setSplit(x),
    onMove: ({ x }) => setSplit(x)
  });

  function setSplit(clientX: number) {
    const rect = container?.getBoundingClientRect();
    if (!rect) return;
    split = Math.min(100, Math.max(0, ((clientX - rect.left) / rect.width) * 100));
  }
</script>

<div bind:this={container} {...drag.handlers} class="relative touch-none">
  <img src={after} alt="After" class="absolute inset-0 size-full object-cover" />

  <!-- Clipping the top layer rather than resizing a wrapper keeps both images
       at the same scale, so the seam lines up exactly. -->
  <img
    src={before}
    alt="Before"
    class="absolute inset-0 size-full object-cover"
    style="clip-path: inset(0 {100 - split}% 0 0)"
  />

  <div class="absolute inset-y-0 w-0.5 bg-white" style="left: {split}%"></div>
</div>

What the Hook Guarantees

These are the parts a hand rolled drag usually gets wrong, and the reason this hook exists at all.

Guarantees

GuaranteeWhat it means
Capture with a fallbacksetPointerCapture runs inside try/catch, and window listeners are attached while the drag is live, so the pointer can leave the element or the window and the drag still follows
One update per frameMoves are coalesced with requestAnimationFrame, so a high frequency mouse cannot schedule more renders than the screen can paint
The final position always landsonMove runs once more with the release position before onEnd, so the committed value matches where the pointer was let go rather than the last painted frame
No accumulated driftEvery update is computed from the start position rather than from the previous one, so a dropped move cannot accumulate error
Ends when disabledA gesture in flight ends cleanly when a disabled getter turns true
Primary button onlyA non-primary mouse button never starts a drag, so right click still opens the context menu. Touch and pen are unaffected
<script lang="ts">
  import { usePointerDrag } from 'sv5ui';

  const drag = usePointerDrag({
    onMove: ({ dx }) => (value = start + dx),
    onEnd: ({ dx }) => commit(start + dx)
  });
</script>

<!-- What the hook takes care of, so a component does not have to:

     1. setPointerCapture inside try/catch, with window listeners attached
        while the drag is live. The pointer can leave the element, leave the
        window, or land on an iframe, and the drag still follows it.

     2. Moves coalesced to one update per animation frame, so a 1000Hz mouse
        does not schedule a thousand renders a second.

     3. The final position is always flushed before onEnd, so the committed
        value matches where the pointer was released rather than where it
        was on the last painted frame.

     4. A gesture ends when disabled flips true mid-drag.

     5. A non-primary mouse button never starts a drag, so a right click
        opens the context menu instead of dragging. Touch and pen are
        unaffected.

     6. handlers has a stable identity, so spreading it does not invalidate
        the element on every render. -->
<div {...drag.handlers} class="touch-none"></div>

<!-- touch-none on the element is still yours to add: without it the browser
     may scroll the page instead of delivering pointermove. -->

Options

Every option is optional; usePointerDrag() with no arguments is valid.

OptionTypeDefault
onStart(context) => unknown-
onMove(context) => void-
onEnd(context) => void-
axis'x' | 'y' | 'both' | (() => DragAxis)'both'
throttlebooleantrue
disabledboolean | (() => boolean)false

Context

Passed to onStart, onMove and onEnd.

FieldType
eventPointerEvent
pointerIdnumber
x, ynumber
startX, startYnumber
dx, dynumber

Return

What the hook hands back.

FieldType
handlersUsePointerDragHandlers
activereadonly boolean
cancel() => void