Docs/Guides
Drag and drop
Free dragging with makeDraggable and drop targets with makeDroppable — typed targets that take some drags and refuse others, a floor plan, trash zones, folders inside a list, and plain items a list takes in.
Use makeDraggable and makeDroppable for anything that isn't a sortable list: waste
into bins, furniture on a floor plan, files into folders, a trash can, a chess board.
Loading the demo…
<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider, type Key } from '@vue-dnd-kit/core';
import Bin from './Bin.vue';
import { BINS, WASTE } from './waste';
// The tray takes everything back (no `accept`); the bins take what their rule allows.
const TRAY = { id: 'tray', name: 'To sort', color: '#42b883', accept: '*', rule: "'*'" };
/** Where each item is: the tray or a bin. */
const place = reactive<Record<string, string>>(Object.fromEntries(WASTE.map((waste) => [waste.id, 'tray'])));
const itemsIn = (bin: string) => WASTE.filter((waste) => place[waste.id] === bin);
const drop = (bin: string, id: Key) => (place[String(id)] = bin);
</script>
<template>
<DnDProvider>
<div class="recycling">
<Bin :bin="TRAY" :items="itemsIn('tray')" tray @drop="drop('tray', $event)" />
<div class="bins">
<Bin v-for="bin in BINS" :key="bin.id" :bin="bin" :items="itemsIn(bin.id)" @drop="drop(bin.id, $event)" />
</div>
</div>
</DnDProvider>
</template>
<style scoped>
.recycling {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 600px;
margin: 0 auto;
}
.bins {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
@media (max-width: 560px) {
.bins {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>A draggable
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeDraggable } from '@vue-dnd-kit/core';
const props = defineProps<{ waste: { id: string; name: string; type: string } }>();
const el = useTemplateRef<HTMLElement>('el');
const { isDragging } = makeDraggable(el, {
id: () => props.waste.id, // read back as `active.id`
type: () => props.waste.type, // matched by the targets' `accept`
data: () => props.waste, // anything: `active.data`
});
</script>
<template>
<div ref="el" :class="{ dragging: isDragging }">{{ waste.name }}</div>
</template>
Pass the id from your data, as a getter. The default id comes from useId() and is new
for every component instance, so it means nothing to the rest of your app. Options are read
when they are needed, so a getter or a ref keeps them current.
| Option | What it does |
|---|---|
id, data, type | Who is dragged, what it carries, and what the targets' accept matches. |
handle | A selector or an element inside: only it starts a drag. See handles. |
disabled | No drag starts. |
overlay | true (default): a copy follows the pointer. A component: your own preview. false: the element moves itself (below). |
axis, modifiers | Lock the drag to 'x' or 'y', snap it, keep it in bounds: see constraints. |
keyboardMode | How the arrows move it: by pixels (default) or from target to target (below). |
activation, canDrag | When a press becomes a drag: see sensors. |
attributes | role, tabindex and aria-* for keyboards and screen readers (default true). |
| Returns | What it is |
|---|---|
isDragging | This element is being dragged. |
selected | Writable, bound to the provider's selection. |
transform | The drag offset, for overlay: false. |
id | The id in use. |
Buttons, inputs and other interactive children don't start a pointer drag, so they keep working inside a draggable. All options are in the makeDraggable API.
A drop target
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeDroppable, type Key } from '@vue-dnd-kit/core';
import type { Bin, Waste } from './waste';
const props = defineProps<{ bin: Bin; items: Waste[] }>();
const emit = defineEmits<{ drop: [id: Key] }>();
const el = useTemplateRef<HTMLElement>('el');
const { isOver, isAccepting } = makeDroppable(el, {
id: props.bin.id,
accept: props.bin.accept, // 'glass', ['paper', 'cardboard'], a function…
onDrop: (event) => emit('drop', event.active.id),
});
</script>
<template>
<section ref="el" :class="{ accepting: isAccepting, over: isOver && isAccepting, refused: isOver && !isAccepting }">
<WasteItem v-for="waste in items" :key="waste.id" :waste="waste" />
</section>
</template>
| Returns | true when |
|---|---|
isOver | The drag is over this target, whether it takes the drag or not. |
isAccepting | Something is being dragged and this target takes it: light up the valid targets. |
placement | Not a boolean: { top, right, bottom, left }, the halves of the target the pointer is in, or null when it isn't hovered. Use it for "before / after this card". |
isOver without isAccepting means the drag is over a target that refuses it. Show it,
as the bins above do: a drop there is cancelled and the item flies back. The bins that
can't take the item fade while it travels; more looks in styling.
| Option | What it does |
|---|---|
id, data | Read back as over.id and over.data. |
accept, acceptMode | Which drags it takes (next section). |
disabled | It takes nothing, and drags pass over it. |
priority | Higher wins when targets overlap without nesting (default 0). |
onDragEnter, onDragLeave | The drag came in or went out. |
onBeforeDrop, onDrop | Decide on the drop, then apply it (below). |
Which drags a target takes
A draggable's type is matched against a target's accept:
accept | Takes |
|---|---|
omitted or '*' | Everything. |
'glass' | Draggables of type glass, and untyped ones. |
['paper', 'cardboard'] | Either type, and untyped ones. |
({ active, items, types }) => boolean | Whatever your function says. |
A draggable without a type fits any string rule. type can be an array too
(['paper', 'large']): one matching type is enough. A function decides on anything about
the drag. The compost bin above reads the items' data, not their type:
{
id: 'compost',
accept: ({ items }) => items.every((item) => (item.data as Waste).compostable),
}
accept is read live, while the drag looks for its target: pass a ref to change it.
When several items are dragged at once, acceptMode says how many must fit: 'all'
(default) or 'some'. With 'some', return the ids to keep from onBeforeDrop, and the
others fly back. See drop decisions.
The drop
onDrop of a plain drop target is where you change your data. The library moves
nothing on its own:
<script setup lang="ts">
/** Where each item is: the tray or a bin. */
const place = reactive<Record<string, string>>(Object.fromEntries(WASTE.map((waste) => [waste.id, 'tray'])));
const itemsIn = (bin: string) => WASTE.filter((waste) => place[waste.id] === bin);
const drop = (bin: string, id: Key) => (place[String(id)] = bin);
</script>
<template>
<Bin v-for="bin in BINS" :key="bin.id" :bin="bin" :items="itemsIn(bin.id)" @drop="drop(bin.id, $event)" />
</template>
| Event field | What it holds |
|---|---|
active | The drag: id, ids, items ({ id, data, type, item } each), data, type. |
over | This target: id, data, element, rect, accepted. |
pointer, delta | Where the pointer is, and how far it moved from the start. |
position | Where the dragged box sits in this target (next section). |
source | Set when the items come from a sortable list: { list, remove() }. |
active.ids holds every dragged id, the grabbed one first. The order is onBeforeDrop
(decide), onDrop (apply), then the provider's drag-end. To hear every drag of the
page in one place, use useDnDMonitor (see the provider). To
ask before a drop, keep the drag going, or accept only some items, see
drop decisions.
Leave it where it was dropped
A floor plan, a canvas, a board: the item stays where you let go. Every event carries
position, the left / top that put an absolutely positioned item where it was
dropped.
Loading the demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Planner from './Planner.vue';
</script>
<template>
<DnDProvider>
<Planner />
</DnDProvider>
</template>const room = useTemplateRef<HTMLElement>('room');
makeDroppable(room, {
id: 'room',
onDrop: ({ active, position }) => {
const piece = find(active.id);
if (!piece || !position || !room.value) return;
// Clamped, so a piece let go half outside stays inside the walls.
piece.x = clamp(position.x, room.value.clientWidth - piece.width);
piece.y = clamp(position.y, room.value.clientHeight - piece.height);
piece.place = 'room';
},
});
<div ref="el" class="piece" :style="{ left: `${piece.x}px`, top: `${piece.y}px` }">…</div>
position is the top-left corner of the dragged box in over, measured from its padding
edge, with its scroll added: the room's border, its walls, doesn't count. It is null
over nothing.
active.rect and delta: that misses the target's border
and scroll. Grids, bounds and axis locks for such boards are in
constraints.Without an overlay
By default a copy of the element follows the pointer, and the element stays in place: the
furniture in storage works that way. With overlay: false there is no copy, and the
element moves itself by the transform the drag returns: the furniture in the room.
const inRoom = computed(() => props.piece.place === 'room');
const { isDragging, transform } = makeDraggable(el, {
id: () => props.piece.id,
overlay: computed(() => !inRoom.value), // a value or a ref, read when the drag starts
});
const style = computed(() => ({
left: `${props.piece.x}px`,
top: `${props.piece.y}px`,
...(transform.value && { transform: `translate3d(${transform.value.x}px, ${transform.value.y}px, 0)` }),
}));
positionis then the element's own corner, so the drop handler stays the same.- The element is under the pointer while it moves; finding the target, the library looks
through it. Raise it above its neighbours while it is dragged (
z-index). - It scrolls with the page, the overlay doesn't:
transformtakes off what its container moved since the drag started, so it stays under the pointer when the page scrolls. - It moves inside its own container: an
overflow: hiddenparent clips it, and its stacking context decides what it passes over. The overlay has neither problem.
For a custom preview instead of the copy, see the overlay.
Trash zones
A drag that starts in a sortable list carries e.source. Call remove() and the list
drops the items:
Loading the demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Triage from './Triage.vue';
</script>
<template>
<DnDProvider>
<Triage />
</DnDProvider>
</template>const triage = (bin: 'done' | 'deleted') => (e: DnDEvent) => {
bins[bin].push(...e.active.items.map((entry) => entry.item as Notice));
e.source?.remove();
};
makeDroppable(doneZone, { id: 'done', onDrop: triage('done') });
makeDroppable(trashZone, { id: 'trash', onDrop: triage('deleted') });
e.active.items[i].itemis the list's item itself. Keep it, and an undo puts it back where it was.- The list's own
onDropthen runs withto.index === -1, ande.resultholds the list without the items. - The overlay shrinks into the zone, as the item is no longer shown anywhere.
- A list can also remove what is dropped outside of everything: see
dropOutside.
Delete key is still quicker for many people: offer one too.Drop into a sortable item
A row of a sortable list can be a drop target too: a folder that takes bookmarks, a group that takes tasks. Put a droppable on the item's element:
Loading the demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Bookmarks from './Bookmarks.vue';
</script>
<template>
<DnDProvider>
<Bookmarks />
</DnDProvider>
</template>const el = useTemplateRef<HTMLElement>('el');
makeSortableItem(el, { item: () => props.folder, type: 'folder' });
const { isOver } = makeDroppable(el, {
accept: 'link',
onDrop: (e) => {
emit('add', e.active.items.map((entry) => entry.item as Link)); // Bookmarks adds them
e.source?.remove(); // take them out of the list they came from
},
});
An open folder shows its links as a list of the same group, which takes links only: they sort there, and drag back out to the top level or into another folder.
While the list would sort the drag, such a target counts only in the middle of its item (25 to 75% along the list's axis). There the drop goes into the item, and the list doesn't move. Near the edges, the list sorts as usual.
- A target whose
acceptrefuses the drag is skipped: the list sorts over the item as over any other. The folders take only links, so a dragged folder sorts past the others. - A target never receives its own drag.
- With the keyboard, an arrow stops on the folder first; the next press in the same direction goes on past it.
- The item is no longer shown after the drop, so the overlay shrinks into the folder instead of flying back.
- Over the folder the list keeps the dragged row's slot where it was, so nothing shifts
under the pointer. The slot gets
data-dnd-placeholder="into"then: the drop doesn't go there, so hide it if you like ([data-dnd-placeholder='into'] { opacity: 0 }).
The file manager does the same in a grid, with nested folders.
Plain draggables into a list
A sortable list takes in items dragged with makeDraggable when it knows how to make an
item from them:
Loading the demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Agenda from './Agenda.vue';
import BlockChip from './BlockChip.vue';
const BLOCKS = [
{ title: 'Focus', minutes: 90, color: '#42b883' },
{ title: 'Meeting', minutes: 30, color: '#3b82f6' },
{ title: 'Lunch', minutes: 60, color: '#f59e0b' },
{ title: 'Gym', minutes: 45, color: '#ec4899' },
{ title: 'Break', minutes: 15, color: '#a1a1aa' },
];
</script>
<template>
<DnDProvider>
<div class="planner">
<aside class="blocks">
<h4>Blocks</h4>
<BlockChip v-for="block in BLOCKS" :key="block.title" :block="block" />
</aside>
<Agenda />
</div>
</DnDProvider>
</template>
<style scoped>
.planner {
display: flex;
justify-content: center;
gap: 24px;
}
.blocks {
display: flex;
flex: none;
flex-direction: column;
gap: 6px;
width: 140px;
}
h4 {
margin: 0 0 6px;
font: 600 11px/1 ui-monospace, Menlo, Consolas, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.55;
}
@media (max-width: 480px) {
.blocks {
width: 112px;
}
}
</style>const { items } = makeSortableList(list, {
items: agenda,
createItem: (active) => ({ ...(active.data as Block), id: `entry-${++created}` }),
});
While the draggable is over the list, the created item sits in the draft and is rendered
by your template like any other row: the agenda's times already count it. It becomes real
on drop (mode: 'insert', from.list === null), and the overlay lands on it.
- Return
undefinedto refuse. Lists withoutcreateItemignore plain draggables. - Give the new item a key of its own: the block stays in the palette.
- With the keyboard, an arrow toward the list takes the draggable in, level with it, and the next arrows move it by slots.
Files from the computer
Files dragged in from the desktop or a file manager aren't the library's drags: the browser
carries them. A drop target takes them with external, and the drop brings them in
e.external:
Loading the demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import AvatarDrop from './AvatarDrop.vue';
import Attachments from './Attachments.vue';
</script>
<template>
<DnDProvider>
<div class="form">
<AvatarDrop />
<Attachments />
</div>
</DnDProvider>
</template>
<style scoped>
.form {
display: flex;
flex-wrap: wrap;
align-items: stretch;
justify-content: center;
gap: 16px;
}
</style>const { isOver, isAccepting } = makeDroppable(el, {
external: { files: 'image/*', multiple: false },
onDrop: (e) => show(e.external!.files[0]!),
});
filesreads like<input accept>: MIME types (image/*,application/pdf) and extensions (.md).truetakes any file, a function decides file by file.linksandtexttake what other apps and tabs drag,typesany other MIME type.- The drag runs as a drag of the provider:
isOver,isAccepting, the events and the announcements work as for any other. Targets withoutexternalignore it. There is no overlay: the browser draws the dragged image. - While the files are held over the page, the browser tells their types only
(
e.external.fileTypes). Their names, sizes and contents come with the drop, and extensions are checked then. Folders are left out; withmultiple: false, a drag of several files is refused.
Lists take files, links and text too, and make room for them while they are held. Items can also leave the page, into another app or another window of yours: see files and other apps.
<input type="file"> next to
the zone, as the demo does with Browse.Keyboard
A focused draggable picks up with Space or Enter, drops with the same keys, and Escape cancels. Between, the arrows move it:
keyboardMode | An arrow |
|---|---|
'pixels' (default) | Moves it by 25 px, the provider's keyboard.step. With Shift, four times as far. The furniture moves that way. |
'droppables' | Jumps to the closest target in that direction that takes it. The waste jumps from bin to bin, and skips the bins that refuse it. |
makeDraggable(el, { id: () => props.waste.id, type: () => props.waste.type, keyboardMode: 'droppables' });
The keys can be rebound on the provider. More in accessibility.