Animé
Du FLIP pendant le tri, un dépôt qui atterrit sur son emplacement, des changements de données animés. Utilisez GSAP ou motion si vous le souhaitez.
Des tableaux qui s'échangent des cartes
Imbriquer d'un léger décalage
Plusieurs items, un seul geste
Des listes qui se trient toutes seules
Le mouvement par défaut
Le clavier, citoyen de première classe
Dix mille lignes
Faites glisser les cartes — ou atteignez-en une avec Tab et utilisez les flèches
Listes triables, tableaux kanban, arbres et multi-drag pour Vue 3 — animés, utilisables au clavier et accessibles d'emblée.
Libre et open source. Sous licence MIT.
00Comment ça marche
Votre balisage, vos styles. Passez une ref de template à un composable, et l'élément glisse, se trie et se dépose — l'animation, le clavier et le lecteur d'écran étant pris en charge pour vous.
makeSortableListListes, grilles et colonnes de kanban qui se réordonnent pendant le glissement — et s'échangent des items au sein d'un groupe.makeSortableTreeDes données imbriquées, rendues à plat : la profondeur par un léger décalage de côté, des dépôts dans les dossiers, la virtualisation.makeDraggable · makeDroppableTout le reste : tableaux, canevas, envois de fichiers — vos propres règles pour décider de ce qui va où.01Glisser-déposer
Un élément déplaçable, une cible de dépôt et une règle pour décider de ce qui va où. Les cibles s'illuminent quand elles acceptent le glissement et refusent ce qu'elles n'acceptent pas ; ce que fait un dépôt, c'est vous qui le décidez.
DocumentationChargement de la démo…
<script setup lang="ts">
import { computed, ref, useTemplateRef, watch } from 'vue';
import { makeDroppable, useDnD, type Key } from '@vue-dnd-kit/core';
import FileCard from './FileCard.vue';
import type { DeskFile } from './types';
const props = defineProps<{ id: string; name: string; accept: string; files: DeskFile[]; desktop?: boolean }>();
const emit = defineEmits<{ drop: [id: Key, place: string] }>();
const el = useTemplateRef<HTMLElement>('el');
// `isAccepting`: something is being dragged and this folder takes it.
const { isOver, isAccepting } = makeDroppable(el, {
id: props.id,
accept: props.accept,
onDrop: (event) => emit('drop', event.active.id, props.id),
});
// Over it, but not its kind of file.
const { status } = useDnD();
const refused = computed(() => isOver.value && !isAccepting.value && status.value === 'dragging');
// A short pulse when a file comes in.
const received = ref(false);
watch(
() => props.files.length,
(count, before) => {
if (count <= before) return;
received.value = false;
requestAnimationFrame(() => (received.value = true));
},
);
</script>
<template>
<section
ref="el"
class="folder"
:class="{ desktop, accepting: isAccepting, over: isOver && isAccepting, refused, received }"
@animationend="received = false"
>
<header>
<svg v-if="id === 'trash'" class="icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h16M9 7V4.5h6V7M6.5 7l1 13h9l1-13" />
<path v-if="files.length" d="M10 11v5M14 11v5" />
</svg>
<svg v-else-if="!desktop" class="icon" viewBox="0 0 24 24" aria-hidden="true">
<path v-if="isOver && isAccepting" d="M3 19V6a1.5 1.5 0 0 1 1.5-1.5h4l2 2h7A1.5 1.5 0 0 1 19 8v2M3 19l3-8h16l-3 8z" />
<path v-else d="M3 7.5A1.5 1.5 0 0 1 4.5 6h4l2 2h9A1.5 1.5 0 0 1 21 9.5v8a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 17.5z" />
</svg>
<span class="name">{{ name }}</span>
<span class="count">{{ files.length }}</span>
</header>
<!-- The files left behind slide into the gap (Vue's TransitionGroup). -->
<TransitionGroup tag="div" class="files" move-class="moving">
<FileCard v-for="file in files" :key="file.id" :file="file" />
</TransitionGroup>
</section>
</template>
<style scoped>
/*
* Sized for every file it can hold, so nothing around it moves when files come and go:
* the desktop two rows, a folder all four in its tray.
*/
.folder {
display: flex;
flex-direction: column;
gap: 10px;
box-sizing: border-box;
min-height: 284px;
padding: 12px;
border: 1.5px solid color-mix(in srgb, currentColor 12%, transparent);
border-radius: 14px;
background: color-mix(in srgb, currentColor 3%, Canvas);
transition:
border-color 0.2s,
background 0.2s;
}
.folder.desktop {
min-height: 166px;
border-style: dashed;
}
/* A drag is on and this folder takes it. */
.folder.accepting {
border-color: color-mix(in srgb, #42b883 55%, transparent);
border-style: dashed;
}
.folder.over {
border-style: solid;
border-color: #42b883;
background: color-mix(in srgb, #42b883 12%, Canvas);
}
.folder.refused {
border-color: #f43f5e;
background: color-mix(in srgb, #f43f5e 8%, Canvas);
animation: shake 0.3s;
}
.folder.received {
animation: pulse 0.45s 0.15s;
}
@keyframes shake {
25% {
translate: -4px 0;
}
75% {
translate: 4px 0;
}
}
@keyframes pulse {
40% {
scale: 1.04;
border-color: #42b883;
}
}
header {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
}
.icon {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.accepting .icon {
stroke: #42b883;
}
.refused .icon {
stroke: #f43f5e;
}
.count {
margin-left: auto;
padding: 3px 7px;
border-radius: 99px;
background: color-mix(in srgb, currentColor 8%, transparent);
font: 11px/1 ui-monospace, Menlo, Consolas, monospace;
}
/* A folder's tray: where its files go. */
.files {
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
padding: 5px;
border: 1.5px dashed color-mix(in srgb, currentColor 14%, transparent);
border-radius: 10px;
transition: border-color 0.2s;
}
.accepting .files {
border-color: color-mix(in srgb, #42b883 60%, transparent);
}
.desktop .files {
display: grid;
flex: none;
grid-template-columns: repeat(auto-fill, 172px);
gap: 8px 10px;
padding: 0;
border: 0;
}
.moving {
transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
}
@media (prefers-reduced-motion: reduce) {
.folder {
transition: none;
}
.folder.refused,
.folder.received {
animation: none;
}
.moving {
transition: none;
}
}
</style>02Tri
Rendez un brouillon de votre tableau, et il se réordonne en direct pendant le glissement. Au dépôt, le nouvel ordre est écrit dans votre ref. Vertical, horizontal ou en grille de n'importe quelle taille.
DocumentationChargement de la démo…
<script setup lang="ts">
import { ref } from 'vue';
import AppList from './AppList.vue';
const apps = ref([
{ id: 'notes', name: 'Notes' },
{ id: 'maps', name: 'Maps' },
{ id: 'photos', name: 'Photos' },
{ id: 'clock', name: 'Clock' },
{ id: 'files', name: 'Files' },
{ id: 'calendar', name: 'Calendar' },
{ id: 'weather', name: 'Weather' },
{ id: 'settings', name: 'Settings' },
]);
const dock = ref([
{ id: 'mail', name: 'Mail' },
{ id: 'chat', name: 'Chat' },
{ id: 'camera', name: 'Camera' },
]);
</script>
<template>
<!-- Each list in a component of its own: one sortable list per component. -->
<div class="screen">
<AppList v-model="apps" />
<AppList v-model="dock" dock :limit="4" />
</div>
</template>
<style scoped>
.screen {
display: flex;
flex-direction: column;
align-items: center;
gap: 28px;
padding: 24px 20px 14px;
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
border-radius: 28px;
background:
radial-gradient(120% 80% at 0% 0%, color-mix(in srgb, #42b883 16%, transparent), transparent 60%),
color-mix(in srgb, currentColor 3%, Canvas);
}
</style>03Kanban
Les listes d'un même groupe s'échangent des items. Copiez depuis une palette, accueillez des éléments déplaçables simples, ou échangez au lieu de décaler — la liste qui reçoit fait de la place pendant le survol.
DocumentationChargement de la démo…
<script setup lang="ts">
import { toRef, useTemplateRef } from 'vue';
import { makeSortableList } from '@vue-dnd-kit/core';
import Card from './Card.vue';
import type { Candidate } from './candidates';
const props = defineProps<{ column: { id: string; title: string; color: string; cards: Candidate[] } }>();
const zone = useTemplateRef<HTMLElement>('zone');
const list = useTemplateRef<HTMLElement>('list');
// Lists with the same `group` exchange items. The drop is written into `column.cards`.
// `dropzone`: the whole column takes drops, below the last card too.
const { items, isOver } = makeSortableList(list, {
id: props.column.id,
items: toRef(props.column, 'cards'),
group: 'board',
dropzone: zone,
});
</script>
<template>
<section ref="zone" class="column" :class="{ over: isOver }">
<h3>
<span class="dot" :style="{ background: column.color }" />
<span class="title">{{ column.title }}</span>
<span class="count">{{ column.cards.length }}</span>
</h3>
<ul ref="list" class="cards">
<Card v-for="card in items" :key="card.id" :card="card" />
</ul>
</section>
</template>
<style scoped>
.column {
display: flex;
flex-direction: column;
padding: 10px;
border: 1px solid color-mix(in srgb, currentColor 10%, transparent);
border-radius: 14px;
background: color-mix(in srgb, currentColor 3%, transparent);
transition:
background 0.2s,
border-color 0.2s;
}
.column.over {
border-color: color-mix(in srgb, #42b883 60%, transparent);
background: color-mix(in srgb, #42b883 6%, transparent);
}
h3 {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 4px 12px;
font-size: 13px;
font-weight: 600;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.count {
margin-left: auto;
padding: 3px 7px;
border-radius: 99px;
background: color-mix(in srgb, currentColor 8%, transparent);
font: 11px/1 ui-monospace, Menlo, Consolas, monospace;
}
.cards {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 160px;
margin: 0;
padding: 0;
list-style: none;
}
</style>04Arbres
Rendus à plat avec des profondeurs, ils se virtualisent. Faites glisser de côté pour changer de niveau, ou survolez un dossier pour déposer à l'intérieur ; limitez la profondeur et décidez de ce qui s'imbrique où.
DocumentationChargement de la démo…
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue';
import { makeSortableTree, type Key } from '@vue-dnd-kit/core';
import TreeRow from './TreeRow.vue';
import { weightOf, type Thing } from './types';
const things = ref<Thing[]>([
{
id: 'backpack',
name: 'Backpack',
icon: '🎒',
container: true,
children: [
{
id: 'pouch',
name: 'Pouch',
icon: '👝',
container: true,
children: [
{ id: 'coins', name: 'Coins', icon: '💰', weight: 1 },
{ id: 'key', name: 'Old key', icon: '🗝️', weight: 1 },
],
},
{ id: 'potion', name: 'Potion', icon: '🧪', weight: 2 },
{ id: 'map', name: 'Map', icon: '🗺️', weight: 1 },
],
},
{ id: 'chest', name: 'Chest', icon: '🧰', container: true, children: [{ id: 'shield', name: 'Shield', icon: '🛡️', weight: 6 }] },
{ id: 'sword', name: 'Sword', icon: '🗡️', weight: 4 },
{ id: 'apple', name: 'Apple', icon: '🍎', weight: 1 },
]);
const collapsed = ref(new Set<Key>());
const INDENT = 20;
const root = useTemplateRef<HTMLElement>('root');
// Drag sideways to change the depth, or hover the middle of a container to drop into it.
// Only containers hold things, and a container holds no more than two levels.
const { items } = makeSortableTree(root, {
items: things,
indentation: INDENT,
collapsed,
canNest: (_thing, parent) => !!parent.container,
maxDepth: 2,
});
const carried = computed(() => things.value.filter((thing) => thing.id !== 'chest').reduce((sum, thing) => sum + weightOf(thing), 0));
const toggle = (key: Key) => (collapsed.value.has(key) ? collapsed.value.delete(key) : collapsed.value.add(key));
</script>
<template>
<section class="inventory">
<header>
Inventory
<span class="carried">{{ carried }} kg carried</span>
</header>
<div ref="root" class="tree">
<TreeRow v-for="node in items" :key="node.key" :node="node" :indent="INDENT" @toggle="toggle" />
</div>
</section>
</template>
<style scoped>
.inventory {
width: 320px;
max-width: 100%;
margin: 0 auto;
padding: 6px;
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
border-radius: 14px;
background: color-mix(in srgb, currentColor 3%, Canvas);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 10px 8px;
font-size: 13px;
font-weight: 700;
}
.carried {
font: 500 11px/1 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.55;
}
.tree {
display: flex;
flex-direction: column;
gap: 2px;
}
</style>05Multi-drag
Sélectionnez par Ctrl/Cmd-clic ou tracez un rectangle, puis faites glisser un élément : toute la sélection se déplace ensemble et atterrit dans l'ordre, même d'une liste à l'autre.
DocumentationChargement de la démo…
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { makeSelectionArea, makeSortableList } from '@vue-dnd-kit/core';
import Shot from './Shot.vue';
const shots = ref(
[12, 48, 96, 150, 200, 265, 300, 330].map((hue, index) => ({ id: `s${index + 1}`, hue })),
);
const grid = useTemplateRef<HTMLElement>('grid');
const { items } = makeSortableList(grid, { items: shots, strategy: 'grid' });
// Press on the empty space and draw a box to select; Ctrl/Cmd-click a shot to toggle it.
const area = useTemplateRef<HTMLElement>('area');
const { isSelecting, style } = makeSelectionArea(area);
</script>
<template>
<div ref="area" class="area">
<div ref="grid" class="grid">
<Shot v-for="shot in items" :key="shot.id" :shot="shot" />
</div>
<div v-if="isSelecting" class="band" :style="style" />
</div>
</template>
<style scoped>
/* The area fills its box: a selection box starts anywhere in it. */
.area {
position: relative;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 100%;
min-height: 320px;
padding: 24px;
user-select: none;
}
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
width: min(100%, 528px);
}
.band {
border: 1px solid #42b883;
background: color-mix(in srgb, #42b883 15%, transparent);
}
</style>06Contraintes
Une zone de glissement fixe les règles de tout ce qui y est glissé : verrouillez un axe, gardez les glissements dans ses bords, alignez sur une grille — et changez ces règles pendant que l'application tourne.
DocumentationChargement de la démo…
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue';
import { makeDragArea, makeDroppable, snapToGrid, useDnD } from '@vue-dnd-kit/core';
import Shape from './Shape.vue';
import type { Shape as ShapeData } from './shapes';
const GRID = 24;
const shapes = ref<ShapeData[]>([
{ id: 'title', kind: 'title', label: 'Title', x: 24, y: 24, width: 240, height: 48 },
{ id: 'chart', kind: 'chart', label: 'Chart', x: 24, y: 96, width: 192, height: 120 },
{ id: 'photo', kind: 'photo', label: 'Photo', x: 264, y: 96, width: 192, height: 120 },
{ id: 'logo', kind: 'logo', label: 'Logo', x: 384, y: 24, width: 72, height: 48 },
]);
const axis = ref<'x' | 'y' | undefined>();
const snap = ref(true);
const bounded = ref(true);
const slide = useTemplateRef<HTMLElement>('slide');
// Rules for every drag that starts on the slide, read when a drag starts: an axis lock,
// the slide's edges, a grid.
makeDragArea(slide, {
axis,
restrictToArea: bounded,
modifiers: () => (snap.value ? [snapToGrid(GRID)] : []),
});
// The slide is also the drop target: a shape stays where it is dropped. `position` is
// where it landed, as `left`/`top` inside the slide.
const moved = ref<ShapeData | null>(null);
makeDroppable(slide, {
id: 'slide',
onDrop: ({ active, position }) => {
const shape = shapes.value.find((item) => item.id === active.id);
if (!shape || !position) return;
shape.x = Math.round(position.x);
shape.y = Math.round(position.y);
moved.value = shape;
},
});
// The line a shape slides along while the axis is locked.
const { active } = useDnD();
const guide = computed(() => {
const shape = shapes.value.find((item) => item.id === active.value?.id);
if (!shape || !axis.value) return null;
return axis.value === 'x' ? { top: `${shape.y + shape.height / 2}px` } : { left: `${shape.x + shape.width / 2}px` };
});
const AXES = [
{ label: 'Free', value: undefined },
{ label: 'X', value: 'x' },
{ label: 'Y', value: 'y' },
] as const;
</script>
<template>
<div class="editor">
<div class="toolbar">
<div class="segmented" role="group" aria-label="Axis">
<button
v-for="option in AXES"
:key="option.label"
type="button"
:aria-pressed="axis === option.value"
@click="axis = option.value"
>
{{ option.label }}
</button>
</div>
<button type="button" class="toggle" :aria-pressed="snap" @click="snap = !snap">Snap to grid</button>
<button type="button" class="toggle" :aria-pressed="bounded" @click="bounded = !bounded">Keep on slide</button>
<span class="readout">{{ moved ? `${moved.label} → ${moved.x}, ${moved.y}` : '' }}</span>
</div>
<!-- On a narrow screen, the slide scrolls sideways: its shapes are placed in px. -->
<div class="scroll">
<div ref="slide" class="slide" :class="{ snap, bounded, dragging: !!active }">
<div v-if="guide" class="guide" :class="axis" :style="guide" />
<Shape v-for="shape in shapes" :key="shape.id" :shape="shape" />
</div>
</div>
</div>
</template>
<style scoped>
.editor {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 488px;
margin: 0 auto;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.segmented {
display: flex;
padding: 3px;
border-radius: 10px;
background: color-mix(in srgb, currentColor 7%, transparent);
}
button {
padding: 6px 12px;
border: 0;
border-radius: 8px;
background: none;
color: inherit;
font: 600 12px/1.2 system-ui, sans-serif;
cursor: pointer;
}
.segmented button[aria-pressed='true'] {
background: Canvas;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
}
.toggle {
box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 15%, transparent);
opacity: 0.6;
}
.toggle[aria-pressed='true'] {
background: color-mix(in srgb, #3b82f6 14%, transparent);
box-shadow: inset 0 0 0 1px #3b82f6;
color: #3b82f6;
opacity: 1;
}
button:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.readout {
margin-left: auto;
font: 11px/1 ui-monospace, Menlo, Consolas, monospace;
opacity: 0.6;
}
.scroll {
overflow-x: auto;
padding: 4px;
}
/* A white 16:9 slide in either theme. */
.slide {
position: relative;
width: 480px;
height: 270px;
border-radius: 8px;
background: #fff;
box-shadow:
0 0 0 1px color-mix(in srgb, currentColor 14%, transparent),
0 18px 40px -24px rgb(0 0 0 / 0.6);
}
.slide.snap {
background-image:
linear-gradient(to right, rgb(15 23 42 / 0.07) 1px, transparent 1px),
linear-gradient(to bottom, rgb(15 23 42 / 0.07) 1px, transparent 1px);
background-size: 24px 24px;
}
.slide.bounded.dragging {
box-shadow: 0 0 0 2px #3b82f6;
}
.guide {
position: absolute;
pointer-events: none;
}
.guide.x {
left: 0;
right: 0;
border-top: 1.5px dashed #3b82f6;
}
.guide.y {
top: 0;
bottom: 0;
border-left: 1.5px dashed #3b82f6;
}
</style>07Et plus encore
Du FLIP pendant le tri, un dépôt qui atterrit sur son emplacement, des changements de données animés. Utilisez GSAP ou motion si vous le souhaitez.
Glissement au clavier par emplacements, annonces en direct et attributs ARIA qui n'écrasent jamais les vôtres.
TanStack Virtual et VueUse : des milliers de lignes, toujours triables, toujours animées.
Pinia, un serveur ou des données immuables : sautez l'écriture par défaut et appliquez le résultat vous-même.
08Écosystème
Tout glisse avec le cœur. Ajoutez le reste quand vous en avez besoin.
Un onglet dans Vue DevTools et Nuxt DevTools : une mini-carte en direct, un joystick qui fait glisser n'importe quoi, des options modifiables pendant que l'application tourne.
« Monter », « déplacer vers Done », visites guidées et tests — de vrais glissements, annoncés et animés, depuis le code.
Composables importés automatiquement, composants globaux et valeurs par défaut compatibles SSR.
Un skill pour agents qui apprend à Claude Code et à d'autres assistants comment fonctionne la bibliothèque.
01
1 243
Tests de bout en bout dans trois navigateurs
02
469
Tests unitaires
03
21KB
Éléments déplaçables et zones de dépôt, gzippés
04
9
Langues de cette documentation