Документация/Руководства

Стилизация

Каждое состояние драга, со стилями — data-атрибуты для чистого CSS, ref для классов, цели, которые говорят «да» или «нет», оверлей и страница во время драга, стили по умолчанию, Tailwind и UI-киты.

Редактировать страницу

Страница переведена ИИ и может содержать неточности. Читать английский оригинал

Библиотека почти ничего не стилизует. Каждое состояние драга доходит до вас двумя путями: как атрибут data-dnd-* — для чистого CSS, и как ref, который возвращает композабл, — для классов и условной разметки. Используйте то, что подходит вашему стеку, или оба.

Data-атрибуты

У строк ниже нет вообще никаких классов состояния: весь внешний вид берётся из атрибутов. Панель перечисляет атрибуты на странице по мере их появления и исчезновения. Выделите две строки с 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"блок оверлеяВсегда. dropping — пока он приземляется и пока ждёт асинхронный onBeforeDrop.
data-dnd-overlay-contentсодержимое оверлеяВсегда: элемент, который заполняет блок и держит то, что он показывает. Его анимируете вы; он получает overlayClass провайдера.
data-dnd-leavingкопия, оставленная animateChangesПока ушедший элемент растворяется.
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>
  • Копия строки в оверлее сохраняет её классы и атрибуты, включая scope-атрибут Vue, поэтому [data-dnd-overlay] .row работает в собственном <style scoped> строки. У копии нет собственных пометок data-dnd-*: копия слота — не слот.
  • У выделения нет атрибута. Отразите ref selected в атрибут (aria-selected выше) или используйте класс.
  • Обычные перетаскиваемые элементы и цели (makeDraggable, makeDroppable) не получают атрибутов состояния data-dnd-*: стилизуйте их через их ref.

Возвращаемые ref

Те же состояния в виде ref, плюс те, что есть у списков и целей. Каждая строка и список ниже выводят 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: от 0 до 1, пока идёт долгое нажатие касанием или delay. Заполняйте по нему кольцо.

Цели: да и нет

У цели во время драга четыре вида: она принимает драг; принимает, и указатель на ней; отклоняет; отклоняет, и указатель на ней. Покажите все четыре и сообщите об этом и на перетаскиваемом элементе.

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 */

Перетаскиваемый образец цвета — это слот #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 на блоке оверлея и data-dnd-dragging на <html>, "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. На нём работают и переходы, и ключевые кадры: см. анимации.
  • Копия показывает элемент как есть и не угадывает фон. Строке, которую рисует список (<li> в белом <ul>, строка таблицы), он нужен: [data-dnd-overlay] .row { background: Canvas; }.
  • Копия лежит в <div>, который несёт кастомные свойства, унаследованные элементом (цвета темы, --tw-*), так что в оверлее они по-прежнему действуют, а собственные правила копии (in-data-[dnd-overlay]:ring-2) всё так же их перебивают. Выбирайте копию по её классу, а не по глубине в блоке.

Стили по умолчанию

Провайдер внедряет несколько правил один раз на документ. Они обёрнуты в :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 на перетаскиваемых элементах добавляете вы. Задайте --dnd-cursor на <html> для другого курсора по умолчанию.
Без touch calloutiOS не открывает меню ссылки при долгом нажатии.

:default-styles="false" на провайдере их отключает. Библиотека по-прежнему помечает <html>, по-прежнему сбрасывает выделение текста в начале драга и не даёт начать новое до его конца: пропадает только CSS. Правила внедряются один раз, первым провайдером, которому они нужны, и остаются: отключайте их на каждом провайдере страницы.

<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 — более короткая запись варианта для предка.

В scoped CSS Vue :global() забирает весь селектор: :global(html[data-dnd-dragging]) .hint компилируется в один html[data-dnd-dragging] и скрывает страницу. Пишите предка без него, html[data-dnd-dragging] .hint: Vue добавляет scope только к последней части.

UI-киты

  • Композаблы принимают не только элемент, но и компонент: useTemplateRef на карточке или элементе списка из кита работает, и библиотека читает его корневой элемент.
  • Передавайте состояние в собственные пропсы кита там, где они есть (:active="selected", :color="isOver ? 'primary' : undefined"), а остальное — классами.
  • У элементов списков в китах часто нет собственного фона: его рисует список. Их копия в оверлее тогда прозрачна. Задайте ей фон:
    [data-dnd-overlay] .v-list-item { background: rgb(var(--v-theme-surface)); }
    
  • Копия не включает рябь от нажатий Vuetify, Quasar, PrimeVue и Naive UI. Помечайте всё остальное, что не должно ехать с ней, тултип или кнопку меню, атрибутом data-dnd-no-clone.

Чего не делать

  • Не ставьте CSS transition на transform или translate сортируемых элементов: библиотека анимирует их сама и отменяет такие переходы, пока измеряет.
  • Не оборачивайте сортируемые элементы в <TransitionGroup>: перемещения анимировались бы дважды. Используйте animateChanges списка.
  • Не анимируйте height, margin или padding строк в виртуальном списке.
  • А вот переходы, которые вы добавляете вокруг драгов, отключайте при prefers-reduced-motion: reduce. Свои библиотека уже отключает.