Анимации
FLIP при сортировке, приземление на слот, анимация изменений данных. Можно подключить GSAP или motion.
Доски, которые меняются карточками
Вложенность одним движением
Много элементов, одно движение
Списки, которые сортируются сами
Движение по умолчанию
Клавиатура наравне с мышью
Десять тысяч строк
Перетащите карточки — или перейдите к одной клавишей Tab и двигайте стрелками
Сортируемые списки, канбан-доски, деревья и мульти-драг для Vue 3 — с анимациями, клавиатурой и доступностью из коробки.
Бесплатно и с открытым кодом. Лицензия MIT.
00Как это устроено
Ваша разметка, ваши стили. Передайте template ref в композабл — и элемент перетаскивается, сортируется и принимает броски, а анимации, клавиатура и скринридеры уже настроены.
makeSortableListСписки, сетки и колонки канбана: перестраиваются во время драга и обмениваются элементами внутри группы.makeSortableTreeВложенные данные, отрисованные плоско: глубина сдвигом вбок, бросок в папку, виртуализация.makeDraggable · makeDroppableВсё остальное: доски, холсты, загрузки — ваши правила, что и куда можно положить.01Drag & drop
Перетаскиваемый элемент, цель и правило, что куда можно положить. Цели подсвечиваются, когда принимают драг, и отказывают чужому, а что делает бросок — решаете вы.
ДокументацияЗагружаем демо…
<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. Вертикально, горизонтально или сеткой любого размера.
ДокументацияЗагружаем демо…
<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Канбан
Списки одной группы обмениваются элементами. Копирование из палитры, приём внешних элементов или обмен местами — список-получатель освобождает место прямо под курсором.
ДокументацияЗагружаем демо…
<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Деревья
Отрисовываются плоско с глубинами, поэтому виртуализируются. Сдвиг вбок меняет уровень, наведение на папку кладёт внутрь; ограничивайте глубину и решайте, что куда вкладывается.
ДокументацияЗагружаем демо…
<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-кликом или рамкой и потащите один элемент — всё выделенное поедет вместе и встанет по порядку, даже между списками.
ДокументацияЗагружаем демо…
<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Ограничения
Область перетаскивания задаёт правила для всего, что тащат внутри неё: фиксирует ось, держит драг в своих границах, примагничивает к сетке — и всё это меняется на лету.
ДокументацияЗагружаем демо…
<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И ещё
FLIP при сортировке, приземление на слот, анимация изменений данных. Можно подключить GSAP или motion.
Перенос с клавиатуры по слотам, озвучивание и ARIA-атрибуты, которые не перезаписывают ваши.
TanStack Virtual и VueUse: тысячи строк — и всё так же с сортировкой и анимацией.
Pinia, сервер или иммутабельные данные: пропустите запись по умолчанию и примените результат сами.
08Экосистема
Перетаскивать можно уже с одним ядром. Остальное подключайте, когда понадобится.
Вкладка в Vue DevTools и Nuxt DevTools: живая мини-карта, джойстик, который тащит что угодно, и правка опций на лету.
«Вверх», «в Done», интерактивные туры и тесты — настоящие перетаскивания с анимацией и озвучиванием, из кода.
Автоимпорт композаблов, глобальные компоненты и безопасные для SSR настройки.
Навык, который объясняет Claude Code и другим ассистентам, как устроена библиотека.
01
1 243
Сквозных тестов в трёх браузерах
02
469
Юнит-тестов
03
21KB
Draggable и зоны сброса, gzip
04
9
Языков документации