动画
排序时的 FLIP、落到位置上的放置、带动画的数据变化。也可以引入 GSAP 或 motion。
互换卡片的看板
轻推一下即可嵌套
多个项,一次移动
自动排序的列表
默认带动效
键盘是一等公民
一万行数据
拖动卡片——或者用 Tab 聚焦到一张卡片,再用方向键移动
00工作方式
标记是你的,样式也是你的。把模板引用交给组合式函数,元素就能拖拽、排序和放置——动画、键盘和屏幕阅读器的工作都已替你完成。
makeSortableList拖拽时重新排序的列表、网格和看板列——还能在同一分组内交换项。makeSortableTree嵌套数据,扁平渲染:横向轻推改变深度,放进文件夹,支持虚拟化。makeDraggable · makeDroppable其他一切:面板、画布、上传——什么放到哪里由你自己定规则。01拖放
一个可拖拽元素、一个放置目标,再加一条决定什么放到哪里的规则。目标接收拖拽时会亮起,不接收的则拒绝;放置后做什么由你决定。
文档正在加载演示…
<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 安全的默认设置。
一个 agent skill,教会 Claude Code 和其他助手这个库是如何工作的。
01
1,243
在三种浏览器中运行的端到端测试
02
469
单元测试
03
21KB
可拖拽元素和放置区,gzip 后
04
9
本文档支持的语言