Skip to content

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.

Return unsuccessful drags

Release outside the target to see the drag image return to its source.

Source
Drag me
Valid target Drop here
View example code

Template

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

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

Accept drag types

Each target participates only when its configured type is being dragged.

Number 42
Letter A
Numbers only
Letters only
View example code

Template

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

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

Custom drag images

Drag a profile icon and see how each drop zone can show a different drag image.

Profile icon for Profile
Outer target
Nested target
Masked area
View example code

Template

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

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

Update a drag image during an active drag

Move 180px beyond any edge of the original source card to replace the compact preview with its expanded state.

Source
Demo itemMove 180px beyond any edge to expand
Target Drop the item here
View example code

Template

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

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

Resolve a drag handle outside the Drag component

Select a card, then use the shared toolbar button to drag it. The handle is resolved only when the pointer goes down.

Select a card first
Design briefClick to select
Research notesClick to select
Launch checklistClick to select
Drop the selected card here
View example code

Template

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

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

Control nested edge scrolling

Adjust the edge activation distance and scroll delta, then choose whether scrolling can continue into the outer container.

Outer scroll area above the list

Scrollable item 1
Scrollable item 2
Scrollable item 3
Scrollable item 4
Scrollable item 5
Scrollable item 6
Scrollable item 7
Scrollable item 8
Scrollable item 9
Scrollable item 10
Scrollable item 11
Scrollable item 12
Scrollable item 13
Scrollable item 14
Scrollable item 15
Scrollable item 16
Scrollable item 17
Scrollable item 18

Outer scroll area below the list

View example code

Template

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

ts
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 and cut modes

Copy leaves the source intact. Cut emits back to the source so it can be removed.

One
Two
Three
Copy
Cut
View example code

Template

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

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

Filter drag data

All items share a type; accepts-data decides whether each value is allowed.

1
2
3
4
5
Copy even numbers
Copy odd numbers
Cut any number
View example code

Template

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

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

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());
};

Keep an item at a fixed position

Pin one item while allowing every unlocked item to move around and across it.

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 files into folders

Use the edges of a folder item to reorder it, or its inset centre to move a file into that folder.

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 drop lists

Compose row and column lists into a nested layout.

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;

DropMask

Mask part of a drop target

Keep part of a Drop component insensitive to drag-and-drop interactions.

Mask part of a drop target

Releasing over the pink mask does nothing; the surrounding target still accepts drops.

Drag me
Drop target
DropMask
View example code

Template

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

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

Observe drag state

useDragAware exposes reactive state without coupling your component to drag internals.

One
Two
Target
In progress
false
Type
Data
Position
Source component
Top component
View example code

Template

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

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

Drag and drop inside an open Shadow DOM

The source, target, event routing, and scroll-parent lookup all operate inside this isolated shadow root.

View example code

Template

vue
<div
  ref="host"
  class="shadow-dom-demo__host"
  aria-label="Shadow DOM drag-and-drop example"
/>

TypeScript

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

Transfer rich cards

The list state changes, while custom slots keep drag images and insertion feedback compact.

Inbox
Alex Chen avatarBrunch this weekend?Alex Chen · 10 min ago
Sam Rivera avatarSummer launch notesSam Rivera · 35 min ago
Saved
Jordan Lee avatarIdeas for the docsJordan Lee · Yesterday
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.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

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

Copy rows into a list

Table rows stay in place because the destination uses copy mode.

Products
NamePrice
Notebook$12
Pencil case$18
Desk lamp$45
Selection
Drop a table row here
View example code

Template

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

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

Build a dashboard

Copy palette widgets into nested rows and columns, then move them between regions.

OverviewProduct performanceLive
Monthly revenue$84.2k+18.2%
Active accounts2,849+6.7%
Conversion7.4%+1.1%
Revenue trend$84,240Last 12 months
Abstract upward revenue trend
Recent activity
  • New workspace created
  • Quarterly report shared
  • 12 members invited
Quarterly noteWhat we learned

Growth is strongest in self-serve teams. Focus the next release on faster onboarding and clearer activation milestones.

View example code

Dashboard template

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

ts
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

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';
};

Widget preview template

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

ts
import revenueTrend from '../assets/revenue-trend.jpg';
import type { DemoWidget } from '../types';

defineProps<{
  widget: DemoWidget;
}>();

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;