Docs/Guides
Deciding a drop
Accept, refuse or postpone a drop — rules while dragging or on release, part of a multi-drag, a dialog, a server that must agree first, monitors that veto, and drops outside every target.
Drag a card into Done. The drop waits for your answer: move the card, keep dragging it somewhere else, or cancel.
Loading the demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Board from './Board.vue';
</script>
<template>
<DnDProvider>
<Board />
</DnDProvider>
</template>onBeforeDrop
makeSortableList(listRef, {
items: tasks,
onBeforeDrop: (e) => (e.items.some((task) => task.locked) ? 'reject' : 'accept'),
});
Lists, trees, drop targets (makeDroppable) and monitors (useDnDMonitor) take an
onBeforeDrop. It runs when the drag is let go, before anything changes, and its answer
decides:
| Answer | What happens |
|---|---|
'accept', true or nothing | The drop goes through. |
'reject' or false | No drop: the items fly back. A thrown error rejects too. |
| An array of keys | Only those items drop; the others fly back. |
'continue' | No drop, and the drag goes on. |
| A promise of any of these | The overlay waits where it was let go until the promise settles. |
Lists and trees pass a SortableDropEvent: keys, items, from, to, mode, sameList and
result, the new arrays of every list the drop changes. Drop targets and monitors get the generic
DnDEvent: active, over, position and source. See events.
A rule while dragging, or on release
Email and Chat both take attachments of 10 MB at most. Drag demo.mov over each: Email refuses it while you hover, Chat takes it and sends it back when you let go.
Loading the demo…
<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Column from './Column.vue';
const columns = reactive([
{
id: 'desktop',
title: 'Desktop',
files: [
{ id: 'notes', name: 'notes.pdf', size: 2 },
{ id: 'demo', name: 'demo.mov', size: 48 },
{ id: 'logo', name: 'logo.png', size: 1 },
{ id: 'backup', name: 'backup.zip', size: 120 },
],
},
// Both take files of 10 MB at most, and say so at different times.
{ id: 'email', title: 'Email', rule: 'put' as const, files: [] },
{ id: 'chat', title: 'Chat', rule: 'onBeforeDrop' as const, files: [] },
]);
</script>
<template>
<DnDProvider>
<div class="board">
<Column v-for="column in columns" :key="column.id" :column="column" />
</div>
</DnDProvider>
</template>
<style scoped>
.board {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.board > * {
flex: 0 1 170px;
min-width: 150px;
}
</style>// Asked while dragging: a refused drag never enters the list.
group: { name: 'attachments', put: ({ items }) => fits(items as Attachment[]) },
// Asked on release: the drag enters the list, then flies back.
onBeforeDrop: (e) => (fits(e.items) ? 'accept' : 'reject'),
group.put (or a target's accept) | onBeforeDrop | |
|---|---|---|
| Asked | While dragging, as soon as the drag reaches the list. | Once, when the drag is let go. |
| The user sees | The list refuses at once: no slot opens, isAccepting is false, over.accepted is false. | The slot opens as usual; the items fly back on release. |
| Knows | What is dragged: keys, items, types, from, to. | The whole drop: to.index, sameList, mode, result. |
| Can | Say yes or no. | Wait for a promise, accept some keys, keep the drag going. |
Use group.put for what is known from the items alone (a type, a flag, a count): the
user learns it before letting go. Use onBeforeDrop for what depends on the place, the
user's answer or the server's. The target's own rules come first: over a list or a target
that refuses the drag, the drop is cancelled and its onBeforeDrop isn't asked.
Accepting some of the items
Some rules can only be checked on release, against a server. Seats of this workshop are booked by others too: on release, the list asks the server how many are left, and takes that many of the people dragged in. Tick a few, and drag them in together.
Loading the demo…
<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Column from './Column.vue';
const columns = reactive([
{
id: 'waiting',
title: 'Waiting list',
people: [
{ id: 'ada', name: 'Ada', hue: 200 },
{ id: 'bo', name: 'Bo', hue: 30 },
{ id: 'cy', name: 'Cyrus', hue: 280 },
{ id: 'dee', name: 'Dee', hue: 140 },
],
},
{
id: 'workshop',
title: 'Workshop',
seats: true,
people: [
{ id: 'eli', name: 'Eli', hue: 340 },
{ id: 'fay', name: 'Fay', hue: 90 },
],
},
]);
</script>
<template>
<DnDProvider>
<div class="demo">
<p class="tip">Tick a few people, then drag one of them into the workshop</p>
<div class="board">
<Column v-for="column in columns" :key="column.id" :column="column" />
</div>
</div>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.tip {
margin: 0;
font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
.board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 230px));
justify-content: center;
gap: 14px;
width: 100%;
}
</style>makeSortableList(list, {
items: toRef(props.column, 'people'),
group: 'workshop',
// Some keys: those land, the others fly back to where they came from.
onBeforeDrop: async (e) => {
const { free } = await seatsLeft(props.column.people.length); // the overlay waits
return e.keys.slice(0, free);
},
onDrop: (e) => {
selection.delete(...e.accepted); // the ones sent back stay selected
notice.value = `${e.accepted.length} booked, ${e.rejected.length} back to the waiting list`;
},
});
In a multi-drag, the keys you return land together and the rest
fly back to their places. onDrop then sees them in e.accepted and e.rejected, and its
e.result is the final one. An empty array accepts nothing: the drop is refused.
Keep dragging: 'continue'
'continue' cancels the drop, not the drag. Answer Keep dragging in the dialog above:
the item stays in hand from where it was, the page goes back into drag mode, and the user
carries on.
- With a mouse or a pen, the item follows the pointer with no button held. The next release — a click — drops it, and that click never reaches the page underneath.
- A touch takes the item again where the finger lands; lifting the finger drops it.
- A keyboard drag goes on: the arrows move the item, Space or Enter drop it.
- Escape cancels it, as any drag.
- The next drop asks
onBeforeDropagain.
Monitors hear onDragResume, and screen readers hear that the item wasn't dropped and is still
moving (override the text with the provider's announcements.onDragResume).
useDnDMonitor({
onDragResume() {
hint.value = 'Still dragging: click to drop, Esc to cancel';
},
});
Asking the user
onBeforeDrop may return a promise. Open a dialog, and resolve the promise with the button the
user picks:
const { items } = makeSortableList(list, {
id: props.column.id,
items: toRef(props.column, 'tasks'),
group: 'release',
onBeforeDrop: (e) => (props.column.confirm && !e.sameList ? props.ask(e) : 'accept'),
});
type Answer = 'accept' | 'continue' | 'reject';
const question = shallowRef<{ title: string; resolve: (answer: Answer) => void } | null>(null);
/** Opens the dialog: `onBeforeDrop` waits for the button the user picks. */
const ask = (e: SortableDropEvent<Task>) =>
new Promise<Answer>((resolve) => {
question.value = { title: e.items[0]!.title, resolve };
});
While the promise is pending:
- the overlay waits where it was let go, marked
data-dnd-overlay="dropping"; useDnD()keeps the state it had at release, andstatusis'dropping';- the page is out of drag mode: the cursor and text selection are back, so a dialog works as usual;
- no other drag can start.
<dialog> opened with showModal() sits in the top layer, above the waiting overlay. Turn
its cancel event (Escape) into 'reject', as the example does.The order of the hooks
Reorder the notes, drop one in the trash, then switch an answer and do it again. The panel lists the hooks of the last drop in the order they ran.
Loading the demo…
<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Monitor from './Monitor.vue';
import Notes from './Notes.vue';
import { ANSWERS, type Answer } from './types';
const monitorAnswer = ref<Answer>('accept');
const targetAnswer = ref<Answer>('accept');
// Every hook of the last drop, in the order they ran.
const steps = ref<{ who: string; what: string }[]>([]);
const trace = (who: string, what: string) => steps.value.push({ who, what });
</script>
<template>
<DnDProvider
@drag-start="steps = []"
@drag-end="trace('provider', '@drag-end')"
@drag-cancel="trace('provider', '@drag-cancel')"
@drag-settle="trace('provider', '@drag-settle: landed')"
>
<div class="demo">
<div class="switches">
<fieldset>
<legend>monitor answers</legend>
<label v-for="value in ANSWERS" :key="value" :class="{ on: monitorAnswer === value }">
<input v-model="monitorAnswer" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
<fieldset>
<legend>target answers</legend>
<label v-for="value in ANSWERS" :key="value" :class="{ on: targetAnswer === value }">
<input v-model="targetAnswer" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
</div>
<div class="layout">
<Notes :answer="targetAnswer" :trace="trace" />
<ol class="steps" aria-label="Hooks of the last drop">
<li v-for="(step, index) in steps" :key="index">
<span class="who">{{ step.who }}</span>{{ step.what }}
</li>
<li v-if="!steps.length" class="empty">Move a note, or drop one in the trash</li>
</ol>
</div>
<Monitor :answer="monitorAnswer" :trace="trace" />
</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 16px;
}
fieldset {
display: inline-flex;
margin: 0;
padding: 3px;
border: 0;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
legend {
float: left;
align-self: center;
padding: 0 8px 0 6px;
font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
label {
padding: 5px 10px;
border-radius: 6px;
font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
cursor: pointer;
}
label.on {
background: Canvas;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
font-weight: 600;
}
label:has(input:focus-visible) {
outline: 2px solid #42b883;
}
input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.layout {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 16px;
}
.steps {
box-sizing: border-box;
min-width: 220px;
min-height: 150px;
margin: 0;
padding: 10px 14px 10px 32px;
border-radius: 10px;
background: color-mix(in srgb, currentColor 5%, transparent);
font: 11px/1.8 ui-monospace, Menlo, Consolas, monospace;
white-space: nowrap;
list-style: decimal;
}
.steps li::marker {
color: color-mix(in srgb, currentColor 45%, transparent);
}
.who {
display: inline-block;
min-width: 60px;
color: #42b883;
}
.steps li.empty {
margin-left: -18px;
list-style: none;
opacity: 0.45;
}
</style>onBeforeDropof everyuseDnDMonitor, in turn. A'reject'or a'continue'ends the asking; keys narrow the drop.onBeforeDropof the target: the list or tree the items go into, or the drop target.onDropof the target only: a list that items leave for another one isn't asked, and its new array is in the target'se.result. When items are removed (a trash zone,dropOutside: 'remove'), their list'sonDropruns, withto.indexof-1.- The default write: each list the drop changes takes its new array.
onDragEndof the provider (@drag-end) and the monitors. Then the overlay lands, andonDragSettle(@drag-settle) says it has.
A refused drop skips to onDragCancel (@drag-cancel) instead, and the items fly back. A
'continue' ends the asking too, with onDragResume: the drag goes on, and its next drop
asks again.
Monitors decide before the target, so a veto never comes after a list has already applied a drop of its own. That makes a monitor the place for rules about every drop of the app:
const { syncing } = useSync();
// Read-only while syncing: every drop under the provider flies back.
useDnDMonitor({
onBeforeDrop: () => (syncing.value ? 'reject' : 'accept'),
});
onDrop: told before the write
makeSortableList(listRef, {
items: tasks,
onDrop: (e) => {
// `tasks` still holds the old order, `e.result` the new one.
console.log(e.keys, e.from, e.to, e.result.get(e.to.list));
},
});
A list's onDrop runs once the drop is accepted, synchronously, right before the list writes it.
To act after the write, use the provider's @drag-end or useDnDMonitor({ onDragEnd }). On a
plain drop target, onDrop is where you change your data: the library moves nothing for it.
Stores and servers: controlled lists
const store = useBoardStore();
const { items } = makeSortableList(listRef, {
id: 'todo',
group: 'board',
items: () => store.lists.todo, // a getter: read-only
onDrop: (e) => {
e.preventDefault();
for (const [listId, next] of e.result) store.setList(listId, next);
},
});
The library writes a drop only into a writable ref. A getter, a computed or a readonly ref is
never written: call e.preventDefault() and apply e.result yourself — the new arrays of every
list the drop changes, by list id. preventDefault() skips the write into all of them, since
writing only some would lose or duplicate items. Development builds warn when a read-only list's
drop is left to the library.
Saving to a server first
Move a card with the server flaky (every other request fails) or down. The card waits with a badge while the request runs, then lands, or flies back if it failed.
Loading the demo…
<script setup lang="ts">
import { DnDProvider, DragPreview } from '@vue-dnd-kit/core';
import Column from './Column.vue';
import { mode, requests, saving, type ServerMode } from './server';
const MODES: ServerMode[] = ['reliable', 'flaky', 'down'];
</script>
<template>
<DnDProvider>
<div class="demo">
<fieldset class="modes">
<legend>server</legend>
<label v-for="value in MODES" :key="value" :class="{ on: mode === value }">
<input v-model="mode" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
<div class="board">
<Column id="backlog" title="Backlog" />
<Column id="sprint" title="Sprint" />
</div>
<ol class="requests" aria-label="Requests">
<li v-for="request in requests" :key="request.id" :class="`status-${request.status}`">
{{ request.text }} <span>{{ request.status === 'pending' ? '…' : request.status }}</span>
</li>
<li v-if="!requests.length" class="empty">No requests yet</li>
</ol>
</div>
<!-- The overlay of every drag: the card's copy, and a badge while the server answers. -->
<template #overlay="{ active }">
<div class="carried">
<DragPreview :id="active.id" />
<span v-if="saving" class="saving">Saving…</span>
</div>
</template>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.modes {
display: inline-flex;
margin: 0;
padding: 3px;
border: 0;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
legend {
float: left;
align-self: center;
padding: 0 8px 0 6px;
font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
.modes label {
padding: 5px 10px;
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;
}
.board {
display: grid;
grid-template-columns: repeat(2, minmax(0, 220px));
gap: 14px;
justify-content: center;
width: 100%;
}
.requests {
width: min(100%, 454px);
min-height: 60px;
margin: 0;
padding: 8px 12px;
border-radius: 10px;
background: color-mix(in srgb, currentColor 5%, transparent);
font: 11px/1.6 ui-monospace, Menlo, Consolas, monospace;
list-style: none;
white-space: pre;
overflow-x: auto;
}
.requests li + li {
opacity: 0.55;
}
.requests .status-200 span {
color: #42b883;
}
.requests .status-503 span {
color: #e5484d;
}
.requests li.empty {
color: inherit;
opacity: 0.45;
}
.carried {
position: relative;
width: 100%;
height: 100%;
}
.saving {
position: absolute;
top: 50%;
right: 10px;
display: inline-flex;
align-items: center;
gap: 6px;
translate: 0 -50%;
padding: 4px 8px;
border-radius: 99px;
background: #42b883;
color: #fff;
font: 600 11px/1 system-ui, sans-serif;
}
.saving::before {
content: '';
width: 9px;
height: 9px;
border: 2px solid rgb(255 255 255 / 0.4);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
rotate: 1turn;
}
}
</style>const { items } = makeSortableList(list, {
id: props.id,
// A getter: the library never writes it. The drop is applied below, once saved.
items: () => board[props.id]!,
group: 'sprint',
onBeforeDrop: async (e) => {
e.preventDefault();
try {
await saveMove(e.keys, e.to);
} catch {
return 'reject'; // nothing applied: the card flies back
}
for (const [id, tasks] of e.result) board[id] = tasks; // lands in its new place
return 'accept';
},
});
Apply e.result before the promise resolves, and the overlay lands in the new order. When
the request fails, apply nothing and reject: the items fly back, and monitors hear
onDragCancel.
The badge comes from the provider's #overlay slot, which renders every drag's overlay. A
DragPreview shows the usual copy of the card, and the badge sits on top while the request
runs:
<DnDProvider>
<Board />
<template #overlay="{ active }">
<div class="carried">
<DragPreview :id="active.id" />
<span v-if="saving" class="saving">Saving…</span>
</div>
</template>
</DnDProvider>
Dropping outside every target
Drag an app off the dock and let go, in each mode.
Loading the demo…
<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider, type DropOutside } from '@vue-dnd-kit/core';
import Dock from './Dock.vue';
const MODES: DropOutside[] = ['placeholder', 'cancel', 'remove'];
const dropOutside = ref<DropOutside>('remove');
</script>
<template>
<DnDProvider>
<div class="demo">
<fieldset class="modes">
<legend>dropOutside</legend>
<label v-for="value in MODES" :key="value" :class="{ on: dropOutside === value }">
<input v-model="dropOutside" type="radio" :value="value" />
{{ value }}
</label>
</fieldset>
<code class="call">makeSortableList(dock, { strategy: 'horizontal', dropOutside: '{{ dropOutside }}' })</code>
<Dock :drop-outside="dropOutside" />
</div>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.modes {
display: inline-flex;
margin: 0;
padding: 3px;
border: 0;
border-radius: 9px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
legend {
float: left;
align-self: center;
padding: 0 8px 0 6px;
font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
.modes label {
padding: 5px 10px;
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;
}
</style>const { items } = makeSortableList(dock, {
items: apps,
strategy: 'horizontal',
dropOutside: () => props.dropOutside,
// Called on the list the items leave, with `to.index` -1, when they are removed.
onDrop: (e) => {
if (e.to.index === -1) notice.value = `Removed ${e.items.map((app) => app.name).join(', ')}`;
},
});
dropOutside | A drop outside every target… |
|---|---|
'placeholder' | Lands where the placeholder is: the last place the list showed. The default. |
'cancel' | Is cancelled: the items fly back. The default with placeholder: 'indicator'. |
'remove' | Removes the items from their list. The list's onBeforeDrop is asked first, and its onDrop gets to.index of -1. |
It is read at the drop, so a ref or a getter can switch it. A trash zone does the same on
purpose: makeDroppable(trash, { onDrop: (e) => e.source?.remove() }). See
drag and drop.