文档/指南

样式

为拖拽的每一种状态设置样式——用于纯 CSS 的 data 属性、用于 class 的 ref、表明接收与否的目标、拖拽期间的浮层和页面、默认样式、Tailwind 以及 UI 库。

编辑此页

本页由 AI 翻译,可能存在不准确之处。 阅读英文原文

库几乎不设置任何样式。拖拽的每种状态都以两种方式提供给你:一种是 data-dnd-* 属性,用于纯 CSS;另一种是组合式函数返回的 ref,用于 class 和条件渲染的标记。按你的技术栈选择其一,或两者并用。

data 属性

下面的行完全没有状态 class:所有外观都来自属性。面板会列出页面上随时出现和消失的属性。按住 Ctrl 或 Meta 选中两行并拖动它们:多项拖拽中的其他项也会出现。

Live

正在加载演示…

<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-leavinganimateChanges 留下的副本离开的项淡出期间。
data-dnd-no-clone由你设置把该元素排除在浮层的副本之外:提示框、菜单、徽标。
TaskRow.vue
/* 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);
}
TaskRow.vue
<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-* 标记:空位的副本不是空位。
  • 选中状态没有对应的属性。把 selected ref 映射到某个属性上(上面的 aria-selected),或使用 class。
  • 普通可拖拽元素和放置目标(makeDraggable、makeDroppable)没有 data-dnd-* 状态属性:用它们的 ref 设置样式。

返回的 ref

同样的状态以 ref 的形式提供,外加列表和目标特有的状态。下面的每一行和每个列表都会打印出当前为 true 的 ref,它们的 class 也来自这些 ref。Icebox 拒绝紧急任务:拖一个到它上面试试。

Live

正在加载演示…

<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>
RefRow.vue
<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>
RefList.vue
<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>
组合式函数状态
makeSortableItemisDragging、isPlaceholder、selected、indicator、index
makeSortableList、makeSortableTreeisOver、isAccepting、indicator、activeKey、activeIndex
makeDraggableisDragging、selected、transform
makeDroppableisOver、isAccepting、placement
makeDragAreaisDragging、isAutoScrolling
useDnD()status、active、over、pointer、activation.progress、isAutoScrolling
  • isPlaceholder:此元素是被拖拽项将落入的位置。在 'group' 模式的多项拖拽中,随之移动的项的位置也是占位。
  • isDragging:该项是拖拽的一部分:多项拖拽中的每个被拖拽项都是。
  • isAccepting:有东西正被拖拽,且此目标接收它。点亮可以放置的地方。
  • isOver:拖拽位于此目标上方,无论它是否接收。isOver && !isAccepting 表示拒绝:把它显示出来。
  • indicator:在指示器模式下插入线的位置。
  • activation.progress:触摸长按或 delay 进行期间从 0 到 1。用它填充一个圆环。

放置目标:接收与拒绝

拖拽进行时,目标有四种外观:接收该拖拽;接收且指针在它上方;拒绝该拖拽;拒绝且指针在它上方。把四种都显示出来,并在被拖拽的项上也表明这一点。

Live

正在加载演示…

<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>
Palette.vue
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.vue
.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。

index.vue
<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 完成,并把这一点显示出来。

Live

正在加载演示…

<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>
index.vue
.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; }
StickyNote.vue
[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-[…],祖先用任意变体。

TaskRow.vue
<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 }"
>
Hint.vue
<p class="hidden [html[data-dnd-dragging=keyboard]_&]:block">Arrows move · Space drops</p>
库的外观(40% 的透明度)位于它自己的级联层 vue-dnd-kit 中,该层在所有其他层之前声明。Tailwind v4 的工具类以及基于它构建的 UI 库也位于级联层中,而它们的层排在后面,所以无需 ! 就能胜出。不在级联层中的 CSS 无论如何都会胜出。

在 Tailwind v4 中,in-data-[dnd-overlay=dragging]:shadow-xl 是祖先变体的更简短写法。

在 Vue 的 scoped CSS 中,: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 加 CSS transition:库会自己为它们添加动画,并在测量时取消这类过渡。
  • 不要用 <TransitionGroup> 包裹可排序项:移动会被动画两次。使用列表的 animateChanges。
  • 不要为虚拟列表中行的 height、margin 或 padding 添加动画。
  • 在 prefers-reduced-motion: reduce 下,务必停止你在拖拽相关处添加的过渡。库已经停止了它自己的过渡。