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.

Live

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.

LayersPanel.vue
<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>
LayerRow.vue
<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. In onDrop, your array still holds the old order and e.result the new one.
  • layers is a regular ref (here a defineModel, and the write emits update: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));
  },
});
A store, server state or immutable data? Pass a getter or a 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 item and id as 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:

LayerRow.vue
<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>
  • handle is 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 drags start after a 200 ms long press, so the page can still scroll. To drag at once from a handle, give the handle 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:

strategyFor
'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:

Live

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>
Browser.vue
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:

Live

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>
PhotoGrid.vue
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:

LayerRow.vue
.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:

ReturnsWhat it is
isPlaceholderThis row is the slot a dragged item will land in.
isDraggingThis row is being dragged (every dragged row, in a multi-drag).
indexIts index in the draft: it changes while you drag.
selectedWritable, bound to the provider's selection.
indicatorIn 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

OptionWhat it stops
disabled on an itemThis item can't be dragged. The others still move past it: lock a layer above, then drag another one over it.
disabled on the listNo drags out of it, no drops into it.
sort: false on the listIts 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:

LayerRow.vue
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:

Live

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>
Backlog.vue
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),
});
  • items holds 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.index and the list's activeIndex count the shown items. e.result holds the whole array, hidden items included.
A 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:

Live

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>
IssueTable.vue
const { items, indicator } = makeSortableList(body, { items: issues, placeholder: 'indicator' });
IssueRow.vue
<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 fieldWhat it holds
listThe list the drop goes into.
indexThe insertion index in the list as rendered.
keyThe 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, parentKeyTrees only: where the drop lands. See trees.
  • The list's indicator is set while the line is in that list (the table prints it), and an item's indicator while 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:

Live

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>
Rotation.vue
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.
PersonCell.vue
<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.