库几乎不设置任何样式。拖拽的每种状态都以两种方式提供给你:一种是 data-dnd-* 属性,用于纯 CSS;另一种是组合式函数返回的 ref,用于 class 和条件渲染的标记。按你的技术栈选择其一,或两者并用。
data 属性
下面的行完全没有状态 class:所有外观都来自属性。面板会列出页面上随时出现和消失的属性。按住 Ctrl 或 Meta 选中两行并拖动它们:多项拖拽中的其他项也会出现。
正在加载演示…
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Inspector from './Inspector.vue';
import TaskList from './TaskList.vue';
const demo = useTemplateRef<HTMLElement>('demo');
</script>
<template>
<DnDProvider>
<div ref="demo" class="demo">
<TaskList />
<Inspector :scope="demo" />
</div>
</DnDProvider>
</template>
<style scoped>
.demo {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
}
</style>| 属性 | 位于 | 时机 |
|---|---|---|
data-dnd-placeholder | 可排序项 | 它是被拖拽项将落入的位置。拖拽位于项内部的放置目标上方时为 "into":放置会落到那个目标里,空位只是占住它的位置。 |
data-dnd-dragging | 可排序项 | 它被拖拽但留在原处:'gaps' 模式下多项拖拽中的其他项。 |
data-dnd-dragging="pointer" 或 "keyboard" | <html> | 整个拖拽期间。 |
data-dnd-overlay="dragging" 或 "dropping" | 浮层盒子 | 始终存在。落位期间以及异步 onBeforeDrop 等待期间为 dropping。 |
data-dnd-overlay-content | 浮层的内容 | 始终存在:填满盒子、承载它所显示内容的元素。动画由你来加;它接收 provider 的 overlayClass。 |
data-dnd-leaving | animateChanges 留下的副本 | 离开的项淡出期间。 |
data-dnd-no-clone | 由你设置 | 把该元素排除在浮层的副本之外:提示框、菜单、徽标。 |
/* The slot a dragged row will land in: a striped gap. */
.row[data-dnd-placeholder] {
opacity: 1;
background: repeating-linear-gradient(-45deg, color-mix(in srgb, #42b883 16%, transparent) 0 6px, transparent 6px 12px);
}
.row[data-dnd-placeholder] > * {
visibility: hidden; /* an empty slot of the row's exact size */
}
/* Selected rows left in place by a multi-drag (`multiple: 'gaps'`). */
.row[data-dnd-dragging] {
opacity: 0.4;
}
/* The copy in the overlay, while it travels. */
[data-dnd-overlay='dragging'] .row {
box-shadow: 0 0 0 1.5px #42b883, 0 14px 24px -10px rgb(0 0 0 / 0.55);
}
<li ref="el" class="row" :aria-selected="selected">
<span class="title">{{ task.title }}</span>
<button type="button" data-dnd-no-clone @click.stop="task.done = !task.done">✓</button>
</li>
- 浮层中行的副本保留它的 class 和属性,包括 Vue 的作用域属性,所以
[data-dnd-overlay] .row写在行自己的<style scoped>中也有效。副本自身没有data-dnd-*标记:空位的副本不是空位。 - 选中状态没有对应的属性。把
selectedref 映射到某个属性上(上面的aria-selected),或使用 class。 - 普通可拖拽元素和放置目标(
makeDraggable、makeDroppable)没有data-dnd-*状态属性:用它们的 ref 设置样式。
返回的 ref
同样的状态以 ref 的形式提供,外加列表和目标特有的状态。下面的每一行和每个列表都会打印出当前为 true 的 ref,它们的 class 也来自这些 ref。Icebox 拒绝紧急任务:拖一个到它上面试试。
正在加载演示…
<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider, type SortablePutContext } from '@vue-dnd-kit/core';
import RefList from './RefList.vue';
const lists = reactive([
{
id: 'sprint',
title: 'Sprint',
group: { name: 'work' },
tasks: [
{ id: 'login', title: 'Fix the login', urgent: true },
{ id: 'search', title: 'Search filters' },
{ id: 'avatars', title: 'Upload avatars' },
{ id: 'invoices', title: 'PDF invoices' },
],
},
{
id: 'icebox',
title: 'Icebox',
// Urgent tasks can't go on ice.
group: { name: 'work', put: ({ types }: SortablePutContext) => !types.includes('urgent') },
tasks: [{ id: 'themes', title: 'Custom themes' }],
},
]);
</script>
<template>
<DnDProvider>
<div class="lists">
<RefList v-for="list in lists" :key="list.id" :list="list" />
</div>
</DnDProvider>
</template>
<style scoped>
.lists {
display: flex;
justify-content: center;
gap: 12px;
}
</style><script setup lang="ts">
const { isDragging, isPlaceholder, selected } = makeSortableItem(el, { item: () => props.task });
</script>
<template>
<li ref="el" class="row" :class="{ placeholder: isPlaceholder, dragging: isDragging, selected }">…</li>
</template>
<style scoped>
.row.selected { box-shadow: 0 0 0 1.5px #3b82f6; }
.row.placeholder { opacity: 1; box-shadow: inset 0 0 0 1.5px #42b883; } /* beats the default 40% */
.row.placeholder .title { visibility: hidden; }
</style>
<script setup lang="ts">
const { items, isOver, isAccepting } = makeSortableList(el, { items, group });
</script>
<template>
<section :class="{ accepting: isAccepting, over: isOver && isAccepting, refused: isOver && !isAccepting }">…</section>
</template>
| 组合式函数 | 状态 |
|---|---|
makeSortableItem | isDragging、isPlaceholder、selected、indicator、index |
makeSortableList、makeSortableTree | isOver、isAccepting、indicator、activeKey、activeIndex |
makeDraggable | isDragging、selected、transform |
makeDroppable | isOver、isAccepting、placement |
makeDragArea | isDragging、isAutoScrolling |
useDnD() | status、active、over、pointer、activation.progress、isAutoScrolling |
isPlaceholder:此元素是被拖拽项将落入的位置。在'group'模式的多项拖拽中,随之移动的项的位置也是占位。isDragging:该项是拖拽的一部分:多项拖拽中的每个被拖拽项都是。isAccepting:有东西正被拖拽,且此目标接收它。点亮可以放置的地方。isOver:拖拽位于此目标上方,无论它是否接收。isOver && !isAccepting表示拒绝:把它显示出来。indicator:在指示器模式下插入线的位置。activation.progress:触摸长按或delay进行期间从 0 到 1。用它填充一个圆环。
放置目标:接收与拒绝
拖拽进行时,目标有四种外观:接收该拖拽;接收且指针在它上方;拒绝该拖拽;拒绝且指针在它上方。把四种都显示出来,并在被拖拽的项上也表明这一点。
正在加载演示…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Palette from './Palette.vue';
import Swatch from './Swatch.vue';
const SWATCHES = [
{ id: 'orange', color: '#f97316', tone: 'warm' as const },
{ id: 'blue', color: '#3b82f6', tone: 'cool' as const },
{ id: 'stone', color: '#a8a29e', tone: 'neutral' as const },
{ id: 'red', color: '#ef4444', tone: 'warm' as const },
{ id: 'cyan', color: '#06b6d4', tone: 'cool' as const },
{ id: 'slate', color: '#475569', tone: 'neutral' as const },
{ id: 'amber', color: '#eab308', tone: 'warm' as const },
{ id: 'violet', color: '#8b5cf6', tone: 'cool' as const },
];
</script>
<template>
<DnDProvider>
<div class="studio">
<div class="swatches">
<Swatch v-for="swatch in SWATCHES" :key="swatch.id" :swatch="swatch" />
</div>
<div class="palettes">
<Palette tone="warm" />
<Palette tone="cool" />
<Palette tone="neutral" />
</div>
</div>
<!-- The dragged swatch says it too: over a palette, `accepted` tells yes from no. -->
<template #overlay="{ active, over, accepted }">
<div class="lifted" :class="{ yes: over && accepted, no: over && !accepted }" :style="{ background: (active.data as { color: string }).color }">
<span v-if="over" class="badge">{{ accepted ? '+' : '✕' }}</span>
</div>
</template>
</DnDProvider>
</template>
<style scoped>
.studio {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
}
.swatches {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.palettes {
display: flex;
justify-content: center;
gap: 10px;
width: 100%;
}
/* Fills the overlay box, which has the swatch's size. */
.lifted {
position: relative;
width: 100%;
height: 100%;
border-radius: 10px;
box-shadow: 0 12px 22px -8px rgb(0 0 0 / 0.55);
transition:
filter 0.15s,
scale 0.15s;
}
.lifted.yes {
scale: 1.12;
}
.lifted.no {
filter: grayscale(1) brightness(0.7);
}
.badge {
position: absolute;
top: -7px;
right: -7px;
display: grid;
place-items: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: #42b883;
color: #fff;
font: 700 12px/1 system-ui, sans-serif;
}
.no .badge {
background: #f43f5e;
font-size: 9px;
}
</style>const { isOver, isAccepting } = makeDroppable(el, { accept: props.tone, onDrop });
const { active } = useDnD();
const state = computed(() => {
if (!active.value) return '';
if (isOver.value) return isAccepting.value ? 'over' : 'refused';
return isAccepting.value ? 'accepting' : 'refusing';
});
.palette.accepting { border-style: dashed; border-color: #42b883; } /* an invitation */
.palette.over { border-color: #42b883; background: color-mix(in srgb, #42b883 14%, Canvas); }
.palette.refusing { opacity: 0.45; } /* out of the way */
.palette.refused { border-color: #f43f5e; animation: shake 0.3s; } /* a drop here is cancelled */
被拖拽的色块是 provider 的 #overlay 插槽。它会收到 over 和 accepted:在拒绝该拖拽的目标上方时,accepted 为 false。
<template #overlay="{ active, over, accepted }">
<div
class="lifted"
:class="{ yes: over && accepted, no: over && !accepted }"
:style="{ background: (active.data as { color: string }).color }"
>
<span v-if="over" class="badge">{{ accepted ? '+' : '✕' }}</span>
</div>
</template>
在浮层之外,useDnD().over.value?.accepted 表达的是同样的信息。更多关于浮层组件的内容见浮层。
浮层与页面
两个属性涵盖了拖拽的其余部分:浮层盒子上的 data-dnd-overlay,以及 <html> 上的 data-dnd-dragging,值为 "pointer" 或 "keyboard"。便签上方的栏读取第二个属性,并说明哪些按键可用。开启慢速保存:浮层会在你松开的地方保持 dropping,直到 onBeforeDrop 完成,并把这一点显示出来。
正在加载演示…
<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import NoteBoard from './NoteBoard.vue';
const slowSave = ref(false);
</script>
<template>
<DnDProvider>
<div class="desk">
<!-- `<html data-dnd-dragging="pointer | keyboard">` for the whole drag: the bar reads it in CSS. -->
<p class="bar">
<span class="idle">Drag a note to reorder</span>
<span class="pointer">Release to drop · Esc cancels</span>
<span class="keyboard">Arrows move · Space drops · Esc cancels</span>
</p>
<NoteBoard :slow-save="slowSave" />
<label class="switch">
<input v-model="slowSave" type="checkbox" />
Save on drop (1 s)
</label>
</div>
</DnDProvider>
</template>
<style scoped>
.desk {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.bar {
margin: 0;
padding: 7px 14px;
border-radius: 99px;
background: color-mix(in srgb, currentColor 7%, transparent);
font: 12px/1 ui-monospace, Menlo, Consolas, monospace;
transition:
background 0.2s,
color 0.2s;
}
.bar .pointer,
.bar .keyboard,
html[data-dnd-dragging] .bar .idle {
display: none;
}
html[data-dnd-dragging] .bar {
background: #42b883;
color: #fff;
}
html[data-dnd-dragging='pointer'] .bar .pointer,
html[data-dnd-dragging='keyboard'] .bar .keyboard {
display: inline;
}
.switch {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
cursor: pointer;
}
.switch input {
accent-color: #42b883;
}
</style>.bar .pointer,
.bar .keyboard,
html[data-dnd-dragging] .bar .idle { display: none; }
html[data-dnd-dragging='pointer'] .bar .pointer,
html[data-dnd-dragging='keyboard'] .bar .keyboard { display: inline; }
[data-dnd-overlay='dragging'] .note {
box-shadow: 0 0 0 2px #42b883, 0 22px 30px -14px rgb(0 0 0 / 0.6);
}
/* Landing, and waiting for the save. */
[data-dnd-overlay='dropping'] .note { opacity: 0.85; }
[data-dnd-overlay='dropping'] .saving { display: flex; }
dropping涵盖落位以及异步onBeforeDrop的等待。等待期间,<html>退出拖拽模式:页面的光标和文本选择恢复,所以确认对话框照常工作。- 为盒子内部的内容设置样式。盒子本身由库通过
transform移动,并通过width和height设置尺寸。它的内容元素[data-dnd-overlay-content]填满盒子,归你使用:在这里抬起和倾斜整个浮层(见动画)。 - 副本的根元素内联了
transform: none:用rotate和scale旋转或放大它。过渡和关键帧都能作用于它:见动画。 - 副本按元素本来的样子显示,不会猜测背景。由列表绘制背景的行(白色
<ul>中的<li>、表格行)需要一个背景:[data-dnd-overlay] .row { background: Canvas; }。 - 副本位于一个
<div>中,这个<div>带有元素继承的自定义属性(主题颜色、--tw-*),所以它们在浮层中仍然生效,而副本自己的规则(in-data-[dnd-overlay]:ring-2)仍然优先于它们。按 class 选择副本,而不是按它在盒子中的层级深度。
默认样式
provider 会在每个文档中注入一次几条规则。它们被包在 :where() 中,所以优先级为零,你的任何规则都会胜出:
:where([data-dnd-placeholder]) { opacity: 0.4; }
:where([data-dnd-dragging]:not(html)) { opacity: 0.4; }
:where(html[data-dnd-dragging]), :where(html[data-dnd-dragging] *) {
user-select: none !important;
overflow-anchor: none !important;
}
:where(html[data-dnd-dragging='pointer']), :where(html[data-dnd-dragging='pointer'] *) {
cursor: var(--dnd-cursor, grabbing) !important;
-webkit-touch-callout: none !important;
}
| 规则 | 原因 |
|---|---|
| 空位为 40% | 这样即使完全不写 CSS,列表看起来也正常。上面的每个演示都覆盖了它。 |
| 留下的项为 40% | 同上,用于 'gaps' 模式的多项拖拽。 |
禁用 user-select | 拖拽不会选中它经过的文本。 |
禁用 overflow-anchor | 页面不会为了让移动中的行保持可见而滚动。 |
| 拖拽光标 | 指针拖拽期间,在所有地方。默认为 grabbing,除非抓起的元素有自己的光标:调整大小手柄的 ns-resize、move、图片会在整个拖拽期间保留自己的光标(grab、pointer 之类的仍会变成 grabbing)。可拖拽项上的 cursor: grab 由你自己添加。在 <html> 上设置 --dnd-cursor 可以换一个默认光标。 |
| 禁用触摸标注 | iOS 不会在长按时打开链接菜单。 |
在 provider 上设置 :default-styles="false" 会省去这些规则。库仍会标记 <html>,仍会在拖拽开始时清除文本选择,并在拖拽结束前阻止新的选择:只是去掉了 CSS。这些规则由第一个需要它们的 provider 注入一次,之后一直保留:要关闭它们,需要在页面的每个 provider 上都关闭。
<DnDProvider :default-styles="false">
Tailwind CSS
这些属性可以作为变体使用:元素本身用 data-[…],祖先用任意变体。
<li
ref="el"
class="rounded-lg bg-white shadow-sm
data-[dnd-placeholder]:bg-transparent data-[dnd-placeholder]:opacity-100
data-[dnd-placeholder]:outline-dashed
[[data-dnd-overlay=dragging]_&]:shadow-xl"
:class="{ 'ring-2 ring-emerald-500': selected }"
>
<p class="hidden [html[data-dnd-dragging=keyboard]_&]:block">Arrows move · Space drops</p>
vue-dnd-kit 中,该层在所有其他层之前声明。Tailwind v4 的工具类以及基于它构建的 UI 库也位于级联层中,而它们的层排在后面,所以无需 ! 就能胜出。不在级联层中的 CSS 无论如何都会胜出。在 Tailwind v4 中,in-data-[dnd-overlay=dragging]:shadow-xl 是祖先变体的更简短写法。
:global() 会作用于整个选择器::global(html[data-dnd-dragging]) .hint 只会编译成 html[data-dnd-dragging],结果把整个页面隐藏了。祖先不要用它包裹,写成 html[data-dnd-dragging] .hint:Vue 只给最后一部分加作用域。UI 库
- 组合式函数既接受元素也接受组件:对 UI 库的卡片或列表项使用
useTemplateRef是可行的,库会读取它的根元素。 - 在 UI 库有对应 props 的地方,把状态传给它们(
:active="selected"、:color="isOver ? 'primary' : undefined"),其余的作为 class 传入。 - UI 库的列表项通常自身没有背景:由列表绘制背景。这样它们在浮层中的副本就是透明的。给它一个背景:
[data-dnd-overlay] .v-list-item { background: rgb(var(--v-theme-surface)); } - 副本会略去 Vuetify、Quasar、PrimeVue 和 Naive UI 的按下涟漪。其他不应随之移动的东西,比如提示框或菜单按钮,用
data-dnd-no-clone标记。
不要这样做
- 不要给可排序项的
transform或translate加 CSStransition:库会自己为它们添加动画,并在测量时取消这类过渡。 - 不要用
<TransitionGroup>包裹可排序项:移动会被动画两次。使用列表的animateChanges。 - 不要为虚拟列表中行的
height、margin或padding添加动画。 - 在
prefers-reduced-motion: reduce下,务必停止你在拖拽相关处添加的过渡。库已经停止了它自己的过渡。