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
<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.
<DnDProvider> throws
No <DnDProvider> found above this component: Vue's inject only sees ancestors.What it owns
| Part | What the provider does |
|---|---|
| Input | Turns pointer, touch and keyboard input into drags (sensors, activation), one drag at a time. |
| Finding the target | Hit-tests the drop targets under the drag (collision) and tells them. |
| The overlay | Renders the box that follows the pointer, teleported to body, and lands it on drop. |
| The selection | One set of selected ids, shared by every list and draggable under it. |
| Auto-scroll | Scrolls containers and the window when a drag nears their edges. |
| The live region | Announces 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.
| Prop | Default | What it sets |
|---|---|---|
collision | pointerFirst | How the target under the drag is chosen. Below. |
activation | mouse 4 px, touch 200 ms | When a press becomes a drag, per pointer kind. Below. |
autoScroll | on | Edge scrolling: { threshold, maxSpeed, acceleration } or false. Below. |
haptics | a short vibration on touch pickup | Vibrations 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 / dropAnimation | on | Items sliding while sorting / the overlay landing: see animations. |
animateChanges | off | Default for lists and trees: animate data changes no drag made. |
keyboard | Space / Enter, Escape | Keyboard keys and step; false turns keyboard drags off. See accessibility. |
sensors | pointer + keyboard | What 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. |
overlayTransition | a crossfade | How 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. |
overlayPreviews | 5 | Multi-drag: how many dragged items get a clone at pickup, or 'all'. See the default overlay. |
announcements | built-in | Screen reader texts, or false. |
screenReaderInstructions | built-in | The text every draggable points to with aria-describedby. |
defaultStyles | true | Injects 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()
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();
| Field | What 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). |
active | What is dragged: id, ids (every dragged id, the grabbed one first), items, data, type, element, rect, sensor. null when idle. |
over | The target under the drag: id, data, element, rect and accepted. null over nothing. |
pointer | The pointer in viewport coordinates, after modifiers. |
activation.progress | 0 to 1 while a press waits for its activation: draw a ring with it. |
isAutoScrolling | Auto-scroll is moving something. |
selection | The 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. |
<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.
| Event | When |
|---|---|
onDragStart | A drag starts. |
onDragMove | The drag moves. |
onDragOver | The target under the drag changes. |
onBeforeDrop | The drag is released. Its answer decides the drop: see deciding a drop. |
onDragEnd | The drop went through. Sortable lists have already run their onDrop and written the new order. dropped is false when it ended over no target. |
onDragCancel | Escape, a drop on a target that refuses it, or an onBeforeDrop that answered 'reject'. |
onDragResume | onBeforeDrop answered 'continue': nothing dropped, and the drag goes on. |
onDragSettle | The 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.
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"><DnDProvider :collision="{{ mode }}"></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:
| Function | Ranks | Good for |
|---|---|---|
pointerFirst | What is under the pointer; when that's nothing, what the dragged box overlaps. | The default: precise under the pointer, forgiving in the gaps. |
pointerWithin | Only what is under the pointer, the innermost first. | Precise targets: a gap drops nowhere. |
rectIntersection | Every 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. |
closestCenter | Every target, by the distance from the box's center to theirs. | Something always wins, even far away: grids and lists of same-size items. |
closestCorners | Every 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
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);
};
| Argument | What it holds |
|---|---|
pointer | The pointer, after modifiers. |
overlayRect | The dragged box as it is shown, null without an overlay. |
candidates | Every target in play: id, rect (its visible part, clipped by scroll containers), priority, depth (0 at the top, deeper when nested). |
hits | The 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)).
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 } }">
| Pointer | Default |
|---|---|
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.
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.
| Option | Default | What it sets |
|---|---|---|
threshold | 0.2 | The edge zone, as a fraction of the container's size, between 24 and 96 px. |
acceleration | 10 | The 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. |
maxSpeed | 25 | The 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] }">
| Key | Vibrates when | Default |
|---|---|---|
start | The long press turns into a drag. | true (10 ms) |
enter | The drag enters a target that accepts it. | off |
drop | The drop goes through. | off |
refuse | The 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.
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"><DnDProvider></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.
makeDragArea(board, { modifiers: [snapToGrid(20)], activation: { touch: { delay: 400 } } });
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.