Animado
FLIP al ordenar, un drop que aterriza en su hueco, cambios de datos animados. Usa GSAP o motion si quieres.
Tableros que intercambian tarjetas
Anidar con un leve gesto
Muchos ítems, un solo movimiento
Listas que se ordenan solas
Movimiento por defecto
El teclado, de primera clase
Diez mil filas
Arrastra las tarjetas — o llega a una con Tab y usa las flechas
Listas ordenables, tableros kanban, árboles y multi-drag para Vue 3 — con animaciones, listos para el teclado y accesibles de serie.
Libre y de código abierto. Con licencia MIT.
00Cómo funciona
Tu marcado, tus estilos. Pasa una ref de plantilla a un composable y el elemento se arrastra, se ordena y se suelta — con la animación, el teclado y el lector de pantalla ya resueltos.
makeSortableListListas, cuadrículas y columnas de kanban que se reordenan mientras arrastras — e intercambian ítems dentro de un grupo.makeSortableTreeDatos anidados, renderizados en plano: profundidad con un leve gesto lateral, drops dentro de carpetas, virtualización.makeDraggable · makeDroppableTodo lo demás: tableros, lienzos, subidas de archivos — tus propias reglas sobre qué va dónde.01Arrastrar y soltar
Un elemento arrastrable, un destino de drop y una regla sobre qué va dónde. Los destinos se iluminan cuando aceptan el arrastre y rechazan lo que no aceptan; lo que hace un drop lo decides tú.
DocumentaciónCargando la demo…
<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>02Ordenable
Renderiza un borrador de tu array y se reordenará en vivo mientras arrastras. Al soltar, el nuevo orden se escribe en tu ref. Vertical, horizontal o una cuadrícula de cualquier tamaño.
DocumentaciónCargando la demo…
<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
Las listas de un mismo grupo intercambian ítems. Copia desde una paleta, acepta elementos arrastrables simples o intercambia en lugar de desplazar — la lista que recibe hace sitio mientras pasas por encima.
DocumentaciónCargando la demo…
<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>04Árboles
Se renderizan en plano con profundidades, así que se pueden virtualizar. Arrastra hacia un lado para cambiar de nivel o pasa sobre una carpeta para soltar dentro; limita la profundidad y decide qué se anida dónde.
DocumentaciónCargando la demo…
<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
Selecciona con Ctrl/Cmd-clic o dibuja un rectángulo, y luego arrastra uno: toda la selección viaja junta y aterriza en orden, incluso entre listas.
DocumentaciónCargando la demo…
<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>06Restricciones
Un área de arrastre fija las reglas de todo lo que se arrastra dentro de ella: bloquea un eje, mantén los arrastres dentro de sus bordes, ajusta a una cuadrícula — y cámbialas mientras la app se ejecuta.
DocumentaciónCargando la demo…
<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>07Y más
FLIP al ordenar, un drop que aterriza en su hueco, cambios de datos animados. Usa GSAP o motion si quieres.
Arrastre con teclado por huecos, anuncios en vivo y atributos ARIA que nunca sobrescriben los tuyos.
TanStack Virtual y VueUse: miles de filas, igual de ordenables, igual de animadas.
Pinia, un servidor o datos inmutables: omite la escritura por defecto y aplica tú el resultado.
08Ecosistema
Todo se arrastra con el núcleo. Añade el resto cuando lo necesites.
Una pestaña en Vue DevTools y Nuxt DevTools: un minimapa en vivo, un joystick que arrastra cualquier cosa y opciones que editas mientras la app se ejecuta.
«Subir», «mover a Done», recorridos guiados y tests — arrastres reales, anunciados y animados, desde el código.
Composables importados automáticamente, componentes globales y valores por defecto seguros para SSR.
Un skill para agentes que enseña a Claude Code y a otros asistentes cómo funciona la biblioteca.
01
1243
Tests end-to-end en tres navegadores
02
469
Tests unitarios
03
21KB
Elementos arrastrables y zonas de drop, con gzip
04
9
Idiomas de esta documentación