v3 — Drag and Drop für Vue 3
01Kanban

Boards, die Karten tauschen

02Bäume

Verschachteln mit einem Schubs

03Multi-Drag

Viele Elemente, ein Zug

04Sortierbar

Listen, die sich selbst sortieren

05Animiert

Bewegung von Haus aus

06A11y

Tastatur gleichberechtigt

07Virtuell

Zehntausend Zeilen

Ziehe die Karten – oder springe mit Tab zu einer und nutze die Pfeiltasten

Drag and Drop,
gemacht für Vue.

Sortierbare Listen, Kanban-Boards, Bäume und Multi-Drag für Vue 3 – animiert, mit Tastatursteuerung und von Haus aus barrierefrei.

npm i @vue-dnd-kit/core
Loslegen

Kostenlos und Open Source. MIT-Lizenz.

00So funktioniert es

Composables,
keine Komponenten.

Dein Markup, deine Styles. Übergib einem Composable ein Template-Ref, und das Element lässt sich ziehen, sortieren und ablegen – die Arbeit für Animation, Tastatur und Screenreader ist schon für dich erledigt.

  • 01makeSortableListListen, Raster und Kanban-Spalten, die sich beim Ziehen neu ordnen – und innerhalb einer Gruppe Elemente austauschen.
  • 02makeSortableTreeVerschachtelte Daten, flach gerendert: Tiefe durch einen Schubs zur Seite, Drops in Ordner, Virtualisierung.
  • 03makeDraggable · makeDroppableAlles andere: Boards, Canvases, Uploads – deine eigenen Regeln dafür, was wohin darf.

01Drag & Drop

Drag and Drop

Ein ziehbares Element, ein Drop-Ziel und eine Regel dafür, was wohin darf. Ziele leuchten auf, wenn sie den Drag annehmen, und lehnen ab, was sie nicht wollen; was ein Drop bewirkt, entscheidest du.

Dokumentation
Live

Demo wird geladen…

<script setup lang="ts">
import { computed, ref, useTemplateRef, watch } from 'vue';
import { makeDroppable, useDnD, type Key } from '@vue-dnd-kit/core';
import FileCard from './FileCard.vue';
import type { DeskFile } from './types';

const props = defineProps<{ id: string; name: string; accept: string; files: DeskFile[]; desktop?: boolean }>();
const emit = defineEmits<{ drop: [id: Key, place: string] }>();

const el = useTemplateRef<HTMLElement>('el');
// `isAccepting`: something is being dragged and this folder takes it.
const { isOver, isAccepting } = makeDroppable(el, {
  id: props.id,
  accept: props.accept,
  onDrop: (event) => emit('drop', event.active.id, props.id),
});

// Over it, but not its kind of file.
const { status } = useDnD();
const refused = computed(() => isOver.value && !isAccepting.value && status.value === 'dragging');

// A short pulse when a file comes in.
const received = ref(false);
watch(
  () => props.files.length,
  (count, before) => {
    if (count <= before) return;
    received.value = false;
    requestAnimationFrame(() => (received.value = true));
  },
);
</script>

<template>
  <section
    ref="el"
    class="folder"
    :class="{ desktop, accepting: isAccepting, over: isOver && isAccepting, refused, received }"
    @animationend="received = false"
  >
    <header>
      <svg v-if="id === 'trash'" class="icon" viewBox="0 0 24 24" aria-hidden="true">
        <path d="M4 7h16M9 7V4.5h6V7M6.5 7l1 13h9l1-13" />
        <path v-if="files.length" d="M10 11v5M14 11v5" />
      </svg>
      <svg v-else-if="!desktop" class="icon" viewBox="0 0 24 24" aria-hidden="true">
        <path v-if="isOver && isAccepting" d="M3 19V6a1.5 1.5 0 0 1 1.5-1.5h4l2 2h7A1.5 1.5 0 0 1 19 8v2M3 19l3-8h16l-3 8z" />
        <path v-else d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4l2 2h9A1.5 1.5 0 0 1 21 9.5v8a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 17.5z" />
      </svg>
      <span class="name">{{ name }}</span>
      <span class="count">{{ files.length }}</span>
    </header>
    <!-- The files left behind slide into the gap (Vue's TransitionGroup). -->
    <TransitionGroup tag="div" class="files" move-class="moving">
      <FileCard v-for="file in files" :key="file.id" :file="file" />
    </TransitionGroup>
  </section>
</template>

<style scoped>
/*
 * Sized for every file it can hold, so nothing around it moves when files come and go:
 * the desktop two rows, a folder all four in its tray.
 */
.folder {
  display: flex;
  flex-direction: column;
  gap: 10px;
  box-sizing: border-box;
  min-height: 284px;
  padding: 12px;
  border: 1.5px solid color-mix(in srgb, currentColor 12%, transparent);
  border-radius: 14px;
  background: color-mix(in srgb, currentColor 3%, Canvas);
  transition:
    border-color 0.2s,
    background 0.2s;
}

.folder.desktop {
  min-height: 166px;
  border-style: dashed;
}

/* A drag is on and this folder takes it. */
.folder.accepting {
  border-color: color-mix(in srgb, #42b883 55%, transparent);
  border-style: dashed;
}

.folder.over {
  border-style: solid;
  border-color: #42b883;
  background: color-mix(in srgb, #42b883 12%, Canvas);
}

.folder.refused {
  border-color: #f43f5e;
  background: color-mix(in srgb, #f43f5e 8%, Canvas);
  animation: shake 0.3s;
}

.folder.received {
  animation: pulse 0.45s 0.15s;
}

@keyframes shake {
  25% {
    translate: -4px 0;
  }
  75% {
    translate: 4px 0;
  }
}

@keyframes pulse {
  40% {
    scale: 1.04;
    border-color: #42b883;
  }
}

header {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  font-weight: 600;
}

.icon {
  width: 20px;
  height: 20px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.accepting .icon {
  stroke: #42b883;
}

.refused .icon {
  stroke: #f43f5e;
}

.count {
  margin-left: auto;
  padding: 3px 7px;
  border-radius: 99px;
  background: color-mix(in srgb, currentColor 8%, transparent);
  font: 11px/1 ui-monospace, Menlo, Consolas, monospace;
}

/* A folder's tray: where its files go. */
.files {
  display: flex;
  flex: 1;
  flex-direction: column;
  gap: 6px;
  padding: 5px;
  border: 1.5px dashed color-mix(in srgb, currentColor 14%, transparent);
  border-radius: 10px;
  transition: border-color 0.2s;
}

.accepting .files {
  border-color: color-mix(in srgb, #42b883 60%, transparent);
}

.desktop .files {
  display: grid;
  flex: none;
  grid-template-columns: repeat(auto-fill, 172px);
  gap: 8px 10px;
  padding: 0;
  border: 0;
}

.moving {
  transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
}

@media (prefers-reduced-motion: reduce) {
  .folder {
    transition: none;
  }

  .folder.refused,
  .folder.received {
    animation: none;
  }

  .moving {
    transition: none;
  }
}
</style>

02Sortierbar

Listen und Raster

Rendere einen Entwurf deines Arrays, und er ordnet sich schon während des Ziehens neu. Beim Drop wird die neue Reihenfolge in dein Ref geschrieben. Vertikal, horizontal oder als Raster beliebiger Größe.

Dokumentation
Live

Demo wird geladen…

<script setup lang="ts">
import { ref } from 'vue';
import AppList from './AppList.vue';

const apps = ref([
  { id: 'notes', name: 'Notes' },
  { id: 'maps', name: 'Maps' },
  { id: 'photos', name: 'Photos' },
  { id: 'clock', name: 'Clock' },
  { id: 'files', name: 'Files' },
  { id: 'calendar', name: 'Calendar' },
  { id: 'weather', name: 'Weather' },
  { id: 'settings', name: 'Settings' },
]);
const dock = ref([
  { id: 'mail', name: 'Mail' },
  { id: 'chat', name: 'Chat' },
  { id: 'camera', name: 'Camera' },
]);
</script>

<template>
  <!-- Each list in a component of its own: one sortable list per component. -->
  <div class="screen">
    <AppList v-model="apps" />
    <AppList v-model="dock" dock :limit="4" />
  </div>
</template>

<style scoped>
.screen {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 28px;
  padding: 24px 20px 14px;
  border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
  border-radius: 28px;
  background:
    radial-gradient(120% 80% at 0% 0%, color-mix(in srgb, #42b883 16%, transparent), transparent 60%),
    color-mix(in srgb, currentColor 3%, Canvas);
}
</style>

03Kanban

Kanban-Boards

Listen einer Gruppe tauschen Elemente aus. Kopiere aus einer Palette, nimm einfache ziehbare Elemente auf oder tausche statt zu verschieben – die empfangende Liste macht Platz, während du darüber hoverst.

Dokumentation
Live

Demo wird geladen…

<script setup lang="ts">
import { toRef, useTemplateRef } from 'vue';
import { makeSortableList } from '@vue-dnd-kit/core';
import Card from './Card.vue';
import type { Candidate } from './candidates';

const props = defineProps<{ column: { id: string; title: string; color: string; cards: Candidate[] } }>();

const zone = useTemplateRef<HTMLElement>('zone');
const list = useTemplateRef<HTMLElement>('list');
// Lists with the same `group` exchange items. The drop is written into `column.cards`.
// `dropzone`: the whole column takes drops, below the last card too.
const { items, isOver } = makeSortableList(list, {
  id: props.column.id,
  items: toRef(props.column, 'cards'),
  group: 'board',
  dropzone: zone,
});
</script>

<template>
  <section ref="zone" class="column" :class="{ over: isOver }">
    <h3>
      <span class="dot" :style="{ background: column.color }" />
      <span class="title">{{ column.title }}</span>
      <span class="count">{{ column.cards.length }}</span>
    </h3>
    <ul ref="list" class="cards">
      <Card v-for="card in items" :key="card.id" :card="card" />
    </ul>
  </section>
</template>

<style scoped>
.column {
  display: flex;
  flex-direction: column;
  padding: 10px;
  border: 1px solid color-mix(in srgb, currentColor 10%, transparent);
  border-radius: 14px;
  background: color-mix(in srgb, currentColor 3%, transparent);
  transition:
    background 0.2s,
    border-color 0.2s;
}

.column.over {
  border-color: color-mix(in srgb, #42b883 60%, transparent);
  background: color-mix(in srgb, #42b883 6%, transparent);
}

h3 {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 4px 4px 12px;
  font-size: 13px;
  font-weight: 600;
}

.dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
}

.count {
  margin-left: auto;
  padding: 3px 7px;
  border-radius: 99px;
  background: color-mix(in srgb, currentColor 8%, transparent);
  font: 11px/1 ui-monospace, Menlo, Consolas, monospace;
}

.cards {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-height: 160px;
  margin: 0;
  padding: 0;
  list-style: none;
}
</style>

04Bäume

Bäume

Flach mit Tiefen gerendert, deshalb lassen sie sich virtualisieren. Ziehe seitwärts, um die Ebene zu ändern, oder hovere über einem Ordner, um darin abzulegen; begrenze die Tiefe und entscheide, was wo verschachtelt wird.

Dokumentation
Live

Demo wird geladen…

<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue';
import { makeSortableTree, type Key } from '@vue-dnd-kit/core';
import TreeRow from './TreeRow.vue';
import { weightOf, type Thing } from './types';

const things = ref<Thing[]>([
  {
    id: 'backpack',
    name: 'Backpack',
    icon: '🎒',
    container: true,
    children: [
      {
        id: 'pouch',
        name: 'Pouch',
        icon: '👝',
        container: true,
        children: [
          { id: 'coins', name: 'Coins', icon: '💰', weight: 1 },
          { id: 'key', name: 'Old key', icon: '🗝️', weight: 1 },
        ],
      },
      { id: 'potion', name: 'Potion', icon: '🧪', weight: 2 },
      { id: 'map', name: 'Map', icon: '🗺️', weight: 1 },
    ],
  },
  { id: 'chest', name: 'Chest', icon: '🧰', container: true, children: [{ id: 'shield', name: 'Shield', icon: '🛡️', weight: 6 }] },
  { id: 'sword', name: 'Sword', icon: '🗡️', weight: 4 },
  { id: 'apple', name: 'Apple', icon: '🍎', weight: 1 },
]);
const collapsed = ref(new Set<Key>());

const INDENT = 20;
const root = useTemplateRef<HTMLElement>('root');
// Drag sideways to change the depth, or hover the middle of a container to drop into it.
// Only containers hold things, and a container holds no more than two levels.
const { items } = makeSortableTree(root, {
  items: things,
  indentation: INDENT,
  collapsed,
  canNest: (_thing, parent) => !!parent.container,
  maxDepth: 2,
});

const carried = computed(() => things.value.filter((thing) => thing.id !== 'chest').reduce((sum, thing) => sum + weightOf(thing), 0));
const toggle = (key: Key) => (collapsed.value.has(key) ? collapsed.value.delete(key) : collapsed.value.add(key));
</script>

<template>
  <section class="inventory">
    <header>
      Inventory
      <span class="carried">{{ carried }} kg carried</span>
    </header>
    <div ref="root" class="tree">
      <TreeRow v-for="node in items" :key="node.key" :node="node" :indent="INDENT" @toggle="toggle" />
    </div>
  </section>
</template>

<style scoped>
.inventory {
  width: 320px;
  max-width: 100%;
  margin: 0 auto;
  padding: 6px;
  border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
  border-radius: 14px;
  background: color-mix(in srgb, currentColor 3%, Canvas);
}

header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 10px 8px;
  font-size: 13px;
  font-weight: 700;
}

.carried {
  font: 500 11px/1 ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.55;
}

.tree {
  display: flex;
  flex-direction: column;
  gap: 2px;
}
</style>

05Multi-Drag

Multi-Drag

Wähle mit Strg/Cmd-Klick aus oder ziehe einen Rahmen auf, dann ziehe eines: Die ganze Auswahl bewegt sich gemeinsam und landet in ihrer Reihenfolge, sogar über Listen hinweg.

Dokumentation
Live

Demo wird geladen…

<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { makeSelectionArea, makeSortableList } from '@vue-dnd-kit/core';
import Shot from './Shot.vue';

const shots = ref(
  [12, 48, 96, 150, 200, 265, 300, 330].map((hue, index) => ({ id: `s${index + 1}`, hue })),
);

const grid = useTemplateRef<HTMLElement>('grid');
const { items } = makeSortableList(grid, { items: shots, strategy: 'grid' });

// Press on the empty space and draw a box to select; Ctrl/Cmd-click a shot to toggle it.
const area = useTemplateRef<HTMLElement>('area');
const { isSelecting, style } = makeSelectionArea(area);
</script>

<template>
  <div ref="area" class="area">
    <div ref="grid" class="grid">
      <Shot v-for="shot in items" :key="shot.id" :shot="shot" />
    </div>
    <div v-if="isSelecting" class="band" :style="style" />
  </div>
</template>

<style scoped>
/* The area fills its box: a selection box starts anywhere in it. */
.area {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  height: 100%;
  min-height: 320px;
  padding: 24px;
  user-select: none;
}

.grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 12px;
  width: min(100%, 528px);
}

.band {
  border: 1px solid #42b883;
  background: color-mix(in srgb, #42b883 15%, transparent);
}
</style>

06Einschränkungen

Achsen, Grenzen und Raster

Ein Drag-Bereich legt die Regeln für alles fest, was darin gezogen wird: eine Achse sperren, Drags innerhalb seiner Ränder halten, am Raster einrasten – und das alles ändern, während die App läuft.

Dokumentation
Live

Demo wird geladen…

<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue';
import { makeDragArea, makeDroppable, snapToGrid, useDnD } from '@vue-dnd-kit/core';
import Shape from './Shape.vue';
import type { Shape as ShapeData } from './shapes';

const GRID = 24;
const shapes = ref<ShapeData[]>([
  { id: 'title', kind: 'title', label: 'Title', x: 24, y: 24, width: 240, height: 48 },
  { id: 'chart', kind: 'chart', label: 'Chart', x: 24, y: 96, width: 192, height: 120 },
  { id: 'photo', kind: 'photo', label: 'Photo', x: 264, y: 96, width: 192, height: 120 },
  { id: 'logo', kind: 'logo', label: 'Logo', x: 384, y: 24, width: 72, height: 48 },
]);

const axis = ref<'x' | 'y' | undefined>();
const snap = ref(true);
const bounded = ref(true);

const slide = useTemplateRef<HTMLElement>('slide');
// Rules for every drag that starts on the slide, read when a drag starts: an axis lock,
// the slide's edges, a grid.
makeDragArea(slide, {
  axis,
  restrictToArea: bounded,
  modifiers: () => (snap.value ? [snapToGrid(GRID)] : []),
});

// The slide is also the drop target: a shape stays where it is dropped. `position` is
// where it landed, as `left`/`top` inside the slide.
const moved = ref<ShapeData | null>(null);
makeDroppable(slide, {
  id: 'slide',
  onDrop: ({ active, position }) => {
    const shape = shapes.value.find((item) => item.id === active.id);
    if (!shape || !position) return;
    shape.x = Math.round(position.x);
    shape.y = Math.round(position.y);
    moved.value = shape;
  },
});

// The line a shape slides along while the axis is locked.
const { active } = useDnD();
const guide = computed(() => {
  const shape = shapes.value.find((item) => item.id === active.value?.id);
  if (!shape || !axis.value) return null;
  return axis.value === 'x' ? { top: `${shape.y + shape.height / 2}px` } : { left: `${shape.x + shape.width / 2}px` };
});

const AXES = [
  { label: 'Free', value: undefined },
  { label: 'X', value: 'x' },
  { label: 'Y', value: 'y' },
] as const;
</script>

<template>
  <div class="editor">
    <div class="toolbar">
      <div class="segmented" role="group" aria-label="Axis">
        <button
          v-for="option in AXES"
          :key="option.label"
          type="button"
          :aria-pressed="axis === option.value"
          @click="axis = option.value"
        >
          {{ option.label }}
        </button>
      </div>
      <button type="button" class="toggle" :aria-pressed="snap" @click="snap = !snap">Snap to grid</button>
      <button type="button" class="toggle" :aria-pressed="bounded" @click="bounded = !bounded">Keep on slide</button>
      <span class="readout">{{ moved ? `${moved.label} → ${moved.x}, ${moved.y}` : '' }}</span>
    </div>
    <!-- On a narrow screen, the slide scrolls sideways: its shapes are placed in px. -->
    <div class="scroll">
      <div ref="slide" class="slide" :class="{ snap, bounded, dragging: !!active }">
        <div v-if="guide" class="guide" :class="axis" :style="guide" />
        <Shape v-for="shape in shapes" :key="shape.id" :shape="shape" />
      </div>
    </div>
  </div>
</template>

<style scoped>
.editor {
  display: flex;
  flex-direction: column;
  gap: 12px;
  width: 100%;
  max-width: 488px;
  margin: 0 auto;
}

.toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
}

.segmented {
  display: flex;
  padding: 3px;
  border-radius: 10px;
  background: color-mix(in srgb, currentColor 7%, transparent);
}

button {
  padding: 6px 12px;
  border: 0;
  border-radius: 8px;
  background: none;
  color: inherit;
  font: 600 12px/1.2 system-ui, sans-serif;
  cursor: pointer;
}

.segmented button[aria-pressed='true'] {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
}

.toggle {
  box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 15%, transparent);
  opacity: 0.6;
}

.toggle[aria-pressed='true'] {
  background: color-mix(in srgb, #3b82f6 14%, transparent);
  box-shadow: inset 0 0 0 1px #3b82f6;
  color: #3b82f6;
  opacity: 1;
}

button:focus-visible {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

.readout {
  margin-left: auto;
  font: 11px/1 ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.6;
}

.scroll {
  overflow-x: auto;
  padding: 4px;
}

/* A white 16:9 slide in either theme. */
.slide {
  position: relative;
  width: 480px;
  height: 270px;
  border-radius: 8px;
  background: #fff;
  box-shadow:
    0 0 0 1px color-mix(in srgb, currentColor 14%, transparent),
    0 18px 40px -24px rgb(0 0 0 / 0.6);
}

.slide.snap {
  background-image:
    linear-gradient(to right, rgb(15 23 42 / 0.07) 1px, transparent 1px),
    linear-gradient(to bottom, rgb(15 23 42 / 0.07) 1px, transparent 1px);
  background-size: 24px 24px;
}

.slide.bounded.dragging {
  box-shadow: 0 0 0 2px #3b82f6;
}

.guide {
  position: absolute;
  pointer-events: none;
}

.guide.x {
  left: 0;
  right: 0;
  border-top: 1.5px dashed #3b82f6;
}

.guide.y {
  top: 0;
  bottom: 0;
  border-left: 1.5px dashed #3b82f6;
}
</style>

07Und mehr

Bereit für echte Apps.

01

Animiert

FLIP beim Sortieren, ein Drop, der in seinem Slot landet, Datenänderungen, die animiert werden. Bring GSAP oder motion mit, wenn du magst.

02

Barrierefrei

Ziehen per Tastatur von Slot zu Slot, Live-Ansagen und ARIA-Attribute, die deine nie überschreiben.

03

Virtuelle Listen

TanStack Virtual und VueUse: Tausende Zeilen, weiterhin sortierbar, weiterhin animiert.

04

Kontrolliert

Pinia, ein Server oder unveränderliche Daten: Überspringe das Standard-Schreiben und wende das Ergebnis selbst an.

08Ökosystem

Ein kleiner Kern, ein paar scharfe Werkzeuge.

Mit dem Kern lässt sich alles ziehen. Füge den Rest hinzu, wenn du ihn brauchst.

01
@vue-dnd-kit/devtools

DevTools

Ein Tab in den Vue DevTools und den Nuxt DevTools: eine Live-Minimap, ein Joystick, der alles ziehen kann, und Optionen, die du bearbeitest, während die App läuft.

02
@vue-dnd-kit/actions

Programmatische Drags

„Nach oben“, „nach Done verschieben“, geführte Touren und Tests – echte Drags, angesagt und animiert, aus dem Code.

03
@vue-dnd-kit/nuxt

Nuxt-Modul

Automatisch importierte Composables, globale Komponenten und SSR-sichere Standardwerte.

04
npx skills add

Für KI-Agenten

Ein Agent-Skill, der Claude Code und anderen Assistenten beibringt, wie die Bibliothek funktioniert.

npx skills add ZiZIGY/vue-dnd-kit

01

1.243

End-to-End-Tests in drei Browsern

02

469

Unit-Tests

03

21KB

Ziehbare Elemente und Drop-Zonen, gzip-komprimiert

04

9

Sprachen dieser Dokumentation