Vue DnD Kit v2: революция в мире Drag N Drop для Vue.js
- воскресенье, 8 марта 2026 г. в 00:00:07
Если вы когда-нибудь пробовали сделать drag-and-drop на Vue по-настоящему гибким - с кастомным overlay, вложенными зонами, multi-drag и анимацией при отпускании - вы знаете, что большинство библиотек держат вас в клетке. Vue DnD Kit v2 эту клетку сломал.
Сегодня выходит v2. Рассказываю, что внутри и почему я уверен что это прям революция.
Никаких компонентов-обёрток <Draggable> и <Droppable>. В библиотеки — чистые composables, которые работают с любым элементом через ref:
<script setup lang="ts"> import { useTemplateRef } from 'vue'; import { makeDraggable } from '@vue-dnd-kit/core'; const props = defineProps<{ index: number; items: string[] }>(); const itemRef = useTemplateRef<HTMLElement>('itemRef'); const { isDragging } = makeDraggable(itemRef, {}, () => [props.index, props.items]); </script> <template> <div ref="itemRef" :class="{ 'opacity-0': isDragging }"> <slot /> </div> </template>
Ваш div, ваша разметка, ваши классы. Библиотека только добавляет логику — не диктует структуру. А так же позволяет работать с компонентами других библиотек потому что используется ref.
То же для droppable:
makeDroppable(zoneRef, { events: { onDrop(e) { const r = e.helpers.suggestSort('vertical'); if (r) items.value = r.targetItems as Item[]; } } }, () => items.value);
Самая болезненная часть любого DnD — логика «куда вставить элемент». Считать индексы, определять, выше или ниже курсора, делать splice... В v2 это решено на уровне API.
Каждый drop-event несёт объект helpers со всем нужным:
function onDrop(e: IDragEvent) { const r = e.helpers.suggestSort('vertical'); // сам смотрит на позицию курсора if (r) items.value = r.targetItems as Item[]; }
suggestSort анализирует, где именно курсор относительно элемента, и возвращает уже готовый новый массив. Никаких getBoundingClientRect в вашем коде.
Полный набор хелперов:
Хелпер | Что делает |
|---|---|
| Сортировка с определением позиции по курсору |
| Обмен местами двух элементов |
| Копирование в целевой список |
| Удаление из исходного списка |
| Низкоуровневая вставка |
| Низкоуровневый обмен |
Но!) Вы всегда можете сами обработать все если вам это нужно, все хелперы лишь используют то вы получаете в event.
Перетаскивание между двумя списками — двенадцать строк с читабельной логикой:
function onDrop(e: IDragEvent) { const r = e.helpers.suggestSort('vertical'); if (!r) return; if (r.sameList) { listA.value = r.targetItems as Item[]; } else { if (r.sourceItems === listA.value) listA.value = r.sourceItems as Item[]; if (r.targetItems === listB.value) listB.value = r.targetItems as Item[]; } }
Обратите внимание: r.sourceItems === listA.value — identity-сравнение массивов вместо поиска. O(1), никакого find.
Выделяете несколько элементов — тащите всё сразу. Вы можете это сделать с помощью selected от драга и назначить на какой нибудь чекбокс через v-model или через makeSelectionArea которая создаёт область выделения прямо как на Windows:
<script setup lang="ts"> import { useTemplateRef } from 'vue'; import { makeSelectionArea } from '@vue-dnd-kit/core'; const areaRef = useTemplateRef<HTMLElement>('areaRef'); const { style } = makeSelectionArea(areaRef); </script> <template> <div ref="areaRef" class="selection-container"> <div class="selection-box" :style="style" /> <slot /> </div> </template> <style scoped> .selection-container { position: relative } </style>
e.draggedItems в обработчике drop содержит все выделенные элементы. suggestSort и suggestSwap корректно обрабатывают их без каких-либо изменений в вашем коде.
Вложенность — исторически слабое место DnD-библиотек. В v2 реализован чёткий алгоритм определения целевого массива: если курсор над draggable-элементом внутри droppable-зоны — вставляем в массив этого элемента, а не зоны. Это работает автоматически.
Дерево на v2:
function onDrop(e: IDragEvent) { const r = e.helpers.suggestSort('vertical'); if (!r) return; // r.targetItems уже указывает на нужный массив — children узла или корень applyToTree(r.sourceItems, r.targetItems, r); }
Никакого специального кода для определения глубины. Библиотека разбирается сама.
DragPreview — встроенный компонент, который вы оборачиваете во что угодно. CSS <Transition>, motion-v, GSAP — что хотите.
Простая CSS-анимация появления:
<Transition name="pop" appear> <DragPreview /> </Transition> <style> .pop-enter-active { transition: opacity 0.15s ease, scale 0.15s cubic-bezier(0.34, 1.56, 0.64, 1); } .pop-enter-from { opacity: 0; scale: 0.85; } </style>
Spring-физика через motion-v — с анимацией появления и исчезновения:
<script setup lang="ts"> import { motion, AnimatePresence } from 'motion-v'; import { DragPreview } from '@vue-dnd-kit/core'; </script> <template> <AnimatePresence mode="popLayout"> <DragPreview v-slot="{ draggingMap }"> <motion.div v-for="[node, draggable] in draggingMap" :key="node" :initial="{ scale: 0.9, opacity: 0, rotate: -2 }" :animate="{ scale: 1.06, opacity: 1, rotate: 1.5 }" :exit="{ scale: 0.9, opacity: 0, rotate: -2 }" :transition="{ type: 'spring', stiffness: 480, damping: 26 }" > <component v-if="draggable.render" :is="draggable.render" /> <component v-else :is="node.tagName" v-html="draggable.initialOuterHTML" :style="{ width: draggable.initialRect.width + 'px', height: draggable.initialRect.height + 'px' }" /> </motion.div> </DragPreview> </AnimatePresence> </template>
AnimatePresence здесь ключевой: DragPreview использует v-if внутри, и без него анимация исчезновения просто не успеет сыграть — элемент уйдёт из DOM раньше.
Можно менять preview динамически — в зависимости от зоны под курсором:
<ReactionZone @enter="$event.provider.preview.render.value = markRaw(SpecialOverlay)" @leave="$event.provider.preview.render.value = null" />
Каждый draggable может указать свой компонент для рендера в overlay:
makeDraggable(itemRef, { render: markRaw(TaskCard), data: () => ({ id: props.id, title: props.title, priority: props.priority }), });
TaskCard рендерится внутри DragPreview и читает данные через useDnDProvider().entities — полный контроль над тем, как выглядит то, что тащит пользователь.
Ограничения движения — только по оси или не выходить за пределы контейнера:
makeConstraintArea(containerRef, { axis: 'y', restrictToArea: true, });
Автоскролл viewport и отдельных контейнеров:
<DnDProvider :auto-scroll-viewport="true">...</DnDProvider>
makeAutoScroll(scrollableRef, { threshold: 80, speed: 1.5 });
Async drop — показываем диалог, preview ждёт результата:
function onDrop(e: IDragEvent) { return new Promise<void>((resolve, reject) => { showConfirmDialog({ message: `Переместить "${e.draggedItems[0].item}"?`, onConfirm: () => { applySort(e); resolve(); }, onCancel: reject, }); }); }
Keyboard navigation — из коробки, с настраиваемыми клавишами.
Zero dependencies (кроме Vue 3), tree-shakeable, полная TypeScript типизация.
npm install @vue-dnd-kit/core
Всех желающий ознакомится приглашаю к себе в репозиторий и в документацию))
Документация и playground: vue-dnd-kit.dev
GitHub: github.com/zizigy/vue-dnd-kit
Если попробуете и найдёте что-то странное — открывайте issue, я читаю всё. Звёзды тоже принимаю :)
А так же любую помощь <3