Docs/Guides
Lists that share items
Lists in one group move, copy and swap items — the whole column takes drops, pull and put decide what goes in and out, palettes copy, and controlled lists apply each drop to a store with an undo history.
Loading the demo…
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Column from './Column.vue';
const columns = reactive([
{
id: 'backlog',
title: 'Backlog',
stories: [
{ id: 's1', title: 'Saved searches', points: 5 },
{ id: 's2', title: 'Crash on empty board', points: 2, bug: true },
{ id: 's3', title: 'CSV export', points: 3 },
{ id: 's4', title: 'Dark mode email', points: 2 },
],
},
{
id: 'sprint',
title: 'Sprint 14',
stories: [{ id: 's5', title: 'Onboarding tour', points: 8 }],
},
]);
const wholeColumn = ref(true);
</script>
<template>
<DnDProvider>
<div class="planning">
<label class="switch">
<input v-model="wholeColumn" type="checkbox" />
<code>dropzone</code>: the whole column takes drops
</label>
<div class="columns">
<Column v-for="column in columns" :key="column.id" :column="column" :whole-column="wholeColumn" />
</div>
</div>
</DnDProvider>
</template>
<style scoped>
.planning {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.switch {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
cursor: pointer;
}
.switch input {
accent-color: #42b883;
}
.switch code {
font: 12px/1 ui-monospace, Menlo, Consolas, monospace;
color: #42b883;
}
.columns {
display: flex;
justify-content: center;
gap: 14px;
width: 100%;
}
</style>A group
Lists with the same group name exchange items. By default every list is a group of its
own.
<script setup lang="ts">
import { toRef, useTemplateRef } from 'vue';
import { makeSortableList } from '@vue-dnd-kit/core';
import StoryCard from './StoryCard.vue';
type Story = { id: string; title: string; points: number };
const props = defineProps<{ column: { id: string; title: string; stories: Story[] } }>();
const list = useTemplateRef<HTMLElement>('list');
const { items, isOver } = makeSortableList(list, {
id: props.column.id, // names the list in drop events
items: toRef(props.column, 'stories'), // a writable ref into the reactive column
group: 'planning', // same name: stories move between these lists
});
</script>
<template>
<section :class="{ over: isOver }">
<h3>{{ column.title }}</h3>
<ul ref="list">
<StoryCard v-for="story in items" :key="story.id" :story="story" />
</ul>
</section>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import Column from './Column.vue';
const columns = reactive([
{ id: 'backlog', title: 'Backlog', stories: [{ id: 's1', title: 'Saved searches', points: 5 }] },
{ id: 'sprint', title: 'Sprint 14', stories: [] },
]);
</script>
<template>
<Column v-for="column in columns" :key="column.id" :column="column" />
</template>
- Each column is a component here, as columns usually are. One component may also make several lists, a fixed Todo and Done side by side: each card joins the list whose element holds it.
toRef(props.column, 'stories')is writable because the parent passes a reactive object. The drop writes both arrays, the one the story left and the one it entered, and the points of both columns follow.idshows up in drop events asfrom.listandto.list, and keyse.result, the new arrays of every list the drop changes.idandgroupare read once.- Keys must be unique across the whole group.
A list's onDrop runs for the drops into it. A move from backlog to sprint calls
the sprint list's onDrop, and its e.result holds the new arrays of both lists.
The whole column takes drops: dropzone
A short list in a tall column leaves empty space below the last card. With dropzone,
another element takes the drops for the list: here the whole column. A story let go
below the last one lands at the end. The stories stay in the <ul>; only the area that
takes drops grows. The demo outlines that area while you drag: switch it off, and only
the list itself takes the story.
const zone = useTemplateRef<HTMLElement>('zone');
const { items } = makeSortableList(list, {
items: toRef(props.column, 'stories'),
group: 'planning',
dropzone: zone, // or a getter: () => (wholeColumn.value ? zone.value : undefined)
});
dropzone, an empty list still needs a size to take drops: give it a
min-height.What goes in and out: pull and put
group also takes an object. pull says what may leave a list, put what may come in:
Loading the demo…
<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider, type SortablePutContext } from '@vue-dnd-kit/core';
import RuleList from './RuleList.vue';
// Four lists of one group, `files`. What each gives and takes is its `group.pull` and `group.put`.
const shortlist = reactive({
id: 'shortlist',
title: 'Shortlist',
code: 'put: ({ keys }) => shortlist.files.length + keys.length <= 3',
refusal: 'full',
group: { name: 'files', put: ({ keys }: SortablePutContext) => shortlist.files.length + keys.length <= 3 },
files: [
{ id: 'pitch', name: 'pitch.pdf', kind: 'doc' as const },
{ id: 'mock', name: 'mockup.png', kind: 'image' as const },
],
});
const lists = reactive([
{
id: 'inbox',
title: 'Inbox',
code: '{ pull: true, put: false }',
refusal: 'put: false',
group: { name: 'files', pull: true, put: false },
files: [
{ id: 'logo', name: 'logo.svg', kind: 'image' as const },
{ id: 'brief', name: 'brief.pdf', kind: 'doc' as const },
{ id: 'hero', name: 'hero.png', kind: 'image' as const },
],
},
{
id: 'templates',
title: 'Templates',
code: "{ pull: 'clone', put: false }, sort: false",
refusal: 'put: false',
group: { name: 'files', pull: 'clone' as const, put: false },
sort: false,
files: [
{ id: 'invoice', name: 'invoice.doc', kind: 'doc' as const },
{ id: 'banner', name: 'banner.png', kind: 'image' as const },
],
},
{
id: 'images',
title: 'Images',
code: "put: 'image'",
refusal: 'images only',
group: { name: 'files', put: 'image' },
files: [],
},
shortlist,
]);
</script>
<template>
<DnDProvider>
<div class="rules">
<RuleList v-for="list in lists" :key="list.id" :list="list" />
</div>
</DnDProvider>
</template>
<style scoped>
.rules {
display: grid;
grid-template-columns: repeat(2, minmax(0, 260px));
gap: 12px;
justify-content: center;
}
@media (max-width: 480px) {
.rules {
grid-template-columns: minmax(0, 1fr);
}
}
</style>| Option | Values |
|---|---|
pull | true (default): items can leave. false: they can't. 'clone': copies leave, the items stay. |
put | true (default): items can come in. false: nothing comes in. A type or types ('image', ['image', 'video']): items of those types come in. A function: you decide per drag. |
// Inbox: gives, takes nothing.
group: { name: 'files', pull: true, put: false }
// Templates: gives copies, takes nothing, keeps its order.
group: { name: 'files', pull: 'clone', put: false }, sort: false
// Images: takes images only.
group: { name: 'files', put: 'image' }
Types are the type options of the dragged items (makeSortableItem(el, { item, type }));
items without one count as the group's name. With types in put, every dragged item must
have one of them (with acceptMode: 'some', the ones that do come in).
A put function gets { keys, items, types, from, to }: the dragged keys and items,
their types, the list they come from (null for plain draggables) and this list. Moves
inside a list are always allowed: the Inbox still sorts its own files.
Over a list that refuses the drag, the slot goes back to where the drag started, so the
lists show what a drop would do: nothing. A drop there is cancelled and the item flies
back. The list's isAccepting is false during such a drag, and isOver still true
over it: that is how each list in the demo knows what to say.
The slot goes home over a drop target of its own outside the lists too, such as a trash
can: the drop there is the target's, and the lists keep the item unless its onDrop takes
it out (event.source.remove()). Targets inside the lists (a drop into an item) and
around them (a panel holding the lists) leave the slot where it is.
const { items, isOver, isAccepting } = makeSortableList(el, { … });
const { active } = useDnD();
const verdict = computed(() => (active.value ? (isAccepting.value ? 'takes' : 'refuses') : null));
Limits
A list that holds three files at most, the Shortlist:
const shortlist = reactive({
files: [/* … */],
group: { name: 'files', put: ({ keys }: SortablePutContext) => shortlist.files.length + keys.length <= 3 },
});
It reads its own items when a drag asks. Fill it, and it refuses the next file, with the reason in its header.
When several selected items of different types are dragged, acceptMode says whether
'all' (default) or 'some' of them must pass put. With 'some', the ones that pass
land and the others fly back. See multi-drag.
One group per list
A list belongs to one group: group takes one name, never several. The lists of a group
share each drag (their drafts, what is dragged, which of them take it), so items never move
between groups. When items should flow only some ways, put the lists in one group and let
put decide by from, the list a drag comes from:
// Review takes tasks from both teams. A team takes tasks back from Review,
// never from the other team.
const fromReview = ({ from }: SortablePutContext) => from === 'review';
makeSortableList(review, { id: 'review', items: reviewTasks, group: 'tasks' });
makeSortableList(frontend, { id: 'frontend', items: frontendTasks, group: { name: 'tasks', put: fromReview } });
makeSortableList(backend, { id: 'backend', items: backendTasks, group: { name: 'tasks', put: fromReview } });
put: ['a', 'b'] names the groups a list takes items from.
Here an array in put lists the types of the items a list takes. To take items from
other lists, give them one group and decide by from, as above.Copy: palettes and the Alt key
Loading the demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Palette from './Palette.vue';
import Sheet from './Sheet.vue';
</script>
<template>
<!-- The palette and the page are lists of one group: copies go from one to the other. -->
<DnDProvider>
<div class="builder">
<Palette />
<Sheet />
</div>
</DnDProvider>
</template>
<style scoped>
.builder {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 24px;
}
</style>makeSortableList(list, {
items: templates,
group: { name: 'blocks', pull: 'clone', put: false }, // copies go out, nothing comes in
sort: false, // its own order stays
clone: (block) => ({ ...block, id: crypto.randomUUID() }),
});
makeSortableList(list, {
items: blocks,
group: 'blocks',
clone: (block) => ({ ...block, id: crypto.randomUUID() }), // for Alt-drags
dropOutside: 'remove', // drag off the Page panel to delete
});
pull: 'clone'copies every drag out of the list. The original stays, and the copy travels and lands.- Holding Alt while dragging copies too, from any list with a
clonefunction: it makes the copy. Change the key with the provider'scopyModifier, or turn it off withnull. - Either way, the list the item comes from needs
clone. It must return the copy with a new key. Withoutclone, nothing is copied. - The drop event has
mode: 'copy', ande.itemsare the copies. An overlay component getsmodetoo, to show a "+" while copying (see the overlay).
Swap between lists
Two lists of one group that both have behavior: 'swap' exchange items between them: the
dragged item takes the hovered one's place, and the hovered one moves into the dragged
one's list. It must be allowed out of its list (pull: true) and into the other
(put). The on-call rotation in sortable lists
swaps across its two columns.
Plain draggables
A list takes items dragged with makeDraggable (a chip, a template, a file from a tree)
when it has createItem. group.put decides on them too, with from: null. See
plain draggables into a list.
Dropping outside
What happens when a drag ends over no target at all is the list's dropOutside:
dropOutside | The items |
|---|---|
'placeholder' (default) | Land where the slot was last shown. |
'cancel' | Fly back. The default with placeholder: 'indicator'. |
'remove' | Leave their list: drag off to delete. The page of the palette demo does it. |
'remove' asks the list's onBeforeDrop first. Then the list's onDrop runs with
to.index === -1, and e.result holds the list without the items. A trash can that takes
drops works the same way: see trash zones.
Controlled lists
The lists can read a store they don't own: Pinia, server state, immutable data. Pass
items as a getter (or a computed, or a readonly ref): the library never writes it.
Call e.preventDefault() in onDrop and apply e.result yourself:
Loading the demo…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Table from './Table.vue';
import { createSeating } from './store';
const seating = createSeating();
const { state, undo } = seating;
</script>
<template>
<DnDProvider>
<div class="seating">
<div class="tables">
<Table id="a" title="Table A" :seating="seating" />
<Table id="b" title="Table B" :seating="seating" />
</div>
<section class="history">
<header>
<h4>History</h4>
<button type="button" :disabled="!state.history.length" @click="undo">Undo</button>
</header>
<ol>
<li v-for="(entry, index) in state.history" :key="state.history.length - index">{{ entry.label }}</li>
<li v-if="!state.history.length" class="none">Move a guest</li>
</ol>
</section>
</div>
</DnDProvider>
</template>
<style scoped>
.seating {
display: flex;
flex-direction: column;
gap: 12px;
width: 460px;
max-width: 100%;
margin: 0 auto;
}
.tables {
display: flex;
gap: 12px;
}
.history {
padding: 10px 12px;
border-radius: 12px;
background: color-mix(in srgb, currentColor 5%, transparent);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
}
h4 {
margin: 0;
font: 600 11px/1 ui-monospace, Menlo, Consolas, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.55;
}
button {
padding: 5px 12px;
border: 0;
border-radius: 7px;
background: #42b883;
color: #fff;
font: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
button:disabled {
background: color-mix(in srgb, currentColor 12%, transparent);
color: inherit;
opacity: 0.5;
cursor: default;
}
ol {
display: flex;
flex-direction: column;
gap: 4px;
height: 66px;
margin: 8px 0 0;
padding: 0;
overflow-y: auto;
list-style: none;
font: 12px/1.4 ui-monospace, Menlo, Consolas, monospace;
}
li:first-child {
color: #42b883;
}
li.none {
color: inherit;
opacity: 0.5;
}
</style>const { items } = makeSortableList(list, {
id: props.id,
items: () => props.seating.state.tables[props.id]!, // read-only
group: 'seating',
onDrop: (e) => {
e.preventDefault(); // no write
props.seating.apply(e.result, `${e.items[0]?.name} → ${props.title}`);
},
});
const apply = (result: ReadonlyMap<Key, Guest[]>, label: string) => {
const before = [...result.keys()].map((id): [Key, Guest[]] => [id, state.tables[id]!]);
for (const [id, guests] of result) state.tables[id] = guests;
state.history.unshift({ label, before });
};
const undo = () => {
const last = state.history.shift();
for (const [id, guests] of last?.before ?? []) state.tables[id] = guests;
};
e.resultmaps every list the drop changes to its new array: both tables after a move, one after a reorder. They are new arrays, so the store can keep the old ones for undo.- The draft follows the store: apply
resultinonDrop(or before an asynconBeforeDropresolves), and the overlay lands in the new order. preventDefault()skips the write into every list inresult. Applied nowhere, the lists stay as they were and the items fly back.- Development builds warn when a read-only list's drop is left to the library: nothing would change.
To save to a server before the drop lands, or to ask the user first, see drop decisions. All list options are in the makeSortableList API.