DropList
DropList is a specialized Drop that renders an array, calculates insertion positions, and supports in-list reordering.
Events
DropList emits the Drop lifecycle events plus these list events:
| Event | Payload | Description |
|---|---|---|
insert | InsertEvent | External data was dropped at a calculated index. The payload also contains type and data. |
reorder | ReorderEvent | An item from this list moved from from to to. apply(array) performs the move and respects the locked indices. |
Event handlers own all application-state changes:
<DropList
:items="items"
@insert="items.splice($event.index, 0, $event.data)"
@reorder="$event.apply(items)"
>
<!-- slots -->
</DropList>An external permitted drop still completes if there is no insert listener, but no item is added automatically. Likewise, a reorder is not persisted unless the handler updates the array.
For an external insertion, drop is emitted before insert. An internal reorder emits reorder, not drop or insert.
Props
DropList accepts all Drop props, plus:
| Prop | Type / Default | Description |
|---|---|---|
tag | String or component ('div') | Root tag. With animations enabled, use an HTML tag supported by Vue's TransitionGroup. With no-animations, a Vue component is allowed if it renders one HTML root. |
items | Array (required) | Items rendered by the item slot. |
row | Boolean (false) | Declares a horizontal layout. Required when this list contains nested drop targets arranged in a row. |
column | Boolean (false) | Declares a vertical layout. Required when this list contains nested drop targets arranged in a column. |
no-animations | Boolean (false) | Renders tag directly instead of using TransitionGroup and disables built-in move transitions. |
reorderable | Boolean or Function (true) | Enables reordering globally or through (item, index) => boolean. false positions remain pinned. |
scrolling-edge-size | Number or undefined (undefined) | Overrides the source edge threshold while this list is active. undefined inherits it; 0 disables autoscroll for this list. |
scrolling-propagation | Boolean or undefined (undefined) | Overrides the source propagation setting while this list is active. |
If both row and column are true, row takes precedence. They can both remain false for a non-nested layout, where item centers are used automatically.
Slots
item and feedback are required. Slot render functions should return one keyed root node; additional root nodes are ignored by the list renderer.
| Slot | Props | Description |
|---|---|---|
item | item, index, reorder | Renders an item. reorder is true for the item shown at the prospective location during live reordering. |
feedback | type, data | Required insertion placeholder used to calculate and display the external drop position. |
default | None | Content appended after managed item or empty content, such as a footer or add button. Key direct children when animations are enabled. |
empty | None | Content rendered when items is empty and no external drag is being inserted. |
drag-image | type, data | Image used while external data is dragged over this list. |
reordering-drag-image | item | Image used while an item is reordered inside this list. |
reordering-feedback | item | Optional explicit reordering placeholder. Without it, items move live to preview the resulting order. |
The feedback, empty, and explicit reordering-feedback roots should have stable keys when animations are enabled.
CSS classes
| Class | Applied when |
|---|---|
drop-list | Always on the root. |
dnd-drop | Always on the root, matching the base Drop contract. |
inserting | A drag from outside this list is active. |
reordering | The active source is a direct child of this list. |
type-allowed / type-forbidden | An external drag type is accepted or rejected. |
drop-in / drop-out | This list is or is not the active target. |
drop-allowed / drop-forbidden | The current external insertion or internal reorder is permitted or forbidden. |
Reorder and transfer items
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());
};Position locking
Use the reorderable predicate to pin an item to its current array position. Unlocked items can still move from one side of that position to the other.
Also disable the pinned item's Drag: reorderable prevents an allowed reorder, while Drag.disabled prevents the item from initiating a gesture at all.
<DropList
:items="items"
:reorderable="item => !item.locked"
@reorder="$event.apply(items)"
>
<template #item="{ item }">
<Drag
:key="item.id"
:data="item"
:disabled="item.locked"
>
{{ item.label }}
</Drag>
</template>
<template #feedback>
<div key="feedback" />
</template>
</DropList>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 onto list items
A list item can contain a nested Drop. The nested target becomes active over its own element, while uncovered parts of the item continue to route movement to the parent DropList. This supports interfaces where the centre of a folder accepts files and narrow edge areas reorder the surrounding list.
Set row or column on the parent list whenever an item contains a nested drop target. The list then uses the appropriate item edge for its insertion and reordering calculations. The nested target's dimensions define the centre threshold, so it can be adjusted with ordinary layout and CSS without another runtime prop.
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 DropLists
DropLists can be nested with these requirements:
- Set
roworcolumnon every list whose rendered items contain nested drop targets. This tells the position grid which edge of a nested item represents before/after. - An explicit
reordering-feedbackslot is recommended for predictable nested-list previews. - Keep
feedbackandreordering-feedbackout of the normal layout until activated, for example withflex: 0 0 0; align-self: stretch;and a visible outline.
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;