Skip to main content
Vuetify0 is now in alpha!
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

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.

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

  const dnd = useDragDrop<{ type: 'card', value: string }>()

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

  dnd.draggables.register({
    el: draggable,
    type: 'card',
    value: 'card-1',
  })

  dnd.zones.register({
    el: dropzone,
    accept: ['card'],
    orientation: 'vertical',
    onDrop: (drag, position) => {
      console.log(drag.value, position.index) // 'card-1', 0
    },
  })
</script>

<template>
  <div
    ref="draggable"
    data-draggable
    aria-roledescription="draggable"
  >
    Card
  </div>

  <div ref="dropzone" data-dropzone>
    Drop zone
  </div>
</template>

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.

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

PointerAdapter

Pointer Events for mouse, touch, and pen. Installed by default.

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 { TouchAdapter } from './touch-adapter'

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

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 TouchAdapter<Z extends DragType = DragType> extends DragDropAdapter<Z> {
  setup (context: DragDropAdapterContext<Z>): void {
    // observe input, then call:
    //   context.emit.start(source, origin, 'touch')
    //   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. 'touch', 'gamepad') flow through without type-level coordination.

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.active.value.viaDragViaSource modality ('pointer', 'keyboard', or any extension) — read to branch keyboard-only behaviors like focus restoration
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 position
zone.elReadonly<Ref<HTMLElement | null>>Mounts / unmounts (registry element-ref pattern)

Indicator rects are cached per zone; getBoundingClientRect runs only when the zone resizes or its children mount/unmount, not on each pointer move.

Methods

MethodPurpose
dnd.cancel()Programmatically cancel the active drag. Fires the cancel chain (onLeave on the over-zone → per-draggable onCancel → 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. The canonical wiring is:

Draggable element:

  • data-draggable (always)

  • aria-roledescription="draggable" (always)

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

  • 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

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

All hooks fire AFTER dnd.active.value is cleared to null — read the drag argument inside onDrop, onCancel, and onLeave-during-cancel rather than re-reading the reactive ref. The cleared-before-notify ordering 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()
  },
})

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, Tab moves to the next zone.

  • 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).

The default KeyboardAdapter honours the standard contract: Space / Enter to pick up and drop, arrow keys to nudge the drag point by step px (default 16), Escape to 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

API

API Reference

The following API details are for the useDragDrop composable.

Functions

useDragDrop

(options?: DragDropOptions<Z>) => DragDropContext<Z>

Create a headless drag-and-drop context.

Options

adapters

DragDropAdapter<Z>[] | undefined

plugins

DragDropPlugin<Z>[] | undefined

onBeforeStart

((drag: ActiveDrag<Z>) => boolean | void) | undefined

onMove

((drag: ActiveDrag<Z>) => void) | undefined

onBeforeDrop

((drag: ActiveDrag<Z>, position: DropPosition) => boolean | void) | undefined

onDrop

((drag: ActiveDrag<Z>, position: DropPosition) => void) | undefined

onCancel

((drag: ActiveDrag<Z>, reason: "cancel" | "reject") => void) | undefined

Properties

draggables

DraggablesContext<Z>

zones

ZonesContext<Z>

active

Readonly<ShallowRef<ActiveDrag<Z> | null>>

isDragging

Readonly<Ref<boolean, boolean>>

Methods

cancel

() => void
Was this page helpful?

Ctrl+/