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.
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>| Attribute | On | When |
|---|---|---|
data-dnd-placeholder | a sortable item | It 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-dragging | a sortable item | It 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 box | Always. dropping while it lands, and while an async onBeforeDrop waits. |
data-dnd-overlay-content | the overlay's content | Always: the element that fills the box and holds what it shows. Yours to animate; it takes the provider's overlayClass. |
data-dnd-leaving | a copy left by animateChanges | While an item that left fades out. |
data-dnd-no-clone | set by you | Leaves the element out of the overlay's copy: a tooltip, a menu, a badge. |
/* 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>
- The overlay's copy of a row keeps its classes and attributes, Vue's scope attribute
included, so
[data-dnd-overlay] .rowworks in the row's own<style scoped>. The copy has nodata-dnd-*marks of its own: a copy of the slot is not a slot. - Selection has no attribute. Mirror the
selectedref into one (aria-selectedabove), or use a class. - Plain draggables and drop targets (
makeDraggable,makeDroppable) get nodata-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.
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><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>
| Composable | State |
|---|---|
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: 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 && !isAcceptingis 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 adelayruns. 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.
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>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 */
The dragged swatch is the provider's #overlay slot. It gets over and accepted:
accepted is false over a target that refuses the drag.
<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.
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>.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; }
droppingcovers the landing and the wait of an asynconBeforeDrop. 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
transformand sizes it withwidthandheight. 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: noneinline: turn or grow it withrotateandscale. 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;
}
| Rule | Why |
|---|---|
| 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-select | A drag doesn't select the text it passes over. |
No overflow-anchor | The page doesn't scroll to keep a moving row in view. |
| The drag's cursor | During 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 callout | iOS 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.
<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, 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.
: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:
useTemplateRefon 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
transitionontransformortranslateof 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'sanimateChanges. - Don't animate
height,marginorpaddingof rows in a virtual list. - Do stop the transitions you add around drags under
prefers-reduced-motion: reduce. The library already stops its own.