Docs/Guides
Sortable lists
Lists, tab strips and grids that reorder while you drag — makeSortableList renders a draft, makeSortableItem makes the rows, and the drop writes the new order into your ref. Handles, a drop line, swaps.
Loading the demo…
<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import LayersPanel from './LayersPanel.vue';
// Front to back, as a design tool lists them.
const layers = ref([
{ id: 'title', name: 'Title', kind: 'text' as const, visible: true, locked: false },
{ id: 'badge', name: 'Badge', kind: 'shape' as const, visible: true, locked: false },
{ id: 'photo', name: 'Photo', kind: 'image' as const, visible: true, locked: false },
{ id: 'card', name: 'Card', kind: 'shape' as const, visible: true, locked: true },
]);
</script>
<template>
<DnDProvider>
<div class="editor">
<!-- The artboard reads `layers`: it restacks when a drop writes the new order. -->
<div class="artboard" aria-hidden="true">
<template v-for="(layer, index) in layers" :key="layer.id">
<div v-show="layer.visible" class="shape" :class="layer.id" :style="{ zIndex: layers.length - index }">
<template v-if="layer.id === 'title'">Summer sale</template>
<template v-if="layer.id === 'badge'">-30%</template>
</div>
</template>
</div>
<LayersPanel v-model="layers" />
</div>
</DnDProvider>
</template>
<style scoped>
.editor {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 24px;
}
.artboard {
position: relative;
overflow: hidden;
width: 240px;
height: 170px;
border-radius: 4px;
background: #fff;
box-shadow: 0 10px 30px -12px rgb(0 0 0 / 0.5);
}
.shape {
position: absolute;
}
.card {
inset: 18px;
border-radius: 12px;
background: #1e3a8a;
}
.photo {
top: 34px;
left: 66px;
width: 120px;
height: 84px;
border-radius: 8px;
background:
radial-gradient(circle at 75% 30%, #fde68a 0 12px, transparent 13px),
linear-gradient(170deg, #67e8f9, #0e7490);
}
.badge {
top: 20px;
left: 160px;
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 50%;
background: #facc15;
color: #1e3a8a;
font: 800 13px/1 system-ui, sans-serif;
}
.title {
top: 104px;
left: 30px;
color: #fff;
font: 800 24px/1 system-ui, sans-serif;
letter-spacing: -0.02em;
text-shadow: 0 2px 6px rgb(0 0 0 / 0.35);
}
</style>A list and its rows
A sortable list takes two components: the list, and its row.
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeSortableList } from '@vue-dnd-kit/core';
import LayerRow from './LayerRow.vue';
const layers = defineModel<Layer[]>({ required: true });
const list = useTemplateRef<HTMLElement>('list');
const { items } = makeSortableList(list, { items: layers });
</script>
<template>
<ul ref="list">
<LayerRow v-for="layer in items" :key="layer.id" :layer="layer" />
</ul>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeSortableItem } from '@vue-dnd-kit/core';
const props = defineProps<{ layer: Layer }>();
const el = useTemplateRef<HTMLElement>('el');
makeSortableItem(el, { item: () => props.layer });
</script>
<template>
<li ref="el">{{ layer.name }}</li>
</template>
The row registers with the list it is rendered in. A component may make several lists (two columns, a list inside a list): each row joins the one whose element holds it, the innermost for lists inside lists.
The draft
makeSortableList returns items, a draft of your array. While you drag, the draft
reorders live: the dragged row takes its new slot, and the others slide out of the way.
Your array doesn't change until the drop. Then the library writes the new order into it.
The layers panel above shows both: the panel renders items and moves while you drag;
the artboard reads layers, the array itself, and restacks only when you let go.
- Render
items, not your array. Rendering your array, nothing moves until the drop. - The write happens after the list's
onDrop. InonDrop, your array still holds the old order ande.resultthe new one. layersis a regular ref (here adefineModel, and the write emitsupdate:modelValue), so the rest of your app sees the change like any other.
makeSortableList(list, {
items: layers,
onDrop: (e) => {
console.log(e.from.index, '→', e.to.index, e.result.get(e.to.list));
},
});
computed instead of a ref.
The library won't write a read-only source: call e.preventDefault() and apply e.result
yourself. See controlled lists.Keys
Rows are keyed by their data, never by index or by an automatic id. By default the key is
item.id, or the item itself for strings and numbers.
// LayersPanel.vue: your items have another key
makeSortableList(list, { items: layers, getKey: (layer) => layer.uuid });
// LayerRow.vue: `item` is keyed with the list's getKey…
makeSortableItem(el, { item: () => props.layer });
// …or pass the key yourself
makeSortableItem(el, { id: () => props.layer.uuid });
- Use the same key in
v-for::key="layer.uuid". With:key="index"the wrong rows animate and their state jumps between rows. - Keys must be unique among all the lists that exchange items.
- Pass
itemandidas getters, so a row stays in sync when its props change.
Handles
By default the whole row starts a drag, except the buttons, inputs and other controls in
it: the eye and the lock of a layer stay plain buttons. Pass handle to drag by one part
only:
<script setup lang="ts">
makeSortableItem(el, { item: () => props.layer, handle: '.grip' });
</script>
<template>
<li ref="el">
<button type="button" class="grip" :aria-label="`Move ${layer.name}`">⠿</button>
{{ layer.name }}
</li>
</template>
handleis a selector inside the item, or an element ref.- A mouse drag starts from the handle at once, without the usual 4 px. A press that
doesn't move is still a click: the handle can open a menu on
click. - The handle gets the drag's attributes (
role,tabindex,aria-*), and keyboard drags start from it.
Make the handle a <button type="button"> with an aria-label: it is focusable as is,
and screen readers announce its label. A bare <span> with an icon also works (the
library gives it tabindex="0" and role="button"), but it has no meaningful name.
touch-action: none and the item
activation: { touch: { distance: 4 } }.Strategies
strategy says how the list is laid out, so the rows make room the right way:
strategy | For |
|---|---|
'vertical' (default) | A column of rows. |
'horizontal' | A single row: tabs, chips, a timeline. |
'grid' | CSS grids and wrapping flex rows, with items of different sizes too. |
A row of tabs is a horizontal list:
Loading the demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Browser from './Browser.vue';
</script>
<template>
<DnDProvider>
<Browser />
</DnDProvider>
</template>const { items } = makeSortableList(strip, {
items: tabs,
strategy: 'horizontal',
axis: 'x', // the tabs stay on their row
animateChanges: true, // the buttons open and close tabs: slide the others then too
});
The tab's close button keeps working inside the draggable tab, and a click that ends a
drag doesn't select a tab. Rows slide on their own while you drag; animateChanges adds the same
for changes no drag made, here the buttons (see animations).
A grid sorts in two dimensions, with tiles of any size:
Loading the demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import PhotoGrid from './PhotoGrid.vue';
</script>
<template>
<DnDProvider>
<PhotoGrid />
</DnDProvider>
</template>const { items } = makeSortableList(grid, { items: photos, strategy: 'grid' });
.grid { display: grid; grid-template-columns: repeat(4, 76px); grid-auto-rows: 76px; grid-auto-flow: dense; }
.wide { grid-column: span 2; }
.tall { grid-row: span 2; }
grid-auto-flow: dense keeps the grid packed when a big tile moves. Without it, the
grid leaves holes where a tile no longer fits, and grows a row.
Lock the axis
makeSortableList(list, { items: layers, axis: 'y' });
axis: 'y' keeps the drag on the vertical axis, 'x' on the horizontal one: the layers
and the tabs above use it. An item's own axis wins over its list's. To also keep the
drag inside the list, add the restrictToParent modifier. More in
constraints.
Style the slot
The dragged row stays in the list as the slot it will land in, and gets
data-dnd-placeholder. The default style fades it to 40%, with zero specificity, so any
rule of yours wins:
.layer[data-dnd-placeholder] {
opacity: 1;
box-shadow: inset 0 0 0 1.5px #42b883;
}
.layer[data-dnd-placeholder] > * {
visibility: hidden; /* an empty slot of the row's exact size */
}
makeSortableItem returns the same state as refs, plus a few more:
| Returns | What it is |
|---|---|
isPlaceholder | This row is the slot a dragged item will land in. |
isDragging | This row is being dragged (every dragged row, in a multi-drag). |
index | Its index in the draft: it changes while you drag. |
selected | Writable, bound to the provider's selection. |
indicator | In indicator mode, where the line goes. |
The list returns isOver and isAccepting for its own highlight, and activeKey and
activeIndex, the dragged item and its place in this list's draft. Every state, with
live demos, is in styling.
Turning things off
| Option | What it stops |
|---|---|
disabled on an item | This item can't be dragged. The others still move past it: lock a layer above, then drag another one over it. |
disabled on the list | No drags out of it, no drops into it. |
sort: false on the list | Its items can't be reordered inside it, but can still leave it: a palette, see lists that share items. |
All of them take a getter or a ref, and apply to the next drag:
makeSortableItem(el, { item: () => props.layer, handle: '.grip', disabled: () => props.layer.locked });
A row that must stay first or last, whatever the others do, isn't an item at all: render
it outside the list. The Background layer of the panel sits below the <ul>.
Show some of the items
A search box, a tag: filter shows the items it returns true for, and a drag sorts among
those. The others stay in your array, in their places:
Loading the demo…
<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Backlog from './Backlog.vue';
import type { Task } from './TaskRow.vue';
// In priority order: the array is the backlog.
const tasks = ref<Task[]>([
{ id: 1, title: 'Login fails on Safari', tag: 'bug' },
{ id: 2, title: 'Dark mode for settings', tag: 'feature' },
{ id: 3, title: 'Document the export API', tag: 'docs' },
{ id: 4, title: 'Crash when the list is empty', tag: 'bug' },
{ id: 5, title: 'Keyboard shortcuts', tag: 'feature' },
{ id: 6, title: 'Typo in the pricing page', tag: 'docs' },
{ id: 7, title: 'Slow search with 10k rows', tag: 'bug' },
{ id: 8, title: 'Share a board by link', tag: 'feature' },
]);
</script>
<template>
<DnDProvider>
<Backlog v-model="tasks" />
</DnDProvider>
</template>const query = ref('');
const tag = ref<Tag | null>(null);
const { items } = makeSortableList(list, {
items: tasks,
filter: (task) => (!tag.value || task.tag === tag.value) && task.title.includes(query.value),
});
itemsholds the matching items: render it as usual. The filter is reactive: typing in the search box updates the list at once.- The drop writes the whole array. Each hidden item stays right after the shown item it followed; when that one is the one that moved, it stays after the nearest one before it that didn't.
e.to.indexand the list'sactiveIndexcount the shown items.e.resultholds the whole array, hidden items included.
computed that filters your array and is passed as items works too, but it is read
only: you would apply e.result yourself and work out where the hidden items go. filter
does both.An insertion line
With placeholder: 'indicator', the list stays still while you drag, and a line shows
where the drop goes. You draw the line:
Loading the demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import IssueTable from './IssueTable.vue';
</script>
<template>
<DnDProvider>
<IssueTable />
</DnDProvider>
</template>const { items, indicator } = makeSortableList(body, { items: issues, placeholder: 'indicator' });
<script setup lang="ts">
const { indicator } = makeSortableItem(el, { item: () => props.issue });
</script>
<template>
<div ref="el" class="row" :class="indicator && `drop-${indicator.side}`">…</div>
</template>
<style scoped>
.row.drop-before::before { content: ''; position: absolute; top: -1.5px; left: 6px; right: 6px; height: 2px; background: #42b883; }
.row.drop-after::before { content: ''; position: absolute; bottom: -1.5px; left: 6px; right: 6px; height: 2px; background: #42b883; }
</style>
indicator field | What it holds |
|---|---|
list | The list the drop goes into. |
index | The insertion index in the list as rendered. |
key | The item the line is attached to; null in an empty list (draw it in the list then). |
side | 'before' or 'after' that item; 'swap' in a list that swaps. Trees add 'inside'. |
depth, parentKey | Trees only: where the drop lands. See trees. |
- The list's
indicatoris set while the line is in that list (the table prints it), and an item'sindicatorwhile the line is attached to that item. - The dragged row stays where it was, marked as the placeholder: fade it.
- The keyboard moves the line, too.
- A drop outside every target cancels in this mode (
dropOutside: 'cancel'), as the list never showed another order. - The overlay covers part of the row under it. Make it a little see-through, and the line
shows through:
[data-dnd-overlay] .row { opacity: 0.85 }.
Swap instead of shift
With behavior: 'swap', the dragged item trades places with the one under it, and the
others stay put:
Loading the demo…
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Rotation from './Rotation.vue';
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
const columns = reactive([
{
id: 'primary',
title: 'Primary',
people: [
{ id: 'ava', name: 'Ava', hue: 160 },
{ id: 'ben', name: 'Ben', hue: 20 },
{ id: 'cleo', name: 'Cleo', hue: 280 },
{ id: 'dev', name: 'Dev', hue: 210 },
{ id: 'eli', name: 'Eli', hue: 45 },
],
},
{
id: 'backup',
title: 'Backup',
people: [
{ id: 'fay', name: 'Fay', hue: 330 },
{ id: 'gus', name: 'Gus', hue: 100 },
{ id: 'hana', name: 'Hana', hue: 190 },
{ id: 'ivo', name: 'Ivo', hue: 0 },
{ id: 'jun', name: 'Jun', hue: 250 },
],
},
]);
/** Live: the swap shows while you drag. On drop: nothing moves, the partner is marked. */
const placeholder = ref<'item' | 'indicator'>('item');
const last = ref('');
</script>
<template>
<DnDProvider>
<div class="schedule">
<div class="modes" role="group" aria-label="Swap preview">
<button type="button" :aria-pressed="placeholder === 'item'" @click="placeholder = 'item'">Live</button>
<button type="button" :aria-pressed="placeholder === 'indicator'" @click="placeholder = 'indicator'">On drop</button>
</div>
<div class="week">
<ol class="days">
<li v-for="day in DAYS" :key="day">{{ day }}</li>
</ol>
<Rotation v-for="column in columns" :key="column.id" :column="column" :placeholder="placeholder" @swap="last = $event" />
</div>
<p class="last">{{ last ? `Swapped ${last}` : 'Drag a person onto another' }}</p>
</div>
</DnDProvider>
</template>
<style scoped>
.schedule {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.modes {
display: flex;
padding: 3px;
border-radius: 10px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
.modes button {
padding: 6px 12px;
border: 0;
border-radius: 7px;
background: none;
color: inherit;
font: inherit;
font-size: 12px;
font-weight: 500;
opacity: 0.6;
cursor: pointer;
}
.modes button[aria-pressed='true'] {
background: Canvas;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
opacity: 1;
}
.week {
display: flex;
gap: 10px;
width: 400px;
max-width: 100%;
}
.days {
display: flex;
flex-direction: column;
gap: 6px;
margin: 21px 0 0;
padding: 0;
list-style: none;
}
.days li {
height: 36px;
font: 11px/36px ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
.last {
margin: 0;
font: 12px/1 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.6;
}
</style>const { items } = makeSortableList(list, {
id: props.column.id,
items: toRef(props.column, 'people'),
group: 'rotation',
behavior: 'swap',
placeholder: () => props.placeholder, // 'item' (live) or 'indicator' (on drop)
onDrop: (e) => {
if (e.partner) emit('swap', `${e.items[0]?.name} ⇄ ${e.partner.item.name}`);
},
});
- Live (
placeholder: 'item', default): the swap shows while you drag. Moving back, or off the list, undoes it. - On drop (
placeholder: 'indicator'): nothing moves until the drop. The indicator marks the item the drop exchanges with,side: 'swap': outline that item instead of drawing a line. The arrows move the mark from item to item; back on the dragged item, there is none.
<li ref="el" :class="{ target: indicator?.side === 'swap' }">…</li>
The drop event has mode: 'swap' and partner, the item it traded with: its key,
item, and its own from and to. Two lists of one group swap between them when both
have behavior: 'swap', as the two columns do. The hovered item then moves into the
other list, so it must be allowed out (group.pull) and in (group.put): see
lists that share items. A multi-drag sorts instead of
swapping.
Keyboard
Focus a row, or its handle, and press Space or Enter to pick it up. The arrows along the list move it by one slot, in grids up and down go to the next row, and the arrows across the list move it to the next list of its group. Past the end of a list, the arrow goes on into the next list that way (lists stacked one above another), else onto a drop target there, a trash zone. Press Space or Enter again to drop, Escape to put it back. Every demo on this page works that way. The screen reader texts are in accessibility.
Animations
Rows slide to their new places while you drag, and the dropped row lands on its slot. The
list itself grows or shrinks to its new size as rows come in and leave (animateSize). That
is on by default and needs no code. Don't wrap rows in <TransitionGroup>: moves would
animate twice. Don't put a CSS transition on the rows' transform either. For changes
no drag made (adding, removing, shuffling), turn on the list's animateChanges option, as the
tabs do. See animations.
The full option list is in the makeSortableList and makeSortableItem API.