v3 — Drag and drop для Vue 3
01Канбан

Доски, которые меняются карточками

02Деревья

Вложенность одним движением

03Мульти-драг

Много элементов, одно движение

04Сортировка

Списки, которые сортируются сами

05Анимации

Движение по умолчанию

06A11y

Клавиатура наравне с мышью

07Виртуализация

Десять тысяч строк

Перетащите карточки — или перейдите к одной клавишей Tab и двигайте стрелками

Drag and drop,
созданный для Vue.

Сортируемые списки, канбан-доски, деревья и мульти-драг для Vue 3 — с анимациями, клавиатурой и доступностью из коробки.

npm i @vue-dnd-kit/core
Начать

Бесплатно и с открытым кодом. Лицензия MIT.

00Как это устроено

Композаблы,
а не компоненты.

Ваша разметка, ваши стили. Передайте template ref в композабл — и элемент перетаскивается, сортируется и принимает броски, а анимации, клавиатура и скринридеры уже настроены.

  • 01makeSortableListСписки, сетки и колонки канбана: перестраиваются во время драга и обмениваются элементами внутри группы.
  • 02makeSortableTreeВложенные данные, отрисованные плоско: глубина сдвигом вбок, бросок в папку, виртуализация.
  • 03makeDraggable · makeDroppableВсё остальное: доски, холсты, загрузки — ваши правила, что и куда можно положить.

01Drag & drop

Перетащить и бросить

Перетаскиваемый элемент, цель и правило, что куда можно положить. Цели подсвечиваются, когда принимают драг, и отказывают чужому, а что делает бросок — решаете вы.

Документация
Live

Загружаем демо…

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

02Сортировка

Списки и сетки

Рендерите черновик массива — он перестраивается прямо во время драга. При броске новый порядок записывается в ваш ref. Вертикально, горизонтально или сеткой любого размера.

Документация
Live

Загружаем демо…

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

03Канбан

Канбан-доски

Списки одной группы обмениваются элементами. Копирование из палитры, приём внешних элементов или обмен местами — список-получатель освобождает место прямо под курсором.

Документация
Live

Загружаем демо…

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

04Деревья

Деревья

Отрисовываются плоско с глубинами, поэтому виртуализируются. Сдвиг вбок меняет уровень, наведение на папку кладёт внутрь; ограничивайте глубину и решайте, что куда вкладывается.

Документация
Live

Загружаем демо…

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

05Мульти-драг

Мульти-драг

Выделите Ctrl/Cmd-кликом или рамкой и потащите один элемент — всё выделенное поедет вместе и встанет по порядку, даже между списками.

Документация
Live

Загружаем демо…

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

06Ограничения

Оси, границы и сетки

Область перетаскивания задаёт правила для всего, что тащат внутри неё: фиксирует ось, держит драг в своих границах, примагничивает к сетке — и всё это меняется на лету.

Документация
Live

Загружаем демо…

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

07И ещё

Готово к настоящим приложениям.

01

Анимации

FLIP при сортировке, приземление на слот, анимация изменений данных. Можно подключить GSAP или motion.

02

Доступность

Перенос с клавиатуры по слотам, озвучивание и ARIA-атрибуты, которые не перезаписывают ваши.

03

Виртуальные списки

TanStack Virtual и VueUse: тысячи строк — и всё так же с сортировкой и анимацией.

04

Управляемый режим

Pinia, сервер или иммутабельные данные: пропустите запись по умолчанию и примените результат сами.

08Экосистема

Небольшое ядро и несколько точных инструментов.

Перетаскивать можно уже с одним ядром. Остальное подключайте, когда понадобится.

01
@vue-dnd-kit/devtools

DevTools

Вкладка в Vue DevTools и Nuxt DevTools: живая мини-карта, джойстик, который тащит что угодно, и правка опций на лету.

02
@vue-dnd-kit/actions

Программный драг

«Вверх», «в Done», интерактивные туры и тесты — настоящие перетаскивания с анимацией и озвучиванием, из кода.

03
@vue-dnd-kit/nuxt

Nuxt-модуль

Автоимпорт композаблов, глобальные компоненты и безопасные для SSR настройки.

04
npx skills add

Для ИИ-агентов

Навык, который объясняет Claude Code и другим ассистентам, как устроена библиотека.

npx skills add ZiZIGY/vue-dnd-kit

01

1 243

Сквозных тестов в трёх браузерах

02

469

Юнит-тестов

03

21KB

Draggable и зоны сброса, gzip

04

9

Языков документации