Vize

draggable

Make an element draggable with Pointer Events.

Package @vizejs/composable/draggable
Own the source vize lib pull composable:draggable
Runtime exports useDraggable
Gzip budget 3840 B

Minimal setup

Use a Vue 3.5+ project with Vue SFC compilation configured (see Vite+ integration).

vp install @vizejs/composable

Import the entry directly. Call lifecycle-bound utilities inside <script setup> so cleanup follows the component scope; consult the runtime contract for this entry's SSR and hydration behavior.

import { useDraggable } from "@vizejs/composable/draggable";

Try it

Move a local review note inside native bounds

Use a separate pointer handle, reactive axis and disabled state while keeping a complete note inside an actual element rectangle.

Open the interactive example

What to observe: Drag the handle toward the board edges: the whole note remains bounded, moves continue beyond the handle and Start/End counts describe actual drags. Select X only or lock pointer dragging. Edit the local note and move it with the keyboard buttons; the text stays exact. Stop listeners: pointer dragging stops while the explicit placement buttons still work.

useDraggable exposes writable client-coordinate x/y and a style for fixed or absolute placement; this sample uses fixed positioning within its iframe. The real board ref supplies bounds, and the note's own dimensions are subtracted at the right/bottom edges. Axis and disabled affect pointer gestures, not arbitrary assignments to writable refs: the independent keyboard placement function explicitly clamps its writes. The chosen SSR position is a fallback; mounting measures the real board and places the same note inside it. A left mouse pointer starts the drag, and owner-document move/up/cancel listeners follow its pointer identifier. Stop is idempotent and scope disposal removes listeners; this example does not claim tested pen/touch gestures or persist the draft.

Compare the initial and interacted states
Move a local review note inside native bounds: initial state
Initial state
Move a local review note inside native bounds: state after the verified interactions
After the verified interactions

Copy the example

Save this complete SFC in your Vue project. Its public imports and source are the same ones executed in the preview.

<script setup lang="ts">
import { onMounted, ref, useTemplateRef } from "vue";
import { useDraggable } from "@vizejs/composable/draggable";
import type { DragAxis } from "@vizejs/composable/draggable";

const board = useTemplateRef<HTMLElement>("board");
const note = useTemplateRef<HTMLElement>("note");
const handle = useTemplateRef<HTMLButtonElement>("handle");
const axis = ref<DragAxis>("both");
const locked = ref(false);
const stopped = ref(false);
const draft = ref("Review the native preview");
const starts = ref(0);
const ends = ref(0);
const { x, y, style, isDragging, stop } = useDraggable(note, {
  handle,
  bounds: board,
  axis,
  disabled: locked,
  preventDefault: true,
  initialValue: { x: 24, y: 160 },
  onStart: () => {
    starts.value += 1;
  },
  onEnd: () => {
    ends.value += 1;
  },
});

function placeNote(dx: number, dy: number, reset = false): void {
  const bounds = board.value?.getBoundingClientRect();
  const size = note.value?.getBoundingClientRect();
  if (!bounds || !size) return;
  x.value = Math.min(
    Math.max(bounds.left, reset ? bounds.left + 12 : x.value + dx),
    Math.max(bounds.left, bounds.right - size.width),
  );
  y.value = Math.min(
    Math.max(bounds.top, reset ? bounds.top + 12 : y.value + dy),
    Math.max(bounds.top, bounds.bottom - size.height),
  );
}

function stopDragging(): void {
  stop();
  stopped.value = true;
}

onMounted(() => placeNote(0, 0, true));
</script>

<template>
  <div class="composable-example">
    <div class="drag-controls">
      <label>Note text <input v-model="draft" /></label>
      <label>
        Drag axis
        <select v-model="axis">
          <option value="both">Both axes</option>
          <option value="x">X only</option>
          <option value="y">Y only</option>
        </select>
      </label>
      <label class="drag-lock"
        ><input v-model="locked" type="checkbox" /> Lock pointer dragging</label
      >
    </div>
    <section ref="board" class="drag-board" aria-label="Bounded note board">
      <article ref="note" class="drag-note" :style aria-label="Draggable review note">
        <button ref="handle" type="button" aria-label="Drag review note">Drag note</button>
        <span>{{ draft }}</span>
      </article>
    </section>
    <div class="example-actions">
      <button type="button" @click="() => placeNote(24, 0)">Move note right</button>
      <button type="button" @click="() => placeNote(0, 24)">Move note down</button>
      <button type="button" @click="() => placeNote(0, 0, true)">Reset note position</button>
      <button type="button" :disabled="stopped" @click="stopDragging">Stop drag listeners</button>
    </div>
    <output aria-live="polite">
      Position: {{ Math.round(x) }}, {{ Math.round(y) }} · Dragging:
      {{ isDragging ? "yes" : "no" }} · Starts: {{ starts }} · Ends: {{ ends }}
    </output>
    <details>
      <summary>How pointer placement works</summary>
      <p>
        Drag the handle; moves continue on the owner document and keep the whole note inside the
        board. Axis and Lock constrain pointer dragging. The keyboard buttons place the note
        independently and explicitly apply the same bounds, including after listeners stop.
      </p>
      <p>
        Position uses this iframe's client coordinates and fixed positioning. Server rendering uses
        the chosen 24, 160 fallback; mounting places it inside the actual board. The local draft
        survives movement. Bounds are read during pointer moves; Reset places the note again after
        scrolling or layout changes. Nothing is saved or sent.
      </p>
    </details>
  </div>
</template>

<style scoped>
.drag-controls {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: end;

  label {
    display: grid;
    gap: 4px;
    flex: 1 1 120px;
    min-inline-size: 0;
  }

  .drag-lock {
    display: flex;
    align-items: center;
    flex-basis: 180px;

    input {
      inline-size: auto;
    }
  }
}
.drag-board {
  block-size: 160px;
  border: 1px dashed currentColor;
}
.drag-note {
  --note-surface: Canvas;
  --note-layer: 1;
  position: fixed;
  z-index: var(--note-layer);
  box-sizing: border-box;
  inline-size: 160px;
  block-size: 120px;
  padding: 8px;
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  gap: 8px;
  border: 1px solid currentColor;
  border-radius: 8px;
  background: var(--note-surface);
  overflow-wrap: anywhere;

  button {
    touch-action: none;
    cursor: grab;
  }

  span {
    min-block-size: 0;
    overflow: auto;
  }
}
</style>

Runtime contract

Utility Category Stability SSR Hydration Cleanup Targets Host globals Uses
useDraggable input experimental deterministic-fallback stable caller, reactive-scope web, desktop — resolveElement, isElementNode, toPointerKind, tryOnScopeDispose

API

useDraggable

Make an element draggable with Pointer Events. pointerdown on the handle records the offset between the pointer and the element; moves are tracked on the owner document (so fast drags that leave the element keep working) and constrained by axis and bounds. x/y are writable for programmatic placement. Server renders expose initialValue; listeners are removed with the owning reactive scope.

function useDraggable( target: MaybeElementTarget, options: UseDraggableOptions = {}, ): DraggableControls

Types

DragBounds

Rectangle (in the same coordinate space as the position) that bounds dragging.

Member Type Description
left number Minimum x.
top number Minimum y.
right number Maximum x of the dragged element's right edge.
bottom number Maximum y of the dragged element's bottom edge.

UseDraggableOptions

Options for useDraggable.

Member Type Description
handle? MaybeElementTarget Element that starts the drag. Defaults to the dragged element itself.
axis? MaybeRefOrGetter<DragAxis> Restrict movement to one axis.
bounds? MaybeRefOrGetter<DragBounds | null | undefined> | MaybeElementTarget Keep the element inside these bounds, or inside a container element's client rectangle.
initialValue? Point Position before the first drag and during server rendering.
pointerTypes? readonly PointerKind[] Device kinds that may start a drag.
exact? boolean Only start when the pointer goes down on the handle itself, not a descendant.
preventDefault? boolean Call preventDefault() on pointer events while dragging.
disabled? MaybeRefOrGetter<boolean> Temporarily disable dragging.
onStart? (position: Point, event: PointerEvent) => boolean | void Called before a drag starts. Return false to cancel it.
onMove? (position: Point, event: PointerEvent) => void Called after every position update.
onEnd? (position: Point, event: PointerEvent) => void Called when the drag ends.

DraggableControls

Reactive drag state returned by useDraggable.

Member Type Description
x Ref<number> Horizontal position (the element's left edge in client coordinates).
y Ref<number> Vertical position (the element's top edge in client coordinates).
position ComputedRef<Point> Current position snapshot.
isDragging Readonly<Ref<boolean>> Whether a drag is in progress.
style ComputedRef<string> left/top declaration for position: fixed or absolute placement.
stop () => void Remove listeners. Idempotent.