Skip to content

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:

EventPayloadDescription
insertInsertEventExternal data was dropped at a calculated index. The payload also contains type and data.
reorderReorderEventAn 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:

vue
<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:

PropType / DefaultDescription
tagString 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.
itemsArray (required)Items rendered by the item slot.
rowBoolean (false)Declares a horizontal layout. Required when this list contains nested drop targets arranged in a row.
columnBoolean (false)Declares a vertical layout. Required when this list contains nested drop targets arranged in a column.
no-animationsBoolean (false)Renders tag directly instead of using TransitionGroup and disables built-in move transitions.
reorderableBoolean or Function (true)Enables reordering globally or through (item, index) => boolean. false positions remain pinned.
scrolling-edge-sizeNumber or undefined (undefined)Overrides the source edge threshold while this list is active. undefined inherits it; 0 disables autoscroll for this list.
scrolling-propagationBoolean 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.

SlotPropsDescription
itemitem, index, reorderRenders an item. reorder is true for the item shown at the prospective location during live reordering.
feedbacktype, dataRequired insertion placeholder used to calculate and display the external drop position.
defaultNoneContent appended after managed item or empty content, such as a footer or add button. Key direct children when animations are enabled.
emptyNoneContent rendered when items is empty and no external drag is being inserted.
drag-imagetype, dataImage used while external data is dragged over this list.
reordering-drag-imageitemImage used while an item is reordered inside this list.
reordering-feedbackitemOptional 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

ClassApplied when
drop-listAlways on the root.
dnd-dropAlways on the root, matching the base Drop contract.
insertingA drag from outside this list is active.
reorderingThe active source is a direct child of this list.
type-allowed / type-forbiddenAn external drag type is accepted or rejected.
drop-in / drop-outThis list is or is not the active target.
drop-allowed / drop-forbiddenThe current external insertion or internal reorder is permitted or forbidden.

Reorder and transfer items

Reorder and transfer list items

Items can be reordered in place or cut from one list and inserted into the other.

First list
One
Two
Three
Second list
A
B
C
View example code

Template

vue
<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

ts
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.

vue
<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>
Keep an item pinned while others move around it

The policy remains at position 3 while unlocked items can move from one side of it to the other.

Inbox
Design review
Required policyPinned at position 3
Quality assurance
Ready to publish
View example code

Template

vue
<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

ts
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.

Reorder files or drop them into folders

Use a folder’s narrow edge strips to reorder, or its inset centre to move a file into that folder.

README.md
Reorder above
Design assetsDrop files in this centre area
logo.svg
Reorder below
roadmap.md
Reorder above
ArchiveDrop files in this centre area
Empty folder
Reorder below
View example code

Template

vue
<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

ts
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 row or column on 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-feedback slot is recommended for predictable nested-list previews.
  • Keep feedback and reordering-feedback out of the normal layout until activated, for example with flex: 0 0 0; align-self: stretch; and a visible outline.
Nested drop lists

Move widgets between row and column lists; each list declares its direction.

Headertext
Metricmetric
Chartchart
Summarytext
View example code

Example template

vue
<NestedListNode :group="tree" @operation="applyOperation" />

Example TypeScript

ts
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

vue
<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

ts
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

ts
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;