Docs/Guides

Styling

Every state of a drag, styled — data attributes for plain CSS, refs for classes, targets that say yes or no, the overlay and the page while you drag, the default styles, Tailwind and UI kits.

The library styles almost nothing. Each state of a drag reaches you two ways: as a data-dnd-* attribute, for plain CSS, and as a ref the composable returns, for classes and conditional markup. Use whichever suits your stack, or both.

The data attributes

The rows below have no state classes at all: every look comes from an attribute. The panel lists the attributes on the page as they come and go. Select two rows with Ctrl or Meta and drag them: the others of a multi-drag show up too.

Live

Loading the demo…

<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>
AttributeOnWhen
data-dnd-placeholdera sortable itemIt is the slot a dragged item will land in. "into" while the drag is over a drop target inside an item: the drop goes there, and the slot only holds its place.
data-dnd-dragginga sortable itemIt is dragged but stays in place: the other items of a multi-drag in 'gaps' mode.
data-dnd-dragging="pointer" or "keyboard"<html>For the whole drag.
data-dnd-overlay="dragging" or "dropping"the overlay boxAlways. dropping while it lands, and while an async onBeforeDrop waits.
data-dnd-overlay-contentthe overlay's contentAlways: the element that fills the box and holds what it shows. Yours to animate; it takes the provider's overlayClass.
data-dnd-leavinga copy left by animateChangesWhile an item that left fades out.
data-dnd-no-cloneset by youLeaves the element out of the overlay's copy: a tooltip, a menu, a badge.
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>
  • The overlay's copy of a row keeps its classes and attributes, Vue's scope attribute included, so [data-dnd-overlay] .row works in the row's own <style scoped>. The copy has no data-dnd-* marks of its own: a copy of the slot is not a slot.
  • Selection has no attribute. Mirror the selected ref into one (aria-selected above), or use a class.
  • Plain draggables and drop targets (makeDraggable, makeDroppable) get no data-dnd-* state attributes: style them with their refs.

The returned refs

The same states as refs, plus the ones the lists and targets have. Each row and list below prints the refs that are true right now, and its classes come from them. The Icebox refuses urgent tasks: drag one over it.

Live

Loading the demo…

<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>
ComposableState
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: this element is the slot a dragged item will land in. In a multi-drag in 'group' mode, the slots of the items travelling along are placeholders too.
  • isDragging: the item is part of the drag: every dragged item in a multi-drag.
  • isAccepting: something is being dragged, and this target takes it. Light up the places where a drop is possible.
  • isOver: the drag is over this target, whether it takes it or not. isOver && !isAccepting is a refusal: show it.
  • indicator: where the drop line goes in indicator mode.
  • activation.progress: from 0 to 1 while a touch long press or a delay runs. Fill a ring with it.

Drop targets: yes and no

A target has four looks while a drag runs: it takes the drag, it takes it and the pointer is on it, it refuses it, it refuses it and the pointer is on it. Show all four, and say it on the dragged item too.

Live

Loading the demo…

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

The dragged swatch is the provider's #overlay slot. It gets over and accepted: accepted is false over a target that refuses the drag.

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>

Outside the overlay, useDnD().over.value?.accepted says the same. More on overlay components in the overlay.

The overlay and the page

Two attributes cover the rest of a drag: data-dnd-overlay on the overlay box, and data-dnd-dragging on <html>, "pointer" or "keyboard". The bar above the notes reads the second one, and says which keys work. Turn on the slow save: the overlay stays dropping where you let go, until onBeforeDrop resolves, and shows it.

Live

Loading the demo…

<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 covers the landing and the wait of an async onBeforeDrop. While it waits, <html> is out of drag mode: the page's cursor and text selection are back, so a confirmation dialog works as usual.
  • Style what is inside the box. The library moves the box itself with transform and sizes it with width and height. Its content element, [data-dnd-overlay-content], fills it and is yours: lift and tilt the whole overlay there (see animations).
  • The copy's root has transform: none inline: turn or grow it with rotate and scale. Transitions and keyframes both run on it: see animations.
  • The copy shows the element as it is and guesses no background. A row the list paints (an <li> in a white <ul>, a table row) needs one: [data-dnd-overlay] .row { background: Canvas; }.
  • The copy sits in a <div> that carries the custom properties the element inherited (theme colors, --tw-*), so they still apply in the overlay, and the copy's own rules (in-data-[dnd-overlay]:ring-2) still beat them. Select the copy by its class, not by its depth in the box.

Default styles

The provider injects a few rules once per document. They are wrapped in :where(), so their specificity is zero and any rule of yours wins:

: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;
}
RuleWhy
The slot at 40%So a list looks right with no CSS at all. Every demo above overrides it.
Items left behind at 40%The same, for a multi-drag in 'gaps' mode.
No user-selectA drag doesn't select the text it passes over.
No overflow-anchorThe page doesn't scroll to keep a moving row in view.
The drag's cursorDuring a pointer drag, everywhere. grabbing, unless the grabbed element has a cursor of its own: a resize handle's ns-resize, a move, an image keep theirs for the whole drag (grab, pointer and the like still turn into grabbing). cursor: grab on draggable items is yours to add. Set --dnd-cursor on <html> for another default.
No touch calloutiOS doesn't open the link menu under a long press.

:default-styles="false" on the provider leaves them out. The library still marks <html>, and still clears the text selection when a drag starts and blocks new selections until it ends: only the CSS is gone. The rules are injected once, by the first provider that wants them, and stay: turn them off on every provider of the page.

<DnDProvider :default-styles="false">

Tailwind CSS

The attributes work as variants: data-[…] for the element itself, an arbitrary variant for an ancestor.

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>
The library's looks (the 40% opacities) sit in a cascade layer of their own, vue-dnd-kit, declared before any other. Tailwind v4's utilities and the UI kits built on it live in layers too, and theirs come later, so they win without !. Unlayered CSS wins anyway.

In Tailwind v4, in-data-[dnd-overlay=dragging]:shadow-xl is a shorter way to write the ancestor variant.

In Vue's scoped CSS, :global() takes the whole selector: :global(html[data-dnd-dragging]) .hint compiles to html[data-dnd-dragging] alone, and hides the page. Write the ancestor without it, html[data-dnd-dragging] .hint: Vue scopes only the last part.

UI kits

  • The composables take a component as well as an element: useTemplateRef on a kit's card or list item works, and the library reads its root element.
  • Pass state to the kit's own props where it has them (:active="selected", :color="isOver ? 'primary' : undefined"), and the rest as classes.
  • A kit's list items often have no background of their own: the list paints it. Their copy in the overlay is then see-through. Give it one:
    [data-dnd-overlay] .v-list-item { background: rgb(var(--v-theme-surface)); }
    
  • The copy leaves out the press ripples of Vuetify, Quasar, PrimeVue and Naive UI. Mark anything else that shouldn't travel, a tooltip or a menu button, with data-dnd-no-clone.

Don't

  • Don't put a CSS transition on transform or translate of sortable items: the library animates them itself, and cancels such transitions while it measures.
  • Don't wrap sortable items in <TransitionGroup>: moves would animate twice. Use the list's animateChanges.
  • Don't animate height, margin or padding of rows in a virtual list.
  • Do stop the transitions you add around drags under prefers-reduced-motion: reduce. The library already stops its own.