Advanced Demos
Every interactive example from the documentation is collected here. Start with the focused component examples, then explore the more complete interfaces built from the same API pieces.
Drag
Return unsuccessful drags
See how the go-back prop works on the Drag component.
Release outside the target to see the drag image return to its source.
View example code
Template
<div class="dnd-demo__grid">
<div>
<span class="dnd-demo__label">Source</span>
<Drag
class="dnd-demo__item"
:data="'Example item'"
go-back
>
Drag me
</Drag>
</div>
<Drop class="dnd-demo__zone" @drop="onDrop">
<span class="dnd-demo__label">Valid target</span>
Drop here
</Drop>
</div>TypeScript
import { ref } from 'vue';
import { Drag, Drop } from 'vue-easy-dnd';
import type { DnDEventPayload } from 'vue-easy-dnd';
const status = ref('Nothing dropped yet');
const onDrop = (event: DnDEventPayload) => {
status.value = `Dropped: ${String(event.data)}`;
};Accept drag types
Use typed drag data to control which targets participate in an operation.
Each target participates only when its configured type is being dragged.
View example code
Template
<div class="dnd-demo__row">
<Drag
class="dnd-demo__item"
type="number"
:data="42"
>
Number 42
</Drag>
<Drag
class="dnd-demo__item"
type="letter"
data="A"
>
Letter A
</Drag>
</div>
<div class="dnd-demo__grid" style="margin-top: 1rem">
<Drop
class="dnd-demo__zone"
accepts-type="number"
@drop="record('Numbers', $event)"
>
<span class="dnd-demo__label">Numbers only</span>
</Drop>
<Drop
class="dnd-demo__zone"
accepts-type="letter"
@drop="record('Letters', $event)"
>
<span class="dnd-demo__label">Letters only</span>
</Drop>
</div>TypeScript
import { ref } from 'vue';
import { Drag, Drop } from 'vue-easy-dnd';
import type { DnDEventPayload } from 'vue-easy-dnd';
const lastDrop = ref('Try either item');
const record = (target: string, event: DnDEventPayload) => {
lastDrop.value = `${target} accepted ${String(event.data)}`;
};Custom drag images
Change the drag image at the source and as the pointer moves through nested targets.
Drag a profile icon and see how each drop zone can show a different drag image.
View example code
Template
<Drag
class="dnd-demo__item"
type="sample"
:data="profileName"
>
<img
:src="avatarAlex"
draggable="false"
:alt="`Profile icon for ${profileName}`"
class="demo-profile-icon"
/>
<template #drag-image>
<img
:src="avatarAlex"
draggable="false"
:alt="`Profile icon for ${profileName}`"
class="dnd-demo__ghost demo-drag-image"
/>
</template>
</Drag>
<Drop
class="dnd-demo__zone demo-nested-zone"
accepts-type="sample"
@drop="record('outer')"
>
<span class="dnd-demo__label">Outer target</span>
<template #drag-image="{ data }">
<img
:src="avatarJordan"
draggable="false"
:alt="`Outer drag target preview for ${data}`"
class="dnd-demo__ghost demo-drag-image"
/>
</template>
<Drop
class="dnd-demo__zone"
accepts-type="sample"
@drop="record('inner')"
>
<span class="dnd-demo__label">Nested target</span>
<DropMask class="demo-mask">
Masked area
</DropMask>
<template #drag-image="{ data }">
<img
:src="avatarSam"
draggable="false"
:alt="`Nested drag target preview for ${data}`"
class="dnd-demo__ghost demo-drag-image"
/>
</template>
</Drop>
</Drop>TypeScript
import { ref } from 'vue';
import { Drag, Drop, DropMask } from 'vue-easy-dnd';
import avatarAlex from './assets/avatar-alex.jpg';
import avatarJordan from './assets/avatar-jordan.jpg';
import avatarSam from './assets/avatar-sam.jpg';
const status = ref('Drag the item through both target levels');
const profileName = 'Profile';
const record = (target: string) => {
status.value = `Dropped on the ${target} target`;
};Dynamic drag images
Update reactive drag-image content during an active drag and explicitly refresh its visible clone.
Move 180px beyond any edge of the original source card to replace the compact preview with its expanded state.
View example code
Template
<div class="dnd-demo__grid dynamic-image-demo">
<div>
<span class="dnd-demo__label">Source</span>
<Drag
class="dnd-demo__card dynamic-image-demo__source"
type="dynamic-preview"
:data="itemName"
@dragstart="onDragStart"
@dragend="onDragEnd"
>
<strong>{{ itemName }}</strong>
<small>Move 180px beyond any edge to expand</small>
<template #drag-image>
<div class="dnd-demo__ghost dynamic-image-demo__preview" :class="previewMode">
<strong>{{ itemName }}</strong>
<small v-if="previewMode === 'expanded'">
Additional content rendered during this drag
</small>
</div>
</template>
</Drag>
</div>
<Drop
class="dnd-demo__zone dynamic-image-demo__target"
accepts-type="dynamic-preview"
@drop="onDrop"
>
<span class="dnd-demo__label">Target</span>
{{ result }}
</Drop>
</div>TypeScript
import { ref, watch } from 'vue';
import { Drag, Drop, refreshDragImage, useDragAware } from 'vue-easy-dnd';
import type { DnDEventPayload } from 'vue-easy-dnd';
const itemName = 'Demo item';
const { dragPosition } = useDragAware();
const expansionDistance = 180;
const previewMode = ref<'compact' | 'expanded'>('compact');
const sourceBounds = ref<Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'> | null>(null);
const ownsDrag = ref(false);
const result = ref('Drop the item here');
watch(dragPosition, position => {
const bounds = sourceBounds.value;
if (!ownsDrag.value || !position || !bounds) return;
const isOutsideSource = position.x < bounds.left - expansionDistance ||
position.x > bounds.right + expansionDistance ||
position.y < bounds.top - expansionDistance ||
position.y > bounds.bottom + expansionDistance;
const nextMode = isOutsideSource ? 'expanded' : 'compact';
if (nextMode === previewMode.value) return;
previewMode.value = nextMode;
void refreshDragImage();
});
const onDragStart = (event: DnDEventPayload) => {
ownsDrag.value = true;
const bounds = event.sourceController?.getElement().getBoundingClientRect();
sourceBounds.value = bounds
? { left: bounds.left, right: bounds.right, top: bounds.top, bottom: bounds.bottom }
: null;
};
const onDragEnd = () => {
ownsDrag.value = false;
sourceBounds.value = null;
previewMode.value = 'compact';
};
const onDrop = (event: DnDEventPayload) => {
result.value = `Received: ${String(event.data)}`;
};
const reset = () => {
sourceBounds.value = null;
previewMode.value = 'compact';
result.value = 'Drop the item here';
};Lazy external handles
Select a card and drag it from a shared toolbar rendered outside every Drag component.
Select a card, then use the shared toolbar button to drag it. The handle is resolved only when the pointer goes down.
View example code
Template
<div class="external-handle-demo__toolbar">
<button
v-if="selectedId !== null"
ref="toolbarHandle"
type="button"
>
Drag selected card
</button>
<span>{{ selectedId === null ? 'Select a card first' : `Selected card ${selectedId}` }}</span>
</div>
<div class="dnd-demo__grid external-handle-demo__layout">
<div class="external-handle-demo__cards">
<Drag
v-for="item in items"
:key="item.id"
:data="item"
:handle="handles[item.id]"
type="external-handle-card"
class="dnd-demo__card external-handle-demo__card"
:class="{ 'external-handle-demo__card--selected': selectedId === item.id }"
@click="selectedId = item.id"
>
<div>
<strong>{{ item.title }}</strong>
<small>Click to select</small>
</div>
</Drag>
</div>
<Drop
accepts-type="external-handle-card"
class="dnd-demo__zone external-handle-demo__target"
@drop="onDrop"
>
{{ result }}
</Drop>
</div>TypeScript
import { ref } from 'vue';
import { Drag, Drop } from 'vue-easy-dnd';
import type { DnDEventPayload } from 'vue-easy-dnd';
const items = [
{ id: 1, title: 'Design brief' },
{ id: 2, title: 'Research notes' },
{ id: 3, title: 'Launch checklist' }
];
const selectedId = ref<number | null>(null);
const toolbarHandle = ref<HTMLButtonElement | null>(null);
const result = ref('Drop the selected card here');
const handles: Record<number, () => Element | null> = Object.fromEntries(
items.map(item => [item.id, () => selectedId.value === item.id ? toolbarHandle.value : null])
);
const onDrop = (event: DnDEventPayload) => {
const item = event.data as { title: string };
result.value = `Dropped: ${item.title}`;
};
const reset = () => {
selectedId.value = null;
result.value = 'Drop the selected card here';
};Automatic scrolling controls
Adjust the edge activation distance and per-step scroll delta, then toggle propagation between nested scroll containers.
Adjust the edge activation distance and scroll delta, then choose whether scrolling can continue into the outer container.
Outer scroll area above the list
Outer scroll area below the list
View example code
Template
<div class="auto-scroll-demo__controls">
<label>
Edge activation distance: <strong>{{ edgeSize }}px</strong>
<input
v-model.number="edgeSize"
type="range"
min="20"
max="140"
step="10"
/>
</label>
<label>
Scroll delta: <strong>{{ scrollDelta }}px per step</strong>
<input
v-model.number="scrollDelta"
type="range"
min="2"
max="80"
step="2"
/>
</label>
<label class="auto-scroll-demo__toggle">
<input v-model="propagate" type="checkbox" />
Allow propagation to the outer container
</label>
</div>
<div class="auto-scroll-demo__outer">
<p class="auto-scroll-demo__spacer">
Outer scroll area above the list
</p>
<DropList
:items="items"
:scrolling-edge-size="edgeSize"
:scrolling-propagation="propagate"
class="dnd-demo__list auto-scroll-demo__inner"
column
no-animations
@reorder="$event.apply(items)"
>
<template #item="{ item }">
<Drag
:key="item"
:data="item"
:scrolling-edge-size="edgeSize"
:scrolling-speed="scrollDelta"
:scrolling-propagation="propagate"
class="dnd-demo__item auto-scroll-demo__item"
>
{{ item }}
</Drag>
</template>
<template #feedback>
<div key="feedback" class="dnd-demo__feedback" />
</template>
</DropList>
<p class="auto-scroll-demo__spacer">
Outer scroll area below the list
</p>
</div>TypeScript
import { ref } from 'vue';
import { Drag, DropList } from 'vue-easy-dnd';
const makeItems = () => Array.from({ length: 18 }, (_, index) => `Scrollable item ${index + 1}`);
const edgeSize = ref(50);
const scrollDelta = ref(12);
const propagate = ref(false);
const items = ref(makeItems());
const reset = () => {
edgeSize.value = 50;
scrollDelta.value = 12;
propagate.value = false;
items.value = makeItems();
};Drop
Copy and cut modes
Compare copy and cut behavior and see how the source reacts to a successful drop.
Copy leaves the source intact. Cut emits back to the source so it can be removed.
View example code
Template
<div class="dnd-demo__row">
<Drag
v-for="item in items"
:key="item"
class="dnd-demo__item"
:data="item"
@cut="remove(item)"
>
{{ item }}
</Drag>
</div>
<div class="dnd-demo__grid" style="margin-top: 1rem">
<Drop
class="dnd-demo__zone"
mode="copy"
@drop="record('Copied', $event)"
>
<span class="dnd-demo__label">Copy</span>
</Drop>
<Drop
class="dnd-demo__zone"
mode="cut"
@drop="record('Cut', $event)"
>
<span class="dnd-demo__label">Cut</span>
</Drop>
</div>TypeScript
import { ref } from 'vue';
import { Drag, Drop } from 'vue-easy-dnd';
import type { DnDEventPayload } from 'vue-easy-dnd';
const initialItems = ['One', 'Two', 'Three'];
const items = ref([...initialItems]);
const status = ref('Choose a target');
const remove = (item: string) => {
items.value = items.value.filter(value => value !== item);
};
const record = (action: string, event: DnDEventPayload) => {
status.value = `${action} ${String(event.data)}`;
};
const reset = () => {
items.value = [...initialItems];
status.value = 'Choose a target';
};Filter drag data
Use accepts-data to accept or reject individual values of the same type.
All items share a type; accepts-data decides whether each value is allowed.
View example code
Template
<div class="dnd-demo__row">
<Drag
v-for="number in numbers"
:key="number"
class="dnd-demo__item"
type="number"
:data="number"
@cut="remove(number)"
>
{{ number }}
</Drag>
</div>
<div class="dnd-demo__grid" style="margin-top: 1rem">
<Drop
class="dnd-demo__zone"
:accepts-data="isEven"
@drop="record('Even', $event)"
>
<span class="dnd-demo__label">Copy even numbers</span>
</Drop>
<Drop
class="dnd-demo__zone"
:accepts-data="isOdd"
@drop="record('Odd', $event)"
>
<span class="dnd-demo__label">Copy odd numbers</span>
</Drop>
<Drop
class="dnd-demo__zone"
mode="cut"
@drop="record('Removed', $event)"
>
<span class="dnd-demo__label">Cut any number</span>
</Drop>
</div>TypeScript
import { ref } from 'vue';
import { Drag, Drop } from 'vue-easy-dnd';
import type { DnDEventPayload, DragData } from 'vue-easy-dnd';
const numbers = ref([1, 2, 3, 4, 5]);
const status = ref('Try each target');
const isEven = (data: DragData) => typeof data === 'number' && data % 2 === 0;
const isOdd = (data: DragData) => typeof data === 'number' && data % 2 === 1;
const remove = (number: number) => {
numbers.value = numbers.value.filter(value => value !== number);
};
const record = (target: string, event: DnDEventPayload) => {
status.value = `${target}: ${String(event.data)}`;
};
const reset = () => {
numbers.value = [1, 2, 3, 4, 5];
status.value = 'Try each target';
};DropList
Reorder and transfer list items
Reorder items in place or transfer them between lists.
Items can be reordered in place or cut from one list and inserted into the other.
View example code
Template
<div class="dnd-demo__grid">
<div v-for="list in lists" :key="list.id">
<span class="dnd-demo__label">{{ list.label }}</span>
<DropList
class="dnd-demo__list"
:items="list.items"
mode="cut"
@insert="insert(list.items, $event)"
@reorder="$event.apply(list.items)"
>
<template #item="{ item }">
<Drag
:key="item"
class="dnd-demo__item"
type="list-item"
:data="item"
@cut="remove(list.items, item)"
>
{{ item }}
</Drag>
</template>
<template #feedback>
<div key="feedback" class="dnd-demo__feedback" />
</template>
<template #empty>
<small key="empty">Drop an item here</small>
</template>
</DropList>
</div>
</div>TypeScript
import { reactive } from 'vue';
import { Drag, DropList } from 'vue-easy-dnd';
import type { DemoInsertEvent } from './types';
interface DemoList {
id: string;
label: string;
items: string[];
}
const makeLists = (): DemoList[] => [
{ id: 'first', label: 'First list', items: ['One', 'Two', 'Three'] },
{ id: 'second', label: 'Second list', items: ['A', 'B', 'C'] }
];
const lists = reactive<DemoList[]>(makeLists());
const insert = (items: string[], event: DemoInsertEvent<string>) => {
items.splice(event.index, 0, event.data);
};
const remove = (items: string[], item: string) => {
const index = items.indexOf(item);
if (index >= 0) items.splice(index, 1);
};
const reset = () => {
lists.splice(0, lists.length, ...makeLists());
};Keep an item at a fixed position
Pin one item while allowing every unlocked item to move around and across it.
The policy remains at position 3 while unlocked items can move from one side of it to the other.
View example code
Template
<DropList
:items="items"
:reorderable="isReorderable"
class="dnd-demo__list position-lock-demo__list"
column
no-animations
@reorder="reorder"
>
<template #item="{ item, index }">
<Drag
:key="item.id"
:data="item"
:disabled="!isReorderable(item, index)"
class="dnd-demo__item position-lock-demo__item"
:class="{ 'position-lock-demo__item--locked': item.locked }"
>
<span>{{ item.label }}</span>
<small v-if="item.locked">Pinned at position {{ index + 1 }}</small>
</Drag>
</template>
<template #feedback>
<div key="feedback" class="dnd-demo__feedback" />
</template>
</DropList>TypeScript
import { ref } from 'vue';
import { Drag, DropList } from 'vue-easy-dnd';
import type { DemoReorderEvent } from './types';
interface PositionLockItem {
id: number;
label: string;
locked?: boolean;
}
const makeItems = (): PositionLockItem[] => [
{ id: 1, label: 'Inbox' },
{ id: 2, label: 'Design review' },
{ id: 3, label: 'Required policy', locked: true },
{ id: 4, label: 'Quality assurance' },
{ id: 5, label: 'Ready to publish' }
];
const items = ref(makeItems());
const status = ref('Move an unlocked item across the pinned policy.');
const isReorderable = (item: unknown, index: number) =>
index >= 0 && !(item as PositionLockItem).locked;
const reorder = (event: DemoReorderEvent) => {
event.apply(items.value);
status.value = `Moved position ${event.from + 1} to ${event.to + 1}; the policy is still position 3.`;
};
const reset = () => {
items.value = makeItems();
status.value = 'Move an unlocked item across the pinned policy.';
};Drop files into folders
Use the edges of a folder item to reorder it, or its inset centre to move a file into that folder.
Use a folder’s narrow edge strips to reorder, or its inset centre to move a file into that folder.
View example code
Template
<DropList
:items="entries"
accepts-type="file-entry"
class="dnd-demo__list folder-demo__list"
column
mode="cut"
no-animations
@insert="insertAtRoot"
@reorder="reorder"
>
<template #item="{ item }">
<Drag
v-if="isFile(item)"
:key="item.id"
:data="filePayload(item, null)"
class="folder-demo__file"
type="file-entry"
@cut="removeFile(null, item.id)"
>
<span aria-hidden="true">📄</span>
<span>{{ item.name }}</span>
</Drag>
<Drag
v-else
:key="item.id"
:data="item"
class="folder-demo__folder"
handle=".folder-demo__folder-handle"
type="folder-entry"
>
<small class="folder-demo__edge">Reorder above</small>
<Drop
accepts-type="file-entry"
:accepts-data="canDropInto(item)"
class="folder-demo__folder-target"
mode="cut"
@drop="moveIntoFolder(item, $event)"
>
<div class="folder-demo__folder-handle">
<span aria-hidden="true">📁</span>
<strong>{{ item.name }}</strong>
<small>Drop files in this centre area</small>
</div>
<div v-if="item.files.length" class="folder-demo__contents">
<Drag
v-for="file in item.files"
:key="file.id"
:data="filePayload(file, item.id)"
class="folder-demo__nested-file"
type="file-entry"
@cut="removeFile(item.id, file.id)"
>
<span aria-hidden="true">📄</span>
{{ file.name }}
</Drag>
</div>
<small v-else class="folder-demo__empty">Empty folder</small>
</Drop>
<small class="folder-demo__edge">Reorder below</small>
</Drag>
</template>
<template #feedback>
<div key="folder-insert-feedback" class="folder-demo__feedback">
Insert at root
</div>
</template>
<template #reordering-feedback>
<div key="folder-reorder-feedback" class="folder-demo__feedback">
Reorder here
</div>
</template>
</DropList>TypeScript
import { ref } from 'vue';
import { Drag, Drop, DropList } from 'vue-easy-dnd';
import type { DnDEventPayload } from 'vue-easy-dnd';
import type { DemoInsertEvent, DemoReorderEvent } from './types';
interface DemoFile {
id: number;
kind: 'file';
name: string;
}
interface DemoFolder {
id: number;
kind: 'folder';
name: string;
files: DemoFile[];
}
interface FilePayload {
file: DemoFile;
sourceFolderId: number | null;
}
type DemoEntry = DemoFile | DemoFolder;
const makeEntries = (): DemoEntry[] => [
{ id: 1, kind: 'file', name: 'README.md' },
{
id: 2,
kind: 'folder',
name: 'Design assets',
files: [{ id: 3, kind: 'file', name: 'logo.svg' }]
},
{ id: 4, kind: 'file', name: 'roadmap.md' },
{ id: 5, kind: 'folder', name: 'Archive', files: [] }
];
const entries = ref<DemoEntry[]>(makeEntries());
const status = ref('Try the top and bottom edges of a folder, then its centre.');
const isFile = (entry: DemoEntry): entry is DemoFile => entry.kind === 'file';
const isFilePayload = (data: unknown): data is FilePayload => {
if (!data || typeof data !== 'object' || !('file' in data) || !('sourceFolderId' in data)) return false;
const payload = data as Partial<FilePayload>;
return !!payload.file && isFile(payload.file) &&
(payload.sourceFolderId === null || typeof payload.sourceFolderId === 'number');
};
const filePayload = (file: DemoFile, sourceFolderId: number | null): FilePayload => ({
file,
sourceFolderId
});
const canDropInto = (folder: DemoFolder) => (data: unknown) =>
isFilePayload(data) && data.sourceFolderId !== folder.id;
const removeFile = (sourceFolderId: number | null, fileId: number) => {
if (sourceFolderId === null) {
const index = entries.value.findIndex(entry => entry.id === fileId);
if (index >= 0) entries.value.splice(index, 1);
return;
}
const folder = entries.value.find(entry => entry.id === sourceFolderId);
if (!folder || isFile(folder)) return;
const index = folder.files.findIndex(file => file.id === fileId);
if (index >= 0) folder.files.splice(index, 1);
};
const moveIntoFolder = (folder: DemoFolder, event: DnDEventPayload) => {
if (!isFilePayload(event.data)) return;
folder.files.push(event.data.file);
status.value = `Moved ${event.data.file.name} into ${folder.name}.`;
};
const insertAtRoot = (event: DemoInsertEvent<FilePayload>) => {
entries.value.splice(event.index, 0, event.data.file);
status.value = `Moved ${event.data.file.name} back to the root list.`;
};
const reorder = (event: DemoReorderEvent) => {
event.apply(entries.value);
status.value = `Reordered root position ${event.from + 1} to ${event.to + 1}.`;
};
const reset = () => {
entries.value = makeEntries();
status.value = 'Try the top and bottom edges of a folder, then its centre.';
};Nested drop lists
Compose row and column lists into a nested layout.
Move widgets between row and column lists; each list declares its direction.
View example code
Example template
<NestedListNode :group="tree" @operation="applyOperation" />Example TypeScript
import { ref } from 'vue';
import type { DemoGroup, DemoTreeOperation } from './types';
import { applyDemoTreeOperation } from './types';
import NestedListNode from './shared/NestedListNode.vue';
const makeTree = (): DemoGroup => ({
id: 1,
direction: 'column',
items: [
{ id: 2, label: 'Header', kind: 'text' },
{
id: 3,
direction: 'row',
items: [
{ id: 4, label: 'Metric', kind: 'metric' },
{
id: 5,
direction: 'column',
items: [
{ id: 6, label: 'Chart', kind: 'chart' },
{ id: 7, label: 'Summary', kind: 'text' }
]
}
]
}
]
});
const tree = ref<DemoGroup>(makeTree());
const applyOperation = (operation: DemoTreeOperation) => {
tree.value = applyDemoTreeOperation(tree.value, operation);
};Nested list template
<DropList
class="dnd-demo__list nested-list"
:class="{ 'dnd-demo__list--row': group.direction === 'row' }"
:items="group.items"
accepts-type="widget"
mode="cut"
:row="group.direction === 'row'"
:column="group.direction === 'column'"
@insert="insert"
@reorder="reorder"
>
<template #item="{ item }">
<NestedListNode
v-if="isDemoGroup(item)"
:key="item.id"
:group="item"
:rich="rich"
@operation="emit('operation', $event)"
/>
<Drag
v-else
:key="item.id"
class="dnd-demo__widget"
:class="rich ? `dnd-demo__widget--${item.kind}` : undefined"
:style="rich ? { '--widget-height': dashboardHeight(item.kind) } : undefined"
type="widget"
:data="item"
@cut="remove(item)"
>
<DashboardWidgetPreview v-if="rich" :widget="item" />
<template v-else>
<strong>{{ item.label }}</strong>
<small>{{ item.kind }}</small>
</template>
</Drag>
</template>
<template #feedback="{ data }">
<div
v-if="rich && isRichDemoWidget(data)"
key="rich-feedback"
:class="feedbackClass()"
:style="{ '--feedback-height': feedbackHeight(data.kind) }"
>
<span class="dnd-demo__feedback-label">{{ data.kind }} widget</span>
</div>
<div
v-else
key="feedback"
class="dnd-demo__feedback"
/>
</template>
<template #reordering-feedback="{ item }">
<div
v-if="rich && isRichDemoWidget(item)"
key="rich-reordering-feedback"
:class="feedbackClass()"
:style="{ '--feedback-height': feedbackHeight(item.kind) }"
>
<span class="dnd-demo__feedback-label">{{ item.kind }} widget</span>
</div>
<div
v-else
key="reordering-feedback-fallback"
class="dnd-demo__feedback"
/>
</template>
<template #empty>
<small key="empty">Drop a widget here</small>
</template>
</DropList>Nested list TypeScript
import { Drag, DropList } from 'vue-easy-dnd';
import type {
DemoGroup,
DemoInsertEvent,
DemoReorderEvent,
DemoTreeItem,
DemoTreeOperation,
DemoWidget
} from '../types';
import { createDemoId, isDemoGroup } from '../types';
import DashboardWidgetPreview from './DashboardWidgetPreview.vue';
const props = defineProps<{
group: DemoGroup;
rich?: boolean;
}>();
const emit = defineEmits<{
operation: [operation: DemoTreeOperation];
}>();
const insert = (event: DemoInsertEvent<DemoTreeItem>) => {
const item = isDemoGroup(event.data)
? event.data
: { ...event.data, id: createDemoId() };
emit('operation', {
kind: 'insert',
groupId: props.group.id,
index: event.index,
item
});
};
const reorder = (event: DemoReorderEvent) => {
emit('operation', {
kind: 'reorder',
groupId: props.group.id,
event
});
};
const remove = (item: DemoTreeItem) => {
emit('operation', {
kind: 'remove',
groupId: props.group.id,
itemId: item.id
});
};
const isRichDemoWidget = (value: unknown): value is DemoWidget => {
return !!value && typeof value === 'object' &&
'kind' in value &&
typeof (value as DemoWidget).kind === 'string' &&
'label' in value &&
'id' in value;
};
const feedbackClass = () => [
'dnd-demo__feedback',
'dnd-demo__feedback--dashboard'
];
const feedbackHeight = (kind: DemoWidget['kind']) => {
if (kind === 'chart') return '10rem';
if (kind === 'metric') return '5.35rem';
if (kind === 'activity') return '6.2rem';
return '5rem';
};
const dashboardHeight = (kind: DemoWidget['kind']) => {
if (kind === 'chart') return '10rem';
if (kind === 'metric') return '5.35rem';
if (kind === 'activity') return '6.2rem';
return '5rem';
};Tree types and update helper
export interface DemoInsertEvent<T> {
data: T;
index: number;
}
export interface DemoReorderEvent {
from: number;
to: number;
apply<T>(items: T[]): void;
}
export interface DemoCard {
id: number;
title: string;
detail: string;
author: string;
avatar: string;
}
export interface DemoWidget {
id: number;
label: string;
kind: 'text' | 'metric' | 'chart' | 'activity';
value?: string;
change?: string;
body?: string;
}
export interface DemoGroup {
id: number;
direction: 'row' | 'column';
items: DemoTreeItem[];
}
export type DemoTreeItem = DemoWidget | DemoGroup;
export type DemoTreeOperation =
| {
kind: 'insert';
groupId: number;
index: number;
item: DemoTreeItem;
}
| {
kind: 'remove';
groupId: number;
itemId: number;
}
| {
kind: 'reorder';
groupId: number;
event: DemoReorderEvent;
};
export const isDemoGroup = (item: DemoTreeItem): item is DemoGroup =>
'direction' in item;
export const applyDemoTreeOperation = (root: DemoGroup, operation: DemoTreeOperation): DemoGroup => {
const update = (group: DemoGroup): DemoGroup => {
if (group.id === operation.groupId) {
const items = [...group.items];
if (operation.kind === 'insert') {
items.splice(operation.index, 0, operation.item);
}
else if (operation.kind === 'remove') {
const index = items.findIndex(item => item.id === operation.itemId);
if (index < 0) return group;
items.splice(index, 1);
}
else {
operation.event.apply(items);
}
return { ...group, items };
}
let changed = false;
const items = group.items.map(item => {
if (!isDemoGroup(item)) return item;
const updated = update(item);
if (updated !== item) changed = true;
return updated;
});
return changed ? { ...group, items } : group;
};
return update(root);
};
let nextDemoId = 1000;
export const createDemoId = () => ++nextDemoId;DropMask
Mask part of a drop target
Keep part of a Drop component insensitive to drag-and-drop interactions.
Releasing over the pink mask does nothing; the surrounding target still accepts drops.
View example code
Template
<Drag
class="dnd-demo__item"
data="Masked example"
go-back
>
Drag me
</Drag>
<Drop class="dnd-demo__zone demo-mask-target" @drop="onDrop">
<span class="dnd-demo__label">Drop target</span>
<DropMask class="demo-mask">
DropMask
</DropMask>
</Drop>TypeScript
import { ref } from 'vue';
import { Drag, Drop, DropMask } from 'vue-easy-dnd';
const status = ref('Nothing dropped yet');
const onDrop = () => {
status.value = 'Dropped on the unmasked target';
};Composables
Observe drag state
Watch the reactive state exposed by useDragAware throughout a drag operation.
useDragAware exposes reactive state without coupling your component to drag internals.
- In progress
- false
- Type
- —
- Data
- —
- Position
- —
- Source component
- —
- Top component
- —
View example code
Template
<div class="dnd-demo__row">
<Drag
class="dnd-demo__item"
type="number"
:data="1"
>
One
</Drag>
<Drag
class="dnd-demo__item"
type="number"
:data="2"
>
Two
</Drag>
<Drop class="dnd-demo__zone demo-state-target">
Target
</Drop>
</div>
<dl class="demo-state">
<div><dt>In progress</dt><dd>{{ dragInProgress }}</dd></div>
<div><dt>Type</dt><dd>{{ dragType ?? '—' }}</dd></div>
<div><dt>Data</dt><dd>{{ dragData ?? '—' }}</dd></div>
<div><dt>Position</dt><dd>{{ formattedPosition }}</dd></div>
<div><dt>Source component</dt><dd>{{ dragSource ? 'available' : '—' }}</dd></div>
<div><dt>Top component</dt><dd>{{ dragTop ? 'available' : '—' }}</dd></div>
</dl>TypeScript
import { computed } from 'vue';
import { Drag, Drop, useDragAware } from 'vue-easy-dnd';
const {
dragInProgress,
dragType,
dragData,
dragPosition,
dragSource,
dragTop
} = useDragAware();
const formattedPosition = computed(() => dragPosition.value
? `${Math.round(dragPosition.value.x)}, ${Math.round(dragPosition.value.y)}`
: '—');Compatibility
Shadow DOM
Run the complete interaction inside an isolated open shadow root.
The source, target, event routing, and scroll-parent lookup all operate inside this isolated shadow root.
View example code
Template
<div
ref="host"
class="shadow-dom-demo__host"
aria-label="Shadow DOM drag-and-drop example"
/>TypeScript
import { createApp, defineComponent, h, onBeforeUnmount, onMounted, ref } from 'vue';
import { Drag, Drop } from 'vue-easy-dnd';
import type { DnDEventPayload } from 'vue-easy-dnd';
const shadowStyles = `
:host { display: block; height: 20rem; overflow: auto; border: 2px solid #7c3aed; border-radius: 10px; }
* { box-sizing: border-box; }
.content { min-height: 38rem; padding: 1rem; color: #1f2937; background: #faf5ff; font: 15px/1.5 system-ui, sans-serif; }
.source { display: inline-block; padding: .7rem 1rem; border-radius: 8px; background: #7c3aed; color: white; cursor: grab; user-select: none; }
.target { display: grid; min-height: 7rem; margin-top: 22rem; place-content: center; border: 2px dashed #7c3aed; border-radius: 9px; text-align: center; }
.target.drop-in { background: #ede9fe; }
`;
const ShadowContent = defineComponent({
name: 'ShadowDndContent',
setup () {
const result = ref('Drag the purple card, scroll down, and drop it here.');
const onDrop = (event: DnDEventPayload) => {
result.value = `Dropped: ${String(event.data)}`;
};
return () => h('div', { class: 'content' }, [
h('style', shadowStyles),
h(Drag, { type: 'shadow-card', data: 'Shadow DOM card' }, {
default: () => h('div', { class: 'source' }, 'Drag inside the shadow root')
}),
h(Drop, { acceptsType: 'shadow-card', class: 'target', onDrop }, {
default: () => result.value
})
]);
}
});
const host = ref<HTMLElement | null>(null);
let shadowApp: ReturnType<typeof createApp> | null = null;
onMounted(() => {
if (!host.value) return;
const shadowRoot = host.value.attachShadow({ mode: 'open' });
const mountPoint = document.createElement('div');
shadowRoot.appendChild(mountPoint);
shadowApp = createApp(ShadowContent);
shadowApp.mount(mountPoint);
});
onBeforeUnmount(() => {
shadowApp?.unmount();
shadowApp = null;
});Complete interface examples
Transfer rich cards
This example combines list reordering, transfers between lists, custom drag images, and custom insertion feedback.
The list state changes, while custom slots keep drag images and insertion feedback compact.
View example code
Template
<div class="dnd-demo__grid">
<div v-for="list in lists" :key="list.id">
<span class="dnd-demo__label">{{ list.label }}</span>
<DropList
class="dnd-demo__list"
:items="list.cards"
mode="cut"
@insert="insert(list.cards, $event)"
@reorder="$event.apply(list.cards)"
>
<template #item="{ item }">
<Drag
:key="item.id"
class="dnd-demo__card"
type="card"
:data="item"
@cut="remove(list.cards, item)"
>
<img
class="demo-card-avatar"
:src="item.avatar"
:alt="`${item.author} avatar`"
width="40"
height="40"
/>
<span class="demo-card-copy">
<strong>{{ item.title }}</strong>
<small>{{ item.author }} · {{ item.detail }}</small>
</span>
<template #drag-image>
<div class="dnd-demo__ghost demo-card-ghost">
<img
:src="item.avatar"
alt=""
width="28"
height="28"
/>
{{ item.title }}
</div>
</template>
</Drag>
</template>
<template #feedback>
<div key="feedback" class="dnd-demo__feedback" />
</template>
<template #empty>
<small key="empty">No cards</small>
</template>
</DropList>
</div>
</div>TypeScript
import { reactive } from 'vue';
import { Drag, DropList } from 'vue-easy-dnd';
import avatarAlex from './assets/avatar-alex.jpg';
import avatarJordan from './assets/avatar-jordan.jpg';
import avatarSam from './assets/avatar-sam.jpg';
import type { DemoCard, DemoInsertEvent } from './types';
interface CardList {
id: string;
label: string;
cards: DemoCard[];
}
const makeLists = (): CardList[] => [
{
id: 'inbox',
label: 'Inbox',
cards: [
{
id: 1,
title: 'Brunch this weekend?',
detail: '10 min ago',
author: 'Alex Chen',
avatar: avatarAlex
},
{
id: 2,
title: 'Summer launch notes',
detail: '35 min ago',
author: 'Sam Rivera',
avatar: avatarSam
}
]
},
{
id: 'done',
label: 'Saved',
cards: [{
id: 3,
title: 'Ideas for the docs',
detail: 'Yesterday',
author: 'Jordan Lee',
avatar: avatarJordan
}]
}
];
const lists = reactive<CardList[]>(makeLists());
const insert = (cards: DemoCard[], event: DemoInsertEvent<DemoCard>) => {
cards.splice(event.index, 0, event.data);
};
const remove = (cards: DemoCard[], card: DemoCard) => {
const index = cards.findIndex(value => value.id === card.id);
if (index >= 0) cards.splice(index, 1);
};
const reset = () => lists.splice(0, lists.length, ...makeLists());Copy table rows into a list
This example combines a table source, a list target, copy mode, custom drag images, and insertion feedback.
Table rows stay in place because the destination uses copy mode.
| Name | Price |
|---|---|
| Notebook | $12 |
| Pencil case | $18 |
| Desk lamp | $45 |
View example code
Template
<div class="dnd-demo__grid">
<div class="demo-table-wrap">
<span class="dnd-demo__label">Products</span>
<table class="demo-table">
<thead><tr><th>Name</th><th>Price</th></tr></thead>
<tbody>
<Drag
v-for="product in products"
:key="product.id"
tag="tr"
type="product"
:data="product"
>
<td>{{ product.name }}</td>
<td>${{ product.price }}</td>
<template #drag-image>
<div class="dnd-demo__ghost demo-product-drag-image">
<img :src="product.image" :alt="`${product.name} preview`" />
<span>{{ product.name }} · ${{ product.price }}</span>
</div>
</template>
</Drag>
</tbody>
</table>
</div>
<div>
<span class="dnd-demo__label">Selection</span>
<DropList
class="dnd-demo__list"
:items="selected"
accepts-type="product"
mode="copy"
@insert="insert"
@reorder="$event.apply(selected)"
>
<template #item="{ item }">
<div :key="item.id" class="dnd-demo__card">
{{ item.name }} · ${{ item.price }}
</div>
</template>
<template #feedback>
<div key="feedback" class="dnd-demo__feedback" />
</template>
<template #empty>
<small key="empty">Drop a table row here</small>
</template>
</DropList>
</div>
</div>TypeScript
import { ref } from 'vue';
import { Drag, DropList } from 'vue-easy-dnd';
import type { DemoInsertEvent } from './types';
import avatarAlex from './assets/avatar-alex.jpg';
import avatarJordan from './assets/avatar-jordan.jpg';
import avatarSam from './assets/avatar-sam.jpg';
interface Product {
id: number;
name: string;
price: number;
image: string;
}
const products: Product[] = [
{ id: 1, name: 'Notebook', price: 12, image: avatarAlex },
{ id: 2, name: 'Pencil case', price: 18, image: avatarJordan },
{ id: 3, name: 'Desk lamp', price: 45, image: avatarSam }
];
const selected = ref<Product[]>([]);
const insert = (event: DemoInsertEvent<Product>) => {
if (selected.value.some(item => item.id === event.data.id)) return;
selected.value.splice(event.index, 0, event.data);
};Build a dashboard
This example uses nested drop lists to build a dashboard visually. Drag new widgets from the palette or move existing widgets between regions.
Copy palette widgets into nested rows and columns, then move them between regions.
View example code
Dashboard template
<div class="demo-dashboard-builder">
<aside class="demo-dashboard-palette">
<span class="dnd-demo__label">Widget library</span>
<p>Drag a widget onto any dotted region.</p>
<div class="demo-dashboard-palette__items">
<Drag
v-for="widget in palette"
:key="widget.id"
class="demo-dashboard-palette__item"
type="widget"
:data="widget"
>
<span class="demo-dashboard-palette__icon" aria-hidden="true">
{{ widgetIcon[widget.kind] }}
</span>
<span>
<strong>{{ widget.label }}</strong>
<small>{{ widgetDescription[widget.kind] }}</small>
</span>
<template #drag-image>
<div class="dnd-demo__ghost">
<img :src="widgetImage[widget.kind]" :alt="`${widget.label} preview`" />
<span>{{ widget.label }}</span>
</div>
</template>
</Drag>
</div>
</aside>
<section class="demo-dashboard-canvas">
<header>
<span>
<small>Overview</small>
<strong>Product performance</strong>
</span>
<span class="demo-dashboard-live">Live</span>
</header>
<NestedListNode
:group="dashboard"
rich
@operation="applyOperation"
/>
</section>
</div>Dashboard TypeScript
import { ref } from 'vue';
import { Drag } from 'vue-easy-dnd';
import type { DemoGroup, DemoTreeOperation, DemoWidget } from './types';
import { applyDemoTreeOperation } from './types';
import NestedListNode from './shared/NestedListNode.vue';
import avatarAlex from './assets/avatar-alex.jpg';
import avatarJordan from './assets/avatar-jordan.jpg';
import avatarSam from './assets/avatar-sam.jpg';
import revenueTrend from './assets/revenue-trend.jpg';
const palette: DemoWidget[] = [
{
id: 101,
label: 'Note',
kind: 'text',
body: 'Add context for your team.'
},
{
id: 102,
label: 'KPI',
kind: 'metric',
value: '8,492',
change: '+12.4%'
},
{ id: 103, label: 'Trend chart', kind: 'chart' },
{ id: 104, label: 'Activity', kind: 'activity' }
];
const widgetIcon: Record<DemoWidget['kind'], string> = {
text: 'Aa',
metric: '↗',
chart: '⌁',
activity: '•••'
};
const widgetDescription: Record<DemoWidget['kind'], string> = {
text: 'Supporting copy',
metric: 'Headline value',
chart: 'Image visualization',
activity: 'Recent events'
};
const widgetImage: Record<DemoWidget['kind'], string> = {
text: avatarAlex,
metric: avatarJordan,
chart: revenueTrend,
activity: avatarSam
};
const makeDashboard = (): DemoGroup => ({
id: 1,
direction: 'column',
items: [
{
id: 2,
direction: 'row',
items: [
{
id: 3,
label: 'Monthly revenue',
kind: 'metric',
value: '$84.2k',
change: '+18.2%'
},
{
id: 4,
label: 'Active accounts',
kind: 'metric',
value: '2,849',
change: '+6.7%'
},
{
id: 5,
label: 'Conversion',
kind: 'metric',
value: '7.4%',
change: '+1.1%'
}
]
},
{
id: 6,
direction: 'row',
items: [
{ id: 7, label: 'Revenue trend', kind: 'chart' },
{ id: 8, label: 'Recent activity', kind: 'activity' }
]
},
{
id: 9,
label: 'Quarterly note',
kind: 'text',
body: 'Growth is strongest in self-serve teams. Focus the next release on faster onboarding and clearer activation milestones.'
}
]
});
const dashboard = ref<DemoGroup>(makeDashboard());
const applyOperation = (operation: DemoTreeOperation) => {
dashboard.value = applyDemoTreeOperation(dashboard.value, operation);
};Nested list template
<DropList
class="dnd-demo__list nested-list"
:class="{ 'dnd-demo__list--row': group.direction === 'row' }"
:items="group.items"
accepts-type="widget"
mode="cut"
:row="group.direction === 'row'"
:column="group.direction === 'column'"
@insert="insert"
@reorder="reorder"
>
<template #item="{ item }">
<NestedListNode
v-if="isDemoGroup(item)"
:key="item.id"
:group="item"
:rich="rich"
@operation="emit('operation', $event)"
/>
<Drag
v-else
:key="item.id"
class="dnd-demo__widget"
:class="rich ? `dnd-demo__widget--${item.kind}` : undefined"
:style="rich ? { '--widget-height': dashboardHeight(item.kind) } : undefined"
type="widget"
:data="item"
@cut="remove(item)"
>
<DashboardWidgetPreview v-if="rich" :widget="item" />
<template v-else>
<strong>{{ item.label }}</strong>
<small>{{ item.kind }}</small>
</template>
</Drag>
</template>
<template #feedback="{ data }">
<div
v-if="rich && isRichDemoWidget(data)"
key="rich-feedback"
:class="feedbackClass()"
:style="{ '--feedback-height': feedbackHeight(data.kind) }"
>
<span class="dnd-demo__feedback-label">{{ data.kind }} widget</span>
</div>
<div
v-else
key="feedback"
class="dnd-demo__feedback"
/>
</template>
<template #reordering-feedback="{ item }">
<div
v-if="rich && isRichDemoWidget(item)"
key="rich-reordering-feedback"
:class="feedbackClass()"
:style="{ '--feedback-height': feedbackHeight(item.kind) }"
>
<span class="dnd-demo__feedback-label">{{ item.kind }} widget</span>
</div>
<div
v-else
key="reordering-feedback-fallback"
class="dnd-demo__feedback"
/>
</template>
<template #empty>
<small key="empty">Drop a widget here</small>
</template>
</DropList>Nested list TypeScript
import { Drag, DropList } from 'vue-easy-dnd';
import type {
DemoGroup,
DemoInsertEvent,
DemoReorderEvent,
DemoTreeItem,
DemoTreeOperation,
DemoWidget
} from '../types';
import { createDemoId, isDemoGroup } from '../types';
import DashboardWidgetPreview from './DashboardWidgetPreview.vue';
const props = defineProps<{
group: DemoGroup;
rich?: boolean;
}>();
const emit = defineEmits<{
operation: [operation: DemoTreeOperation];
}>();
const insert = (event: DemoInsertEvent<DemoTreeItem>) => {
const item = isDemoGroup(event.data)
? event.data
: { ...event.data, id: createDemoId() };
emit('operation', {
kind: 'insert',
groupId: props.group.id,
index: event.index,
item
});
};
const reorder = (event: DemoReorderEvent) => {
emit('operation', {
kind: 'reorder',
groupId: props.group.id,
event
});
};
const remove = (item: DemoTreeItem) => {
emit('operation', {
kind: 'remove',
groupId: props.group.id,
itemId: item.id
});
};
const isRichDemoWidget = (value: unknown): value is DemoWidget => {
return !!value && typeof value === 'object' &&
'kind' in value &&
typeof (value as DemoWidget).kind === 'string' &&
'label' in value &&
'id' in value;
};
const feedbackClass = () => [
'dnd-demo__feedback',
'dnd-demo__feedback--dashboard'
];
const feedbackHeight = (kind: DemoWidget['kind']) => {
if (kind === 'chart') return '10rem';
if (kind === 'metric') return '5.35rem';
if (kind === 'activity') return '6.2rem';
return '5rem';
};
const dashboardHeight = (kind: DemoWidget['kind']) => {
if (kind === 'chart') return '10rem';
if (kind === 'metric') return '5.35rem';
if (kind === 'activity') return '6.2rem';
return '5rem';
};Widget preview template
<article class="dashboard-widget" :class="`dashboard-widget--${widget.kind}`">
<template v-if="widget.kind === 'metric'">
<small>{{ widget.label }}</small>
<strong>{{ widget.value ?? '8,492' }}</strong>
<span>{{ widget.change ?? '+12.4%' }}</span>
</template>
<template v-else-if="widget.kind === 'chart'">
<header>
<span>
<small>{{ widget.label }}</small>
<strong>$84,240</strong>
</span>
<span>Last 12 months</span>
</header>
<img
:src="revenueTrend"
alt="Abstract upward revenue trend"
width="720"
height="300"
/>
</template>
<template v-else-if="widget.kind === 'activity'">
<strong>{{ widget.label }}</strong>
<ul>
<li><i class="activity-dot activity-dot--violet" />New workspace created</li>
<li><i class="activity-dot activity-dot--cyan" />Quarterly report shared</li>
<li><i class="activity-dot activity-dot--mint" />12 members invited</li>
</ul>
</template>
<template v-else>
<small>{{ widget.label }}</small>
<strong>What we learned</strong>
<p>{{ widget.body ?? 'Add context for your team.' }}</p>
</template>
</article>Widget preview TypeScript
import revenueTrend from '../assets/revenue-trend.jpg';
import type { DemoWidget } from '../types';
defineProps<{
widget: DemoWidget;
}>();Tree types and update helper
export interface DemoInsertEvent<T> {
data: T;
index: number;
}
export interface DemoReorderEvent {
from: number;
to: number;
apply<T>(items: T[]): void;
}
export interface DemoCard {
id: number;
title: string;
detail: string;
author: string;
avatar: string;
}
export interface DemoWidget {
id: number;
label: string;
kind: 'text' | 'metric' | 'chart' | 'activity';
value?: string;
change?: string;
body?: string;
}
export interface DemoGroup {
id: number;
direction: 'row' | 'column';
items: DemoTreeItem[];
}
export type DemoTreeItem = DemoWidget | DemoGroup;
export type DemoTreeOperation =
| {
kind: 'insert';
groupId: number;
index: number;
item: DemoTreeItem;
}
| {
kind: 'remove';
groupId: number;
itemId: number;
}
| {
kind: 'reorder';
groupId: number;
event: DemoReorderEvent;
};
export const isDemoGroup = (item: DemoTreeItem): item is DemoGroup =>
'direction' in item;
export const applyDemoTreeOperation = (root: DemoGroup, operation: DemoTreeOperation): DemoGroup => {
const update = (group: DemoGroup): DemoGroup => {
if (group.id === operation.groupId) {
const items = [...group.items];
if (operation.kind === 'insert') {
items.splice(operation.index, 0, operation.item);
}
else if (operation.kind === 'remove') {
const index = items.findIndex(item => item.id === operation.itemId);
if (index < 0) return group;
items.splice(index, 1);
}
else {
operation.event.apply(items);
}
return { ...group, items };
}
let changed = false;
const items = group.items.map(item => {
if (!isDemoGroup(item)) return item;
const updated = update(item);
if (updated !== item) changed = true;
return updated;
});
return changed ? { ...group, items } : group;
};
return update(root);
};
let nextDemoId = 1000;
export const createDemoId = () => ++nextDemoId;