Skip to main content
Vuetify0 Logo
Theme
Mode
Palettes
Accessibility
Vuetify One
Sign in to Vuetify One

Access premium tools across the Vuetify ecosystem — Bin, Play, Studio, and more.

Not a subscriber? See what's included

useDragDrop

Edit this page
Report a Bug
Open issues
View on GitHub
Copy Markdown

PreviewIntermediateAug 27, 2026

Headless drag-and-drop primitive. Owns two registries — draggables and zones — plus the active-drag state.

Usage

Call useDragDrop once per scope and pass the returned context to children that register draggables or zones. Adapters locate by registered el, not data-draggable. Keyboard pickup needs the ticket focused — tabindex="0", or useRovingFocus.

Drop the card onto the zone. Pointer, touch, or keyboard (Tab, then Space / Enter to pick up, arrows to nudge, Space / Enter to drop).

vue
<script setup lang="ts">
  import { useDragDrop } from '@vuetify/v0'
  import { ref, useTemplateRef } from 'vue'

  const dnd = useDragDrop<{ type: 'card', value: string }>()
  const held = ref(['Card'])
  const dropped = ref<string[]>([])

  const draggable = useTemplateRef<HTMLElement>('draggable')
  const dropzone = useTemplateRef<HTMLElement>('dropzone')

  const ticket = dnd.draggables.register({
    el: draggable,
    type: 'card',
    value: 'Card',
  })

  const zone = dnd.zones.register({
    el: dropzone,
    accept: ['card'],
    orientation: 'vertical',
    onDrop: (drag, position) => {
      held.value = held.value.filter(v => v !== drag.value)
      dropped.value.splice(position.index ?? 0, 0, drag.value)
    },
  })
</script>

<template>
  <div class="flex flex-wrap gap-4">
    <div
      v-if="held.length"
      ref="draggable"
      aria-roledescription="draggable"
      class="touch-none cursor-grab select-none px-3 py-2 rounded bg-primary text-on-primary data-[dragging]:cursor-grabbing data-[dragging]:opacity-50"
      data-draggable
      :data-dragging="ticket.isDragging.value || undefined"
      tabindex="0"
    >
      Card
    </div>

    <div
      ref="dropzone"
      class="min-h-12 min-w-32 px-3 py-2 rounded border border-divider data-[accepts]:border-primary data-[accepts]:bg-primary/10"
      :data-accepts="(zone.isOver.value && zone.willAccept.value) || undefined"
      data-dropzone
      :data-over="zone.isOver.value || undefined"
    >
      {{ dropped[0] ?? 'Drop zone' }}
    </div>
  </div>
</template>

Adapters

Adapters are pluggable input layers: an adapter observes the DOM (or any other input source) and emits the four lifecycle events the factory consumes. Default adapters are installed automatically.

AdapterImportDescription
PointerAdapter@vuetify/v0Pointer Events for mouse, touch, and pen (default)
KeyboardAdapter@vuetify/v0Keyboard activation (default)
DragDropAdapter@vuetify/v0Abstract base class for custom adapters — see Custom adapters

locate() walks ancestors of the event target until it finds a ticket whose el matches. It does not read data-draggable.

PointerAdapter

Pointer Events for mouse, touch, and pen. Installed by default. You do not need a separate touch adapter.

OptionTypeDefaultDescription
thresholdnumber0Drag-activation distance in px. Set non-zero to require a minimum movement before the drag starts — useful for distinguishing drags from clicks.
ts
import { useDragDrop, PointerAdapter } from '@vuetify/v0'

const dnd = useDragDrop({ adapters: [new PointerAdapter({ threshold: 8 })] })

KeyboardAdapter

Keyboard activation: Space / Enter to pick up and drop, arrow keys to nudge, Escape to cancel. Installed by default.

OptionTypeDefaultDescription
activatestring[][' ', 'Enter']Keys that pick up an idle draggable and drop the active one.
stepnumber16Pixel step per arrow-key press.
ts
import { useDragDrop, KeyboardAdapter } from '@vuetify/v0'

const dnd = useDragDrop({ adapters: [new KeyboardAdapter({ step: 32 })] })

Replacing the defaults

To use only one adapter, pass it explicitly. The default array is replaced entirely:

ts
import { useDragDrop, PointerAdapter } from '@vuetify/v0'

// Pointer only — keyboard disabled.
const dnd = useDragDrop({ adapters: [new PointerAdapter()] })

To extend instead of replace, list the defaults alongside your custom adapter:

ts
import { useDragDrop, PointerAdapter, KeyboardAdapter } from '@vuetify/v0'
import { GamepadAdapter } from './gamepad-adapter'

useDragDrop({
  adapters: [new PointerAdapter(), new KeyboardAdapter(), new GamepadAdapter()],
})

adapters: [] disables both defaults entirely — useful for server-driven or test scenarios.

Custom adapters

Extend the abstract DragDropAdapter base for shared cleanup + dispose() lifecycle and the locate() DOM-walk helper:

ts
import { DragDropAdapter } from '@vuetify/v0'
import type { DragDropAdapterContext, DragType } from '@vuetify/v0'

class GamepadAdapter<Z extends DragType = DragType> extends DragDropAdapter<Z> {
  setup (context: DragDropAdapterContext<Z>): void {
    // observe input, then call:
    //   context.emit.start(source, origin, 'gamepad')
    //   context.emit.move(point)
    //   context.emit.drop()
    //   context.emit.cancel()
    this.cleanup = () => { /* tear down listeners */ }
  }
}

context.emit exposes start(source, origin, via), move(point), drop(), and cancel() — call these as input arrives. Adapters declare their own via value (typed as DragVia) so consumers reading active.value.via can distinguish the input source. DragVia is Extensible<'pointer' | 'keyboard'> — additional modalities (e.g. 'gamepad') flow through without type-level coordination.

Architecture

The factory owns four pieces of state (draggables, zones, active, isDragging) plus a public cancel() action, and three extension points (adapters, plugins, lifecycle hooks). Pointer and keyboard adapters observe the DOM and emit a four-call lifecycle (start, move, drop, cancel); the factory pipes those through per-ticket and global hooks before mutating active.

useDragDrop architecture

Use controls to zoom and pan. Click outside or press Escape to close.

useDragDrop architecture

Options

OptionTypeDefaultDescription
adaptersDragDropAdapter<Z>[][PointerAdapter, KeyboardAdapter]Replaces the default adapter array when provided. [] disables both.
pluginsDragDropPlugin<Z>[][]Install-time observers. Each plugin receives the public context and may return a disposer. No plugins ship in v1 — you write them.
onBeforeStart(drag) => boolean | voidReturn false to veto the start. Runs while active is still null; read the drag argument.
onMove(drag) => voidFires on every move while active is set.
onBeforeDrop(drag, position) => boolean | voidReturn false to veto the drop (reason: 'reject'). Runs while active is still set.
onDrop(drag, position) => voidFires after active is cleared to null. Read the drag argument, not the ref.
onCancel(drag, reason) => voidreason is 'cancel' (Escape, dnd.cancel()) or 'reject' (drop veto). Fires after active is cleared.

Per-ticket hooks (onBeforeStart, onMove, onCancel on a draggable; onEnter, onLeave, onBeforeDrop, onDrop on a zone) have the same signatures and the same active timing as the matching global option. disabled?: MaybeRefOrGetter<boolean> on either registration skips pointer/keyboard start (draggable) or hit-testing (zone).

Reactivity

Reactive fields

Every consumer-facing state field is a reactive ref . Reads in templates need .value.

FieldShapeUpdates when
dnd.activeReadonly<ShallowRef<ActiveDrag<Z> | null>>A drag starts, moves, drops, or cancels
dnd.isDraggingReadonly<Ref<boolean>>active becomes non-null / null
ticket.isDraggingReadonly<Ref<boolean>>This specific ticket is the active drag
ticket.elReadonly<Ref<HTMLElement | null>>Mounts / unmounts (registry element-ref pattern)
zone.isOverReadonly<Ref<boolean>>The active drag’s over field equals this zone’s id
zone.willAcceptReadonly<Ref<boolean>>An active drag matches this zone’s accept policy
zone.indicatorReadonly<Ref<DropIndicator | null>>While over an oriented zone, computes the index/edge/rect of the resolved drop slot. null over an unoriented or empty zone — and over the two slots flanking the dragged element’s own position in its home zone, which are stays, not moves
zone.elReadonly<Ref<HTMLElement | null>>Mounts / unmounts (registry element-ref pattern)

Active drag

dnd.active.value is null when idle. While set, the object is:

FieldShapeNotes
idIDRegistry ticket id. Auto-generated unless you pass id to register. Not your payload id.
type / valueZ['type'] / Z['value']Discriminated payload. Narrow on type to narrow value.
origin{ x, y }Pointer (or keyboard start point) at pickup
current{ x, y }Latest point
delta{ x, y }current - origin
overID | nullZone id under the point, or null
willAcceptbooleanWhether that zone’s accept matches this drag
viaDragVia'pointer', 'keyboard', or an adapter-declared extension. Read this to branch keyboard-only behaviors like focus restoration.

Indicator rects are cached per zone; getBoundingClientRect runs only when the zone resizes or its children mount/unmount, not on each pointer move. The index is measured against every element child of the zone el (zoneEl.children) — headers, spacers, and an in-zone indicator all count as slots. Keep the indicator as a sibling of the zone, not a child.

Methods

MethodPurpose
dnd.draggables.register(input)Register a draggable. Requires el, type, and value. Returns a ticket with isDragging and unregister().
dnd.zones.register(input)Register a drop zone. Requires el. Returns a ticket with isOver, willAccept, indicator, and unregister().
ticket.unregister()Drop a ticket from its registry. Child components that register must unregister on unmount — the factory only disposes remaining tickets when its own scope tears down. Unregistering the active draggable cancels the drag.
dnd.cancel()Programmatically cancel the active drag. Fires onLeave on the over-zone, then per-draggable onCancel, then global onCancel, with reason: 'cancel'. No-op when no drag is active.

DOM attributes

The composable does not produce attribute objects — consumers wire data attributes themselves so the design-system layer can choose its own keys. Adapters never read these; they locate by registered el. The canonical wiring is:

Draggable element:

  • data-draggable (always)

  • aria-roledescription="draggable" (always)

  • data-dragging toggled while ticket.isDragging.value is true

  • tabindex="0" (or a roving tabindex) so KeyboardAdapter can find the focused ticket

  • touch-action: none (CSS or style="touch-action: none") so the browser doesn’t pan/zoom on pointer drag

Drop zone element:

  • data-dropzone (always)

  • data-over toggled while zone.isOver.value is true

  • data-accepts toggled while both zone.isOver.value && zone.willAccept.value are true

Examples

Basic two-list drag

Pick up an item with the pointer or keyboard (Space / Enter) and drop it in either list. The example splits the surface across three files to mirror how a real consumer would compose the primitive: a DragItem that registers itself as a draggable, a DropList that registers itself as a zone, paints zone.indicator as a sibling bar, and renders draggables, and a basic entry that wires the lists together and owns the data.

The zones declare orientation: 'vertical' to opt into list-style index resolution — the onDrop callback receives position.index against the pre-move child list. Same-list downward moves subtract 1 after removing the source so the splice lands in the intended slot; cross-list drops use the index as-is. While a drag is active the wrapper toggles cursor-grabbing, each zone shows a primary-tinted ring when it would accept the drag, and a 2px bar marks the resolved slot. The bar lives outside the zone el so it is not counted as a child slot.

Reach for this shape when you want a sortable list with cross-container moves and headless control over visual affordances. For a single-list reorder driven by createSortable instead of a plain array, see that page’s drag-and-drop example. For a two-level board, compose with createKanban. For more drag types in the same scope (e.g. items and their containers), widen the discriminated union — the type narrowing on drag.type carries the corresponding drag.value through.

This example uses tabindex="0" on every item so keyboard pickup works without extra wiring. Production lists usually add useRovingFocus so each zone is one tab stop — see Accessibility. Share the dnd context with Vue provide / inject when prop-threading gets noisy; there is no first-class drag-drop trinity.

FileRole
DragItem.vueReceives the shared dnd context as a prop and registers itself as a draggable via dnd.draggables.register({ el, type, value })
DropList.vueReceives the shared dnd context as a prop, registers itself as a zone, renders zone.indicator as a sibling, and emits move events upward
basic.vueOwns the lists, calls useDragDrop() to create the context, threads it to children, and applies the pre-move index adjustment on same-list reorder
Apple
Banana
Cherry
Theme
Mode
Palettes
Accessibility

Recipes

Multiple drag types in one scope

Default to a single type per scope (useDragDrop<{ type: 'card', value: Card }>()) — every draggable and zone shares one shape, every callback narrows trivially. Widen Z to a discriminated union only when you need cross-type interactions in the same scope (e.g. a kanban where cards drop on columns and columns drop on a column-row); a separate useDragDrop() per scope is cleaner whenever the types don’t meet.

When you do widen, type narrowing on drag.type carries the corresponding drag.value through, so each variant keeps its payload shape across onDrop and accept.

ts
type KanbanTypes =
  | { type: 'card', value: Card }
  | { type: 'column', value: Column }

const dnd = useDragDrop<KanbanTypes>()

// Card zone accepts only cards
dnd.zones.register({ el, accept: ['card'], onDrop: (drag, position) => {
  // drag.type narrows to 'card', drag.value to Card
}})

// Column-row zone accepts only columns
dnd.zones.register({ el, accept: ['column'], orientation: 'horizontal' })

Vetoing drops

Either layer can veto. Per-zone vetoes route the drag through the cancel chain (onLeave on the active zone → onCancel on the source draggable → global onCancel) so consumers can roll back optimistic UI without subscribing to a separate “drop failed” event. Both onCancel callbacks (per-draggable and global) receive a second argument reason: 'cancel' | 'reject''reject' when the cancel was triggered by a drop veto, 'cancel' for user-initiated aborts (Escape, programmatic dnd.cancel()).

Tip

onDrop, onCancel, and onLeave-during-cancel fire AFTER dnd.active.value is cleared to null — read the drag argument, not the reactive ref. onMove, onEnter, onLeave during a drag, onBeforeStart, and onBeforeDrop run while active still holds the draft (or, for onBeforeStart, before it is written). The cleared-before-notify ordering on drop/cancel prevents re-entrance loops when a hook calls dnd.cancel() or unregisters a ticket.

accept (function form) must return synchronously — predicates that return a Promise / thenable are rejected with a console warning. Wrap async work in onBeforeDrop instead, returning false to veto.

ts
dnd.zones.register({
  el,
  accept: ['card'],
  onBeforeDrop: (drag) => column.cards.length < column.wipLimit,
})

// Per-draggable cancel can react to the reason:
dnd.draggables.register({
  el,
  type: 'card',
  value: card,
  onCancel: (drag, reason) => {
    if (reason === 'reject') notify()
  },
})

Installing a plugin

A plugin is (context) => disposer. Nothing named scroll() or flip() is exported — write autoscroll or FLIP yourself against active and the registry event bus.

ts
import { useDragDrop } from '@vuetify/v0'
import type { DragDropPlugin } from '@vuetify/v0'

const logDrops: DragDropPlugin = context => {
  function onRegister (ticket: { id: string }) {
    console.log('zone', ticket.id)
  }
  context.zones.on('register:ticket', onRegister)
  return () => context.zones.off('register:ticket', onRegister)
}

const dnd = useDragDrop({ plugins: [logDrops] })

Accessibility

WAI-ARIA does not standardize a kanban or “drag list” pattern. The primitive follows the list-of-lists convention used by Pragmatic DnD, dnd-kit, and headless-ui:

  • Draggable tickets carry aria-roledescription="draggable" only — no aria-grabbed or aria-dropeffect, both deprecated in ARIA 1.1.

  • Wrap each drop zone in a container with role="list" and the draggable list items with role="listitem".

  • Each zone should wire a roving tabindex via useRovingFocus — one focus stop per zone, arrow keys move between items in the same zone while idle, Tab moves to the next zone. The two-list example uses tabindex="0" on every item instead, so every card is a tab stop.

  • Provide a single live region per scope (<div role="status" aria-live="polite">) and watch active to announce moves (“Card moved to Done, position 2 of 5”). The live region is the consumer’s responsibility — the headless contract excludes user-facing strings (PHILOSOPHY §5.5).

Keyboard maps split on whether a drag is active:

StateSpace / EnterArrow keysEscape
IdlePick up the focused ticketRoving focus between items (your useRovingFocus, not the adapter)
DraggingDropNudge the drag point by step px (default 16). KeyboardAdapter calls preventDefault, so roving does not see these keys.Cancel

Post-drop focus

After a successful keyboard drop, the moved element is typically replaced by the consumer’s onDrop handler — focus then lands on <body>, breaking keyboard flow. Restore it explicitly: in onDrop, after mutating the source list, call nextTick and refocus the new element by id (or rely on useRovingFocus to refocus the active item). Branch on drag.via === 'keyboard' (the first argument to onDrop) so the restoration only runs for keyboard drags, not pointer drags. dnd.active.value is already null inside onDrop / onCancel — read the drag argument instead.

FAQ

Discord
Need help? Join our community for support and discussions ↗

API Reference

The following API details are for the useDragDrop composable.
Was this page helpful?

© 2016-1970 Vuetify, LLC
Services
Ctrl+/