Documentación/Guías

Listas ordenables

Listas, barras de pestañas y cuadrículas que se reordenan mientras arrastras — makeSortableList renderiza un borrador, makeSortableItem crea las filas y el drop escribe el nuevo orden en tu ref. Asas, línea de inserción, intercambios.

Editar esta página

Esta página la ha traducido una IA y puede contener imprecisiones. Leer el original en inglés

Live

Cargando la 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>

Una lista y sus filas

Una lista ordenable necesita dos componentes: la lista y su fila.

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>

La fila se registra en la lista dentro de la que se renderiza. Un componente puede crear varias listas (dos columnas, una lista dentro de otra): cada fila se une a aquella cuyo elemento la contiene, la más interior en listas anidadas.

El borrador

makeSortableList devuelve items, un borrador de tu array. Mientras arrastras, el borrador se reordena en vivo: la fila arrastrada ocupa su nuevo hueco y las demás se apartan. Tu array no cambia hasta el drop. Entonces la biblioteca escribe en él el nuevo orden.

El panel de capas de arriba muestra las dos cosas: el panel renderiza items y se mueve mientras arrastras; el lienzo lee layers, el propio array, y solo se reapila cuando sueltas.

  • Renderiza items, no tu array. Si renderizas tu array, nada se mueve hasta el drop.
  • La escritura ocurre después del onDrop de la lista. En onDrop, tu array todavía tiene el orden anterior y e.result el nuevo.
  • layers es una ref normal (aquí un defineModel, y la escritura emite update:modelValue), así que el resto de tu app ve el cambio como cualquier otro.
makeSortableList(list, {
  items: layers,
  onDrop: (e) => {
    console.log(e.from.index, '→', e.to.index, e.result.get(e.to.list));
  },
});
¿Un store, estado del servidor o datos inmutables? Pasa un getter o un computed en lugar de una ref. La biblioteca no escribe en una fuente de solo lectura: llama a e.preventDefault() y aplica e.result tú mismo. Consulta listas controladas.

Claves

Las filas se identifican por sus datos, nunca por índice ni por un id automático. Por defecto la clave es item.id, o el propio ítem para strings y números.

// 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 });
  • Usa la misma clave en v-for: :key="layer.uuid". Con :key="index" se animan las filas equivocadas y su estado salta de una fila a otra.
  • Las claves deben ser únicas entre todas las listas que intercambian ítems.
  • Pasa item e id como getters, para que una fila siga sincronizada cuando cambian sus props.

Asas

Por defecto toda la fila inicia un arrastre, salvo los botones, inputs y demás controles que contiene: el ojo y el candado de una capa siguen siendo botones normales. Pasa handle para arrastrar solo desde una parte:

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 es un selector dentro del ítem, o una ref de elemento.
  • Un arrastre con ratón empieza desde el asa de inmediato, sin los 4 px habituales. Una pulsación que no se mueve sigue siendo un clic: el asa puede abrir un menú en click.
  • El asa recibe los atributos del arrastre (role, tabindex, aria-*), y los arrastres con teclado empiezan desde ella.

Haz que el asa sea un <button type="button"> con un aria-label: puede recibir el foco tal cual, y los lectores de pantalla anuncian su etiqueta. Un <span> sin más con un icono también funciona (la biblioteca le da tabindex="0" y role="button"), pero no tiene un nombre con sentido.

Los arrastres táctiles empiezan tras una pulsación larga de 200 ms, para que la página pueda seguir desplazándose. Para arrastrar al instante desde un asa, dale al asa touch-action: none y al ítem activation: { touch: { distance: 4 } }.

Estrategias

strategy indica cómo está dispuesta la lista, para que las filas hagan sitio de la forma correcta:

strategyPara
'vertical' (por defecto)Una columna de filas.
'horizontal'Una sola fila: pestañas, chips, una línea de tiempo.
'grid'Cuadrículas CSS y filas flex que saltan de línea, también con ítems de distintos tamaños.

Una fila de pestañas es una lista horizontal:

Live

Cargando la 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
});

El botón de cerrar de la pestaña sigue funcionando dentro de la pestaña arrastrable, y un clic que termina un arrastre no selecciona ninguna pestaña. Las filas se deslizan solas mientras arrastras; animateChanges añade lo mismo para los cambios que no hizo ningún arrastre, aquí los botones (consulta animaciones).

Una cuadrícula ordena en dos dimensiones, con mosaicos de cualquier tamaño:

Live

Cargando la 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 mantiene la cuadrícula compacta cuando se mueve un mosaico grande. Sin él, la cuadrícula deja huecos donde un mosaico ya no cabe y crece una fila.

Bloquear el eje

makeSortableList(list, { items: layers, axis: 'y' });

axis: 'y' mantiene el arrastre en el eje vertical, 'x' en el horizontal: las capas y las pestañas de arriba lo usan. El axis propio de un ítem gana al de su lista. Para mantener además el arrastre dentro de la lista, añade el modificador restrictToParent. Más en restricciones.

Dar estilo al hueco

La fila arrastrada se queda en la lista como el hueco donde aterrizará, y recibe data-dnd-placeholder. El estilo por defecto la atenúa al 40 %, con especificidad cero, así que cualquier regla tuya gana:

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 devuelve el mismo estado como refs, y algunos más:

DevuelveQué es
isPlaceholderEsta fila es el hueco donde aterrizará un ítem arrastrado.
isDraggingEsta fila se está arrastrando (cada fila arrastrada, en un multi-drag).
indexSu índice en el borrador: cambia mientras arrastras.
selectedModificable, ligado a la selección del provider.
indicatorEn modo indicador, dónde va la línea.

La lista devuelve isOver e isAccepting para su propio resaltado, y activeKey y activeIndex, el ítem arrastrado y su lugar en el borrador de esta lista. Todos los estados, con demos en vivo, están en estilos.

Desactivar cosas

OpciónQué impide
disabled en un ítemEste ítem no se puede arrastrar. Los demás siguen pasando por delante de él: bloquea una capa arriba y luego arrastra otra por encima.
disabled en la listaNingún arrastre sale de ella, ningún drop entra.
sort: false en la listaSus ítems no se pueden reordenar dentro, pero sí pueden salir: una paleta, consulta listas que comparten ítems.

Todas aceptan un getter o una ref, y se aplican al siguiente arrastre:

LayerRow.vue
makeSortableItem(el, { item: () => props.layer, handle: '.grip', disabled: () => props.layer.locked });

Una fila que debe quedarse primera o última, hagan lo que hagan las demás, no es un ítem: renderízala fuera de la lista. La capa Background del panel está debajo del <ul>.

Mostrar solo algunos ítems

Un cuadro de búsqueda, una etiqueta: filter muestra los ítems para los que devuelve true, y un arrastre ordena entre esos. Los demás se quedan en tu array, en su sitio:

Live

Cargando la 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 contiene los ítems que coinciden: renderízalo como siempre. El filtro es reactivo: escribir en el cuadro de búsqueda actualiza la lista al momento.
  • El drop escribe el array entero. Cada ítem oculto se queda justo después del ítem visible al que seguía; si ese es el que se movió, se queda después del más cercano anterior a él que no se movió.
  • e.to.index y el activeIndex de la lista cuentan los ítems visibles. e.result contiene el array entero, con los ítems ocultos.
Un computed que filtra tu array y se pasa como items también funciona, pero es de solo lectura: tendrías que aplicar e.result tú mismo y calcular dónde van los ítems ocultos. filter hace las dos cosas.

Una línea de inserción

Con placeholder: 'indicator', la lista se queda quieta mientras arrastras, y una línea muestra adónde va el drop. La línea la dibujas tú:

Live

Cargando la 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>
Campo de indicatorQué contiene
listLa lista en la que cae el drop.
indexEl índice de inserción en la lista tal como se renderiza.
keyEl ítem al que va unida la línea; null en una lista vacía (dibújala entonces en la lista).
side'before' o 'after' de ese ítem; 'swap' en una lista que intercambia. Los árboles añaden 'inside'.
depth, parentKeySolo árboles: dónde cae el drop. Consulta árboles.
  • El indicator de la lista tiene valor mientras la línea está en esa lista (la tabla lo muestra), y el de un ítem mientras la línea está unida a ese ítem.
  • La fila arrastrada se queda donde estaba, marcada como placeholder: atenúala.
  • El teclado también mueve la línea.
  • Un drop fuera de cualquier destino cancela en este modo (dropOutside: 'cancel'), ya que la lista nunca mostró otro orden.
  • El overlay tapa parte de la fila que tiene debajo. Hazlo un poco transparente y la línea se verá a través: [data-dnd-overlay] .row { opacity: 0.85 }.

Intercambiar en vez de desplazar

Con behavior: 'swap', el ítem arrastrado cambia el sitio con el que tiene debajo, y los demás no se mueven:

Live

Cargando la 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}`);
  },
});
  • En vivo (placeholder: 'item', por defecto): el intercambio se ve mientras arrastras. Volver atrás, o salir de la lista, lo deshace.
  • Al soltar (placeholder: 'indicator'): nada se mueve hasta el drop. El indicador marca el ítem con el que el drop hace el intercambio, side: 'swap': resalta el contorno de ese ítem en lugar de dibujar una línea. Las flechas mueven la marca de ítem en ítem; de vuelta sobre el ítem arrastrado, no hay ninguna.
PersonCell.vue
<li ref="el" :class="{ target: indicator?.side === 'swap' }">…</li>

El evento de drop tiene mode: 'swap' y partner, el ítem con el que se intercambió: su key, su item y sus propios from y to. Dos listas de un mismo grupo intercambian entre sí cuando ambas tienen behavior: 'swap', como hacen las dos columnas. El ítem que está debajo pasa entonces a la otra lista, así que debe poder salir (group.pull) y entrar (group.put): consulta listas que comparten ítems. Un multi-drag ordena en lugar de intercambiar.

Teclado

Pon el foco en una fila, o en su asa, y pulsa Space o Enter para agarrarla. Las flechas a lo largo de la lista la mueven un hueco, en las cuadrículas arriba y abajo pasan a la fila siguiente, y las flechas transversales a la lista la mueven a la siguiente lista de su grupo. Pasado el final de una lista, la flecha sigue hacia la siguiente lista en esa dirección (listas apiladas una encima de otra) o, si no la hay, hacia un destino de drop que esté ahí, una papelera. Pulsa Space o Enter otra vez para soltarla, y Escape para devolverla a su sitio. Todas las demos de esta página funcionan así. Los textos para lectores de pantalla están en accesibilidad.

Animaciones

Las filas se deslizan a sus nuevos sitios mientras arrastras, y la fila soltada aterriza en su hueco. La propia lista crece o se encoge hasta su nuevo tamaño cuando entran y salen filas (animateSize). Está activado por defecto y no necesita código. No envuelvas las filas en <TransitionGroup>: los movimientos se animarían dos veces. Tampoco pongas una transition CSS en el transform de las filas. Para los cambios que no hizo ningún arrastre (añadir, quitar, barajar), activa la opción animateChanges de la lista, como hacen las pestañas. Consulta animaciones.

La lista completa de opciones está en la API de makeSortableList y makeSortableItem.