Docs/Guides

The provider

DnDProvider owns every drag under it. It finds the target, starts drags, scrolls, and tells useDnD and useDnDMonitor everything that happens.

One provider for the app

App.vue
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
</script>

<template>
  <DnDProvider>
    <RouterView />
  </DnDProvider>
</template>

Put one DnDProvider high up, in App.vue or a layout, and forget about it. Every composable (makeSortableList, makeDraggable, useDnD…) looks for the closest provider above the component it runs in.

A composable must run in a component rendered inside the provider. Calling it in the component whose own template contains <DnDProvider> throws No <DnDProvider> found above this component: Vue's inject only sees ancestors.

What it owns

PartWhat the provider does
InputTurns pointer, touch and keyboard input into drags (sensors, activation), one drag at a time.
Finding the targetHit-tests the drop targets under the drag (collision) and tells them.
The overlayRenders the box that follows the pointer, teleported to body, and lands it on drop.
The selectionOne set of selected ids, shared by every list and draggable under it.
Auto-scrollScrolls containers and the window when a drag nears their edges.
The live regionAnnounces pickups, moves, drops and cancels to screen readers.

It renders nothing else: your markup stays as it is.

Options at a glance

Every option has a default that works, so the provider usually takes none. The provider reads its props when it needs them: the demos below switch collision, activation and autoScroll live.

PropDefaultWhat it sets
collisionpointerFirstHow the target under the drag is chosen. Below.
activationmouse 4 px, touch 200 msWhen a press becomes a drag, per pointer kind. Below.
autoScrollonEdge scrolling: { threshold, maxSpeed, acceleration } or false. Below.
hapticsa short vibration on touch pickupVibrations for touch drags, or false. Below.
copyModifier'Alt'Key that, held while dragging, turns a move into a copy.
modifiers[]Movement rules for every drag: see constraints.
animation / dropAnimationonItems sliding while sorting / the overlay landing: see animations.
animateChangesoffDefault for lists and trees: animate data changes no drag made.
keyboardSpace / Enter, EscapeKeyboard keys and step; false turns keyboard drags off. See accessibility.
sensorspointer + keyboardWhat turns input into drags: see sensors.
overlayTo'body'Where the overlay is teleported.
overlayFit'slot'Overlay size: the slot it will land in, or the source's. See the overlay.
overlayTransitiona crossfadeHow the overlay changes from one component to another mid-drag: false, or <Transition> props. See targets that change the overlay.
overlayClass—Classes for the overlay's content ([data-dnd-overlay-content]), the element to animate as a whole: a lift as the drag picks up, a settle as it lands. See the overlay: lift, tilt, settle.
overlayPreviews5Multi-drag: how many dragged items get a clone at pickup, or 'all'. See the default overlay.
announcementsbuilt-inScreen reader texts, or false.
screenReaderInstructionsbuilt-inThe text every draggable points to with aria-describedby.
defaultStylestrueInjects zero-specificity CSS for drag states: see styling.

The full list with every shape is in the DnDProvider API. The provider also emits drag-start, drag-move, drag-over, drag-end, drag-cancel, drag-resume and drag-settle, the same events useDnDMonitor hears.

Read the drag: useDnD()

Live

Loading the demo…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Tasks from './Tasks.vue';
import StatePanel from './StatePanel.vue';
</script>

<template>
  <!-- One provider: the list drags, the panel reads the drag. Neither imports the other. -->
  <DnDProvider>
    <div class="demo">
      <Tasks />
      <StatePanel />
    </div>
  </DnDProvider>
</template>

<style scoped>
.demo {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: center;
  gap: 24px;
}
</style>

useDnD() returns the drag as reactive state, in any component under the provider:

import { useDnD } from '@vue-dnd-kit/core';

const { status, active, over, pointer, activation, isAutoScrolling, selection, cancel } = useDnD();
FieldWhat it holds
status'idle', 'activating' (a press waiting for its activation), 'dragging', 'dropping' (no drag runs, but an overlay still lands, or an async onBeforeDrop decides), 'selecting' (a rubber band).
activeWhat is dragged: id, ids (every dragged id, the grabbed one first), items, data, type, element, rect, sensor. null when idle.
overThe target under the drag: id, data, element, rect and accepted. null over nothing.
pointerThe pointer in viewport coordinates, after modifiers.
activation.progress0 to 1 while a press waits for its activation: draw a ring with it.
isAutoScrollingAuto-scroll is moving something.
selectionThe provider's selection: ids, has, add, delete, toggle, set, clear. See multi-drag.
cancel()Cancels the running drag (the items fly back), a charging press, or a selection band.
DropHint.vue
<script setup lang="ts">
import { computed } from 'vue';
import { useDnD } from '@vue-dnd-kit/core';

const { status, over } = useDnD();
const refused = computed(() => status.value === 'dragging' && over.value?.accepted === false);
</script>

<template>
  <p v-if="refused" role="status">This list doesn't take that.</p>
</template>

As soon as a drop is decided, active, over and pointer read null, even while the overlay is still landing (status reads 'dropping'). UI derived from them never shows the old drag next to the new data. While an async onBeforeDrop waits, the state stays as it was at release.

The next drag doesn't wait for a landing: it starts at once, and status reads 'dragging' while the last overlay lands around it. A press on a landing overlay catches it in flight: the drag goes on from where the overlay is. onDragSettle tells when a drag's overlay is home.

Listen to every drag: useDnDMonitor()

import { useDnDMonitor } from '@vue-dnd-kit/core';

useDnDMonitor({
  onDragStart: ({ active }) => {},
  onDragMove: ({ pointer, delta }) => {},
  onDragOver: ({ over }) => {},                  // the target changed
  onBeforeDrop: (e) => 'accept',                 // or 'reject', 'continue', some ids; may be async
  onDragEnd: ({ active, over, dropped, accepted, position }) => {},
  onDragCancel: ({ active }) => {},
  onDragResume: ({ active }) => {},              // onBeforeDrop answered 'continue'
  onDragSettle: ({ active, over }) => {},        // its overlay has landed
});

A monitor hears every drag under its provider, wherever it starts: the panel above logs the list's drags without importing it. It stops when the component that called it unmounts.

EventWhen
onDragStartA drag starts.
onDragMoveThe drag moves.
onDragOverThe target under the drag changes.
onBeforeDropThe drag is released. Its answer decides the drop: see deciding a drop.
onDragEndThe drop went through. Sortable lists have already run their onDrop and written the new order. dropped is false when it ended over no target.
onDragCancelEscape, a drop on a target that refuses it, or an onBeforeDrop that answered 'reject'.
onDragResumeonBeforeDrop answered 'continue': nothing dropped, and the drag goes on.
onDragSettleThe drag's overlay has landed (or flown back, or was caught by the next drag), with the event onDragEnd or onDragCancel got. Other drags may have started meanwhile.

Every event carries active, over, pointer, delta (the offset from the start), position (where the dragged box sits in over, see drag and drop) and, for items from a sortable list, source. The full shapes are in the events API.

onDragEnd is the place for "after the drop" work: saving, clearing the selection, analytics. A list's own onDrop runs before its write, so it still sees the old order.

Finding the target

Pick a function, then drag the pin or the card over the targets. The badges show how the function ranks them on this frame; the green one is the target.

Live

Loading the demo…

<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue';
import {
  DnDProvider,
  closestCenter,
  closestCorners,
  pointerFirst,
  pointerWithin,
  rectIntersection,
  type CollisionDetection,
  type DnDEndEvent,
  type Key,
} from '@vue-dnd-kit/core';
import Desk from './Desk.vue';
import { magnet } from './magnet';

const DETECTORS: Record<string, CollisionDetection> = {
  pointerFirst,
  pointerWithin,
  rectIntersection,
  closestCenter,
  closestCorners,
  magnet,
};
const names = Object.keys(DETECTORS);
const mode = ref('pointerFirst');

// The ranking of the current frame, best first: a wrapper around the chosen function
// records it, so the targets can show their rank.
const ranked = shallowRef<Key[]>([]);
const collision = computed<CollisionDetection>(() => {
  const detect = DETECTORS[mode.value]!;
  return (args) => (ranked.value = detect(args));
});

const result = ref('Drag the pin or the card over the targets');
const onEnd = ({ over }: DnDEndEvent) => {
  ranked.value = [];
  result.value = over ? `Dropped on ${over.id}` : 'Dropped on nothing';
};
const onCancel = () => {
  ranked.value = [];
  result.value = 'Cancelled';
};
</script>

<template>
  <DnDProvider :collision="collision" @drag-end="onEnd" @drag-cancel="onCancel">
    <div class="lab">
      <div class="modes" role="radiogroup" aria-label="Collision detection">
        <label v-for="name in names" :key="name" :class="{ on: mode === name }">
          <input v-model="mode" type="radio" :value="name" />
          {{ name }}
        </label>
      </div>
      <code class="call">&lt;DnDProvider :collision="{{ mode }}"&gt;</code>
      <Desk :ranked="ranked" />
      <p class="result" aria-live="polite">{{ result }}</p>
    </div>
  </DnDProvider>
</template>

<style scoped>
.lab {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
}

.modes {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 2px;
  max-width: 100%;
  padding: 3px;
  border-radius: 9px;
  background: color-mix(in srgb, currentColor 7%, transparent);
}

.modes label {
  padding: 5px 9px;
  border-radius: 6px;
  font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
  cursor: pointer;
}

.modes label.on {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
  font-weight: 600;
}

.modes label:has(input:focus-visible) {
  outline: 2px solid #42b883;
}

.modes input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.call {
  max-width: 100%;
  padding: 6px 10px;
  overflow-x: auto;
  border-radius: 8px;
  background: color-mix(in srgb, currentColor 6%, transparent);
  font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
  white-space: nowrap;
  color: #42b883;
}

.result {
  margin: 0;
  font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.6;
}
</style>

On every frame of a drag, the provider gives collision the drop targets in play, and it returns their ids, best first. Five functions come with the library:

FunctionRanksGood for
pointerFirstWhat is under the pointer; when that's nothing, what the dragged box overlaps.The default: precise under the pointer, forgiving in the gaps.
pointerWithinOnly what is under the pointer, the innermost first.Precise targets: a gap drops nowhere.
rectIntersectionEvery target the dragged box overlaps: higher priority, then deeper nesting, then the bigger overlap.Big items over small targets, where the pointer is at the item's edge.
closestCenterEvery target, by the distance from the box's center to theirs.Something always wins, even far away: grids and lists of same-size items.
closestCornersEvery target, by the distances between the four corners.Columns stacked side by side (kanban), targets of very different heights.

Under the pointer, hit-testing respects what the user sees: of overlapping targets only the one on top is hit (front, never back where they overlap), and of nested ones the innermost comes first (subfolder, then folder).

A function of your own

magnet.ts
import type { CollisionDetection, Coordinates, Rect } from '@vue-dnd-kit/core';

/** How far the point is from the rect: 0 inside it. */
const gap = (rect: Rect, point: Coordinates) =>
  Math.hypot(
    Math.max(rect.left - point.x, 0, point.x - rect.right),
    Math.max(rect.top - point.y, 0, point.y - rect.bottom),
  );

/** What is under the pointer; in a gap, the nearest target within 40px. */
export const magnet: CollisionDetection = ({ hits, pointer, candidates }) => {
  if (hits.length) return hits;
  return candidates
    .filter((candidate) => gap(candidate.rect, pointer) <= 40)
    .sort((a, b) => gap(a.rect, pointer) - gap(b.rect, pointer))
    .map((candidate) => candidate.id);
};
ArgumentWhat it holds
pointerThe pointer, after modifiers.
overlayRectThe dragged box as it is shown, null without an overlay.
candidatesEvery target in play: id, rect (its visible part, clipped by scroll containers), priority, depth (0 at the top, deeper when nested).
hitsThe targets under the pointer, from hit-testing, best first.

Return an empty array for "no target". Disabled targets, targets inside the dragged items and targets hidden by their scroll containers never reach the function. A target that refuses the drag still competes: over it, over.accepted is false and a drop is refused (a target inside a sortable item steps aside instead, so the list sorts over it). The demo wraps the chosen function to record its ranking: (args) => (ranked.value = detect(args)).

The provider damps the switches the function asks for. A target the drag just left can't win again until the pointer has moved 10 px, so boundaries don't flicker. Between the targets nested in a parent (the gaps of a column of cards), the last target stays.

When a press becomes a drag

A press doesn't drag at once. activation sets, per pointer kind (mouse, touch, pen), what it waits for: a distance in px, a delay in ms (with a tolerance past which the press gives up, so the page can scroll), or whichever of the two comes first.

<DnDProvider :activation="{ mouse: { distance: 8 }, touch: { delay: 300, tolerance: 8 } }">
PointerDefault
mouse, pen{ distance: 4 }: a click stays a click. A mouse press on a handle starts at once, and is still a click if it doesn't move.
touch{ delay: 200, tolerance: 5 }: a long press, so lists keep scrolling under a finger.

A kind you leave out keeps its default. A draggable's own activation wins over a drag area's, which wins over the provider's. While a press waits, useDnD().status is 'activating' and activation.progress runs from 0 to 1; Escape or useDnD().cancel() gives it up. Try the constraints live, and draw a long-press ring from the progress, in sensors.

Auto-scroll

Drag a channel toward the top or the bottom of the list. The green bands are the edge zones: the deeper into one, the faster the list scrolls.

Live

Loading the demo…

<script setup lang="ts">
import { computed, ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Channels from './Channels.vue';

const OPTIONS = {
  threshold: [0.1, 0.2, 0.4],
  maxSpeed: [10, 25, 50],
  acceleration: [4, 10, 25],
} as const;

const on = ref(true);
// The defaults: an edge zone of 20% of the container (24 to 96px), 10px a frame at the
// edge, up to 25px a frame past it.
const options = ref({ threshold: 0.2, maxSpeed: 25, acceleration: 10 });
const autoScroll = computed(() => (on.value ? options.value : false));
const call = computed(() =>
  on.value
    ? `:auto-scroll="{ threshold: ${options.value.threshold}, maxSpeed: ${options.value.maxSpeed}, acceleration: ${options.value.acceleration} }"`
    : ':auto-scroll="false"',
);
</script>

<template>
  <DnDProvider :auto-scroll="autoScroll">
    <div class="demo">
      <div class="switches">
        <label class="switch">
          <input v-model="on" type="checkbox" />
          autoScroll
        </label>
        <fieldset v-for="(values, name) in OPTIONS" :key="name" :disabled="!on">
          <legend>{{ name }}</legend>
          <label v-for="value in values" :key="value" :class="{ on: options[name] === value }">
            <input v-model="options[name]" type="radio" :value="value" />
            {{ value }}
          </label>
        </fieldset>
      </div>
      <code class="call">{{ call }}</code>
      <Channels :threshold="on ? options.threshold : 0" />
    </div>
  </DnDProvider>
</template>

<style scoped>
.demo {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.switches {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 10px 14px;
}

fieldset {
  display: inline-flex;
  margin: 0;
  padding: 3px;
  border: 0;
  border-radius: 9px;
  background: color-mix(in srgb, currentColor 7%, transparent);
}

fieldset:disabled {
  opacity: 0.4;
}

legend {
  float: left;
  align-self: center;
  padding: 0 8px 0 6px;
  font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.55;
}

fieldset label {
  padding: 5px 9px;
  border-radius: 6px;
  font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
  cursor: pointer;
}

fieldset label.on {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
  font-weight: 600;
}

fieldset label:has(input:focus-visible),
.switch:has(input:focus-visible) {
  outline: 2px solid #42b883;
}

fieldset input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.switch {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 10px;
  border-radius: 9px;
  background: color-mix(in srgb, currentColor 7%, transparent);
  font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
  cursor: pointer;
}

.switch input {
  accent-color: #42b883;
}

.call {
  max-width: 100%;
  padding: 6px 10px;
  overflow-x: auto;
  border-radius: 8px;
  background: color-mix(in srgb, currentColor 6%, transparent);
  font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
  white-space: nowrap;
  color: #42b883;
}
</style>

Any element with overflow: auto or scroll scrolls by itself, and so does the window. The provider scrolls the container under the pointer first, then its ancestors, then the window.

OptionDefaultWhat it sets
threshold0.2The edge zone, as a fraction of the container's size, between 24 and 96 px.
acceleration10The speed at the very edge, in px per frame. It grows with the square of the depth into the zone, so the start is slow and precise.
maxSpeed25The top speed. Past the edge (the pointer outside the container), the speed keeps growing up to it.

:auto-scroll="false" turns it off. A direction scrolls only once the pointer has moved that way, so an item picked up near an edge doesn't scroll by itself. Lists, trees and drag areas take an autoScroll of their own for their container:

makeSortableList(list, { items: channels, autoScroll: { maxSpeed: 10 } });

Keyboard drags never auto-scroll: they scroll the item into view instead. isAutoScrolling (from useDnD(), or a drag area) is true while something scrolls.

Haptics and the copy key

A touch drag vibrates briefly when it starts. :haptics="false" turns that off; an object sets each vibration:

<DnDProvider :haptics="{ start: true, enter: 10, drop: 10, refuse: [20, 40, 20] }">
KeyVibrates whenDefault
startThe long press turns into a drag.true (10 ms)
enterThe drag enters a target that accepts it.off
dropThe drop goes through.off
refuseThe drop is refused and the items fly back.off

Each takes true (the default pattern), a length in ms, an on/off pattern, or false. Only drags started by touch vibrate, and iOS Safari can't vibrate at all.

copyModifier is the key that, held during a drag between lists, turns the move into a copy: 'Alt' by default, any KeyboardEvent.key, or null for none. The list the items come from needs a clone function; see lists that share items.

Several providers

Most apps need exactly one. Put the wishlist under its own provider, then try to move a book across: it can't leave its provider, and the outer monitor stops hearing the wishlist.

Live

Loading the demo…

<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Shelf from './Shelf.vue';
import EventLog from './EventLog.vue';

const reading = ref([
  { id: 'dune', title: 'Dune', hue: 32 },
  { id: 'emma', title: 'Emma', hue: 330 },
  { id: 'ulysses', title: 'Ulysses', hue: 210 },
]);
const wishlist = ref([
  { id: 'beloved', title: 'Beloved', hue: 140 },
  { id: 'solaris', title: 'Solaris', hue: 260 },
]);

const separate = ref(false);
</script>

<template>
  <DnDProvider>
    <div class="demo">
      <div class="modes" role="radiogroup" aria-label="Wishlist">
        <label :class="{ on: !separate }"><input v-model="separate" type="radio" :value="false" />one provider</label>
        <label :class="{ on: separate }"><input v-model="separate" type="radio" :value="true" />wishlist in its own</label>
      </div>
      <div class="columns">
        <div class="column">
          <Shelf id="reading" v-model="reading" title="Reading" />
          <EventLog title="outer monitor" />
        </div>
        <!-- A provider inside a provider: its items belong to it, and drags never cross. -->
        <DnDProvider v-if="separate">
          <div class="column island">
            <span class="tag">&lt;DnDProvider&gt;</span>
            <Shelf id="wishlist" v-model="wishlist" title="Wishlist" />
            <EventLog title="inner monitor" />
          </div>
        </DnDProvider>
        <div v-else class="column">
          <Shelf id="wishlist" v-model="wishlist" title="Wishlist" />
        </div>
      </div>
    </div>
  </DnDProvider>
</template>

<style scoped>
.demo {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
}

.modes {
  display: inline-flex;
  padding: 3px;
  border-radius: 9px;
  background: color-mix(in srgb, currentColor 7%, transparent);
}

.modes label {
  padding: 5px 10px;
  border-radius: 6px;
  font-size: 12px;
  cursor: pointer;
}

.modes label.on {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
  font-weight: 600;
}

.modes label:has(input:focus-visible) {
  outline: 2px solid #42b883;
}

.modes input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.columns {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 230px));
  gap: 16px;
  max-width: 100%;
}

.column {
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-width: 0;
}

.island {
  position: relative;
  margin: -10px;
  padding: 10px;
  border: 1.5px dashed color-mix(in srgb, #42b883 60%, transparent);
  border-radius: 16px;
}

.tag {
  position: absolute;
  top: -9px;
  right: 14px;
  padding: 0 6px;
  background: Canvas;
  font: 10px/18px ui-monospace, Menlo, Consolas, monospace;
  color: #42b883;
}
</style>
<DnDProvider>
  <Shelf v-model="reading" />
  <DnDProvider>
    <Shelf v-model="wishlist" />
    <EventLog />            <!-- hears the inner provider's drags only -->
  </DnDProvider>
</DnDProvider>
  • Providers are independent: a drag never crosses from one to another.
  • Nested providers are allowed. The closest owner wins: an item belongs to the nearest provider above it, and the outer provider ignores it.
  • Each provider has its own selection, overlay, live region and monitors.

A second provider makes sense only for a part of the page that needs its own provider-wide settings (collision, keyboard keys, announcements) and whose items never leave it. If items can't move between two parts of your page, check that they don't sit under two providers.

Rules for one part of the page (a board that snaps, a feed with a longer touch press, a panel where nothing drags) don't need a provider of their own:
makeDragArea(board, { modifiers: [snapToGrid(20)], activation: { touch: { delay: 400 } } });
Its items stay in the app's provider and can still move anywhere. See constraints.

An app mounted elsewhere, inside a shadow root or as a separate Vue app, can join an existing provider: pass useDnDContext() to provideDnDContext(app, context) before mounting it. Hit-testing and the overlay work across shadow roots.

SSR and Nuxt

The provider is SSR-safe. Lists and items render on the server like any other markup. The overlay and the live region are teleported and mount on the client only, so they never cause a hydration mismatch. Default ids come from Vue's useId() and match between server and client.

In Nuxt, the module registers DnDProvider globally and auto-imports the composables: see installation.