Documentación/Guías

El provider

DnDProvider gestiona todos los arrastres que hay debajo. Encuentra el destino, inicia los arrastres, desplaza y cuenta a useDnD y useDnDMonitor todo lo que pasa.

Editar esta página

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

Un provider para la app

App.vue
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
</script>

<template>
  <DnDProvider>
    <RouterView />
  </DnDProvider>
</template>

Pon un DnDProvider arriba del todo, en App.vue o en un layout, y olvídate de él. Cada composable (makeSortableList, makeDraggable, useDnD…) busca el provider más cercano por encima del componente en el que se ejecuta.

Un composable debe ejecutarse en un componente renderizado dentro del provider. Llamarlo en el componente cuya propia plantilla contiene <DnDProvider> lanza No <DnDProvider> found above this component: el inject de Vue solo ve a los ancestros.

Lo que gestiona

ParteLo que hace el provider
EntradaConvierte la entrada de puntero, táctil y de teclado en arrastres (sensors, activation), un arrastre a la vez.
Encontrar el destinoHace la prueba de colisión con los destinos de drop bajo el arrastre (collision) y se lo comunica.
El overlayRenderiza la caja que sigue al puntero, teletransportada a body, y la hace aterrizar al soltar.
La selecciónUn único conjunto de ids seleccionados, compartido por todas las listas y elementos arrastrables que tiene debajo.
Desplazamiento automáticoDesplaza los contenedores y la ventana cuando un arrastre se acerca a sus bordes.
La región vivaAnuncia a los lectores de pantalla los agarres, movimientos, drops y cancelaciones.

No renderiza nada más: tu marcado se queda como está.

Las opciones de un vistazo

Cada opción tiene un valor por defecto que funciona, así que el provider normalmente no recibe ninguna. El provider lee sus props en el momento en que las necesita: las demos de abajo cambian collision, activation y autoScroll en vivo.

PropPor defectoQué ajusta
collisionpointerFirstCómo se elige el destino bajo el arrastre. Más abajo.
activationratón 4 px, táctil 200 msCuándo una pulsación se convierte en arrastre, según el tipo de puntero. Más abajo.
autoScrollactivadoDesplazamiento en los bordes: { threshold, maxSpeed, acceleration } o false. Más abajo.
hapticsuna vibración corta al agarrar con el dedoVibraciones de los arrastres táctiles, o false. Más abajo.
copyModifier'Alt'Tecla que, mantenida durante el arrastre, convierte un movimiento en una copia.
modifiers[]Reglas de movimiento para todos los arrastres: consulta las restricciones.
animation / dropAnimationactivadoLos ítems que se deslizan al ordenar / el aterrizaje del overlay: consulta las animaciones.
animateChangesdesactivadoValor por defecto de listas y árboles: animar los cambios de datos que no hizo ningún arrastre.
keyboardSpace / Enter, EscapeTeclas y paso del teclado; false desactiva los arrastres con teclado. Consulta la accesibilidad.
sensorspuntero + tecladoLo que convierte la entrada en arrastres: consulta los sensores.
overlayTo'body'Adónde se teletransporta el overlay.
overlayFit'slot'Tamaño del overlay: el del hueco donde aterrizará, o el del origen. Consulta el overlay.
overlayTransitionun fundido cruzadoCómo cambia el overlay de un componente a otro durante el arrastre: false, o props de <Transition>. Consulta los destinos que cambian el overlay.
overlayClass—Clases para el contenido del overlay ([data-dnd-overlay-content]), el elemento que se anima entero: un levantar al agarrar, un asentar al aterrizar. Consulta el overlay: levantar, inclinar, asentar.
overlayPreviews5Multi-drag: cuántos ítems arrastrados reciben un clon al agarrar, o 'all'. Consulta el overlay por defecto.
announcementsintegradosTextos para lectores de pantalla, o false.
screenReaderInstructionsintegradoEl texto al que apunta cada elemento arrastrable con aria-describedby.
defaultStylestrueInyecta CSS de especificidad cero para los estados de arrastre: consulta los estilos.

La lista completa, con todas las formas, está en la API de DnDProvider. El provider también emite drag-start, drag-move, drag-over, drag-end, drag-cancel, drag-resume y drag-settle, los mismos eventos que escucha useDnDMonitor.

Leer el arrastre: useDnD()

Live

Cargando la demo…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Tasks from './Tasks.vue';
import StatePanel from './StatePanel.vue';
</script>

<template>
  <!-- One provider: the list drags, the panel reads the drag. Neither imports the other. -->
  <DnDProvider>
    <div class="demo">
      <Tasks />
      <StatePanel />
    </div>
  </DnDProvider>
</template>

<style scoped>
.demo {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: center;
  gap: 24px;
}
</style>

useDnD() devuelve el arrastre como estado reactivo, en cualquier componente bajo el provider:

import { useDnD } from '@vue-dnd-kit/core';

const { status, active, over, pointer, activation, isAutoScrolling, selection, cancel } = useDnD();
CampoQué contiene
status'idle', 'activating' (una pulsación que espera su activación), 'dragging', 'dropping' (no hay ningún arrastre en curso, pero un overlay todavía aterriza, o decide un onBeforeDrop asíncrono), 'selecting' (un rectángulo de selección).
activeLo que se arrastra: id, ids (todos los ids arrastrados, empezando por el que se agarró), items, data, type, element, rect, sensor. null en reposo.
overEl destino bajo el arrastre: id, data, element, rect y accepted. null si no hay nada debajo.
pointerEl puntero en coordenadas del viewport, después de los modificadores.
activation.progressDe 0 a 1 mientras una pulsación espera su activación: dibuja un anillo con él.
isAutoScrollingEl desplazamiento automático está moviendo algo.
selectionLa selección del provider: ids, has, add, delete, toggle, set, clear. Consulta multi-drag.
cancel()Cancela el arrastre en curso (los ítems vuelven a su sitio), una pulsación que está esperando o un rectángulo de selección.
DropHint.vue
<script setup lang="ts">
import { computed } from 'vue';
import { useDnD } from '@vue-dnd-kit/core';

const { status, over } = useDnD();
const refused = computed(() => status.value === 'dragging' && over.value?.accepted === false);
</script>

<template>
  <p v-if="refused" role="status">This list doesn't take that.</p>
</template>

En cuanto se decide un drop, active, over y pointer valen null, incluso mientras el overlay sigue aterrizando (status vale 'dropping'). La UI derivada de ellos nunca muestra el arrastre anterior junto a los datos nuevos. Mientras un onBeforeDrop asíncrono espera, el estado se queda como estaba al soltar.

El siguiente arrastre no espera a ningún aterrizaje: empieza enseguida, y status vale 'dragging' mientras el último overlay aterriza a su alrededor. Una pulsación sobre un overlay que está aterrizando lo atrapa en pleno vuelo: el arrastre continúa desde donde está el overlay. onDragSettle avisa cuando el overlay de un arrastre ha llegado a su sitio.

Escuchar todos los arrastres: useDnDMonitor()

import { useDnDMonitor } from '@vue-dnd-kit/core';

useDnDMonitor({
  onDragStart: ({ active }) => {},
  onDragMove: ({ pointer, delta }) => {},
  onDragOver: ({ over }) => {},                  // the target changed
  onBeforeDrop: (e) => 'accept',                 // or 'reject', 'continue', some ids; may be async
  onDragEnd: ({ active, over, dropped, accepted, position }) => {},
  onDragCancel: ({ active }) => {},
  onDragResume: ({ active }) => {},              // onBeforeDrop answered 'continue'
  onDragSettle: ({ active, over }) => {},        // its overlay has landed
});

Un monitor escucha todos los arrastres bajo su provider, empiecen donde empiecen: el panel de arriba registra los arrastres de la lista sin importarla. Se detiene cuando se desmonta el componente que lo llamó.

EventoCuándo
onDragStartEmpieza un arrastre.
onDragMoveEl arrastre se mueve.
onDragOverCambia el destino bajo el arrastre.
onBeforeDropSe suelta el arrastre. Su respuesta decide el drop: consulta decidir un drop.
onDragEndEl drop se completó. Las listas ordenables ya ejecutaron su onDrop y escribieron el nuevo orden. dropped es false cuando terminó sin ningún destino debajo.
onDragCancelEscape, un drop sobre un destino que lo rechaza, o un onBeforeDrop que respondió 'reject'.
onDragResumeonBeforeDrop respondió 'continue': no se soltó nada y el arrastre continúa.
onDragSettleEl overlay del arrastre ha aterrizado (o ha vuelto a su sitio, o lo atrapó el siguiente arrastre), con el evento que recibió onDragEnd u onDragCancel. Mientras tanto pueden haber empezado otros arrastres.

Cada evento lleva active, over, pointer, delta (el desplazamiento desde el inicio), position (dónde queda la caja arrastrada dentro de over, consulta arrastrar y soltar) y, para los ítems de una lista ordenable, source. Las formas completas están en la API de eventos.

onDragEnd es el sitio para el trabajo «después del drop»: guardar, limpiar la selección, analítica. El onDrop propio de una lista se ejecuta antes de su escritura, así que todavía ve el orden anterior.

Encontrar el destino

Elige una función y arrastra el pin o la tarjeta sobre los destinos. Las insignias muestran cómo los clasifica la función en este frame; la verde es el destino.

Live

Cargando la demo…

<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue';
import {
  DnDProvider,
  closestCenter,
  closestCorners,
  pointerFirst,
  pointerWithin,
  rectIntersection,
  type CollisionDetection,
  type DnDEndEvent,
  type Key,
} from '@vue-dnd-kit/core';
import Desk from './Desk.vue';
import { magnet } from './magnet';

const DETECTORS: Record<string, CollisionDetection> = {
  pointerFirst,
  pointerWithin,
  rectIntersection,
  closestCenter,
  closestCorners,
  magnet,
};
const names = Object.keys(DETECTORS);
const mode = ref('pointerFirst');

// The ranking of the current frame, best first: a wrapper around the chosen function
// records it, so the targets can show their rank.
const ranked = shallowRef<Key[]>([]);
const collision = computed<CollisionDetection>(() => {
  const detect = DETECTORS[mode.value]!;
  return (args) => (ranked.value = detect(args));
});

const result = ref('Drag the pin or the card over the targets');
const onEnd = ({ over }: DnDEndEvent) => {
  ranked.value = [];
  result.value = over ? `Dropped on ${over.id}` : 'Dropped on nothing';
};
const onCancel = () => {
  ranked.value = [];
  result.value = 'Cancelled';
};
</script>

<template>
  <DnDProvider :collision="collision" @drag-end="onEnd" @drag-cancel="onCancel">
    <div class="lab">
      <div class="modes" role="radiogroup" aria-label="Collision detection">
        <label v-for="name in names" :key="name" :class="{ on: mode === name }">
          <input v-model="mode" type="radio" :value="name" />
          {{ name }}
        </label>
      </div>
      <code class="call">&lt;DnDProvider :collision="{{ mode }}"&gt;</code>
      <Desk :ranked="ranked" />
      <p class="result" aria-live="polite">{{ result }}</p>
    </div>
  </DnDProvider>
</template>

<style scoped>
.lab {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
}

.modes {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 2px;
  max-width: 100%;
  padding: 3px;
  border-radius: 9px;
  background: color-mix(in srgb, currentColor 7%, transparent);
}

.modes label {
  padding: 5px 9px;
  border-radius: 6px;
  font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
  cursor: pointer;
}

.modes label.on {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
  font-weight: 600;
}

.modes label:has(input:focus-visible) {
  outline: 2px solid #42b883;
}

.modes input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.call {
  max-width: 100%;
  padding: 6px 10px;
  overflow-x: auto;
  border-radius: 8px;
  background: color-mix(in srgb, currentColor 6%, transparent);
  font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
  white-space: nowrap;
  color: #42b883;
}

.result {
  margin: 0;
  font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.6;
}
</style>

En cada frame de un arrastre, el provider le da a collision los destinos de drop en juego, y ella devuelve sus ids, el mejor primero. La biblioteca trae cinco funciones:

FunciónClasificaBuena para
pointerFirstLo que está bajo el puntero; si no hay nada, lo que solapa la caja arrastrada.El valor por defecto: precisa bajo el puntero, tolerante en los huecos.
pointerWithinSolo lo que está bajo el puntero, el más interior primero.Destinos precisos: un hueco no suelta en ningún sitio.
rectIntersectionCada destino que solapa la caja arrastrada: mayor priority, luego anidamiento más profundo, luego mayor solapamiento.Ítems grandes sobre destinos pequeños, cuando el puntero está en el borde del ítem.
closestCenterCada destino, por la distancia del centro de la caja al suyo.Siempre gana algo, incluso lejos: cuadrículas y listas de ítems del mismo tamaño.
closestCornersCada destino, por las distancias entre las cuatro esquinas.Columnas una al lado de otra (kanban), destinos de alturas muy distintas.

Bajo el puntero, la prueba de colisión respeta lo que ve el usuario: de los destinos superpuestos solo se acierta el de encima (front, nunca back donde se solapan), y de los anidados el más interior va primero (subfolder, luego folder).

Una función propia

magnet.ts
import type { CollisionDetection, Coordinates, Rect } from '@vue-dnd-kit/core';

/** How far the point is from the rect: 0 inside it. */
const gap = (rect: Rect, point: Coordinates) =>
  Math.hypot(
    Math.max(rect.left - point.x, 0, point.x - rect.right),
    Math.max(rect.top - point.y, 0, point.y - rect.bottom),
  );

/** What is under the pointer; in a gap, the nearest target within 40px. */
export const magnet: CollisionDetection = ({ hits, pointer, candidates }) => {
  if (hits.length) return hits;
  return candidates
    .filter((candidate) => gap(candidate.rect, pointer) <= 40)
    .sort((a, b) => gap(a.rect, pointer) - gap(b.rect, pointer))
    .map((candidate) => candidate.id);
};
ArgumentoQué contiene
pointerEl puntero, después de los modificadores.
overlayRectLa caja arrastrada tal como se muestra, null sin overlay.
candidatesCada destino en juego: id, rect (su parte visible, recortada por los contenedores desplazables), priority, depth (0 arriba del todo, mayor cuando está anidado).
hitsLos destinos bajo el puntero, según la prueba de colisión, el mejor primero.

Devuelve un array vacío para «ningún destino». Los destinos desactivados, los destinos dentro de los ítems arrastrados y los destinos ocultos por sus contenedores desplazables nunca llegan a la función. Un destino que rechaza el arrastre sigue compitiendo: sobre él, over.accepted es false y el drop se rechaza (un destino dentro de un ítem ordenable se aparta en su lugar, para que la lista ordene por encima). La demo envuelve la función elegida para registrar su clasificación: (args) => (ranked.value = detect(args)).

El provider amortigua los cambios que pide la función. Un destino que el arrastre acaba de abandonar no puede volver a ganar hasta que el puntero se haya movido 10 px, para que los bordes no parpadeen. Entre los destinos anidados en un padre (los huecos de una columna de tarjetas), se mantiene el último destino.

Cuándo una pulsación se convierte en arrastre

Una pulsación no arrastra de inmediato. activation define, por tipo de puntero (mouse, touch, pen), qué espera: una distance en px, un delay en ms (con una tolerance a partir de la cual la pulsación se abandona, para que la página pueda desplazarse), o lo que ocurra primero de los dos.

<DnDProvider :activation="{ mouse: { distance: 8 }, touch: { delay: 300, tolerance: 8 } }">
PunteroPor defecto
mouse, pen{ distance: 4 }: un clic sigue siendo un clic. Una pulsación de ratón sobre un asa (handle) empieza de inmediato, y sigue siendo un clic si no se mueve.
touch{ delay: 200, tolerance: 5 }: una pulsación larga, para que las listas sigan desplazándose bajo el dedo.

Un tipo que omitas conserva su valor por defecto. La activation propia de un elemento arrastrable gana a la de un área de arrastre, que gana a la del provider. Mientras una pulsación espera, useDnD().status es 'activating' y activation.progress va de 0 a 1; Escape o useDnD().cancel() la abandonan. Prueba las restricciones en vivo y dibuja un anillo de pulsación larga a partir del progreso en sensores.

Desplazamiento automático

Arrastra un canal hacia arriba o hacia abajo de la lista. Las franjas verdes son las zonas de borde: cuanto más te adentras en una, más rápido se desplaza la lista.

Live

Cargando la demo…

<script setup lang="ts">
import { computed, ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Channels from './Channels.vue';

const OPTIONS = {
  threshold: [0.1, 0.2, 0.4],
  maxSpeed: [10, 25, 50],
  acceleration: [4, 10, 25],
} as const;

const on = ref(true);
// The defaults: an edge zone of 20% of the container (24 to 96px), 10px a frame at the
// edge, up to 25px a frame past it.
const options = ref({ threshold: 0.2, maxSpeed: 25, acceleration: 10 });
const autoScroll = computed(() => (on.value ? options.value : false));
const call = computed(() =>
  on.value
    ? `:auto-scroll="{ threshold: ${options.value.threshold}, maxSpeed: ${options.value.maxSpeed}, acceleration: ${options.value.acceleration} }"`
    : ':auto-scroll="false"',
);
</script>

<template>
  <DnDProvider :auto-scroll="autoScroll">
    <div class="demo">
      <div class="switches">
        <label class="switch">
          <input v-model="on" type="checkbox" />
          autoScroll
        </label>
        <fieldset v-for="(values, name) in OPTIONS" :key="name" :disabled="!on">
          <legend>{{ name }}</legend>
          <label v-for="value in values" :key="value" :class="{ on: options[name] === value }">
            <input v-model="options[name]" type="radio" :value="value" />
            {{ value }}
          </label>
        </fieldset>
      </div>
      <code class="call">{{ call }}</code>
      <Channels :threshold="on ? options.threshold : 0" />
    </div>
  </DnDProvider>
</template>

<style scoped>
.demo {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.switches {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 10px 14px;
}

fieldset {
  display: inline-flex;
  margin: 0;
  padding: 3px;
  border: 0;
  border-radius: 9px;
  background: color-mix(in srgb, currentColor 7%, transparent);
}

fieldset:disabled {
  opacity: 0.4;
}

legend {
  float: left;
  align-self: center;
  padding: 0 8px 0 6px;
  font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.55;
}

fieldset label {
  padding: 5px 9px;
  border-radius: 6px;
  font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
  cursor: pointer;
}

fieldset label.on {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
  font-weight: 600;
}

fieldset label:has(input:focus-visible),
.switch:has(input:focus-visible) {
  outline: 2px solid #42b883;
}

fieldset input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.switch {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 10px;
  border-radius: 9px;
  background: color-mix(in srgb, currentColor 7%, transparent);
  font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
  cursor: pointer;
}

.switch input {
  accent-color: #42b883;
}

.call {
  max-width: 100%;
  padding: 6px 10px;
  overflow-x: auto;
  border-radius: 8px;
  background: color-mix(in srgb, currentColor 6%, transparent);
  font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
  white-space: nowrap;
  color: #42b883;
}
</style>

Cualquier elemento con overflow: auto o scroll se desplaza solo, y la ventana también. El provider desplaza primero el contenedor bajo el puntero, luego sus ancestros y luego la ventana.

OpciónPor defectoQué ajusta
threshold0.2La zona de borde, como fracción del tamaño del contenedor, entre 24 y 96 px.
acceleration10La velocidad justo en el borde, en px por frame. Crece con el cuadrado de la profundidad dentro de la zona, así que el arranque es lento y preciso.
maxSpeed25La velocidad máxima. Más allá del borde (el puntero fuera del contenedor), la velocidad sigue creciendo hasta ella.

:auto-scroll="false" lo desactiva. Una dirección solo se desplaza cuando el puntero se ha movido hacia ella, así que un ítem agarrado cerca de un borde no se desplaza solo. Las listas, los árboles y las áreas de arrastre aceptan su propio autoScroll para su contenedor:

makeSortableList(list, { items: channels, autoScroll: { maxSpeed: 10 } });

Los arrastres con teclado nunca usan el desplazamiento automático: en su lugar desplazan el ítem hasta hacerlo visible. isAutoScrolling (de useDnD(), o de un área de arrastre) es true mientras algo se desplaza.

Vibración y la tecla de copia

Un arrastre táctil vibra brevemente al empezar. :haptics="false" lo desactiva; un objeto ajusta cada vibración:

<DnDProvider :haptics="{ start: true, enter: 10, drop: 10, refuse: [20, 40, 20] }">
ClaveVibra cuandoPor defecto
startLa pulsación larga se convierte en arrastre.true (10 ms)
enterEl arrastre entra en un destino que lo acepta.desactivado
dropEl drop se completa.desactivado
refuseEl drop se rechaza y los ítems vuelven a su sitio.desactivado

Cada una acepta true (el patrón por defecto), una duración en ms, un patrón de encendido y apagado, o false. Solo vibran los arrastres iniciados con el dedo, y Safari en iOS no puede vibrar en absoluto.

copyModifier es la tecla que, mantenida durante un arrastre entre listas, convierte el movimiento en una copia: 'Alt' por defecto, cualquier KeyboardEvent.key, o null para ninguna. La lista de la que salen los ítems necesita una función clone; consulta listas que comparten ítems.

Varios providers

La mayoría de las apps necesitan exactamente uno. Pon la lista de deseos bajo su propio provider e intenta mover un libro al otro lado: no puede salir de su provider, y el monitor exterior deja de oír la lista de deseos.

Live

Cargando la demo…

<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider } from '@vue-dnd-kit/core';
import Shelf from './Shelf.vue';
import EventLog from './EventLog.vue';

const reading = ref([
  { id: 'dune', title: 'Dune', hue: 32 },
  { id: 'emma', title: 'Emma', hue: 330 },
  { id: 'ulysses', title: 'Ulysses', hue: 210 },
]);
const wishlist = ref([
  { id: 'beloved', title: 'Beloved', hue: 140 },
  { id: 'solaris', title: 'Solaris', hue: 260 },
]);

const separate = ref(false);
</script>

<template>
  <DnDProvider>
    <div class="demo">
      <div class="modes" role="radiogroup" aria-label="Wishlist">
        <label :class="{ on: !separate }"><input v-model="separate" type="radio" :value="false" />one provider</label>
        <label :class="{ on: separate }"><input v-model="separate" type="radio" :value="true" />wishlist in its own</label>
      </div>
      <div class="columns">
        <div class="column">
          <Shelf id="reading" v-model="reading" title="Reading" />
          <EventLog title="outer monitor" />
        </div>
        <!-- A provider inside a provider: its items belong to it, and drags never cross. -->
        <DnDProvider v-if="separate">
          <div class="column island">
            <span class="tag">&lt;DnDProvider&gt;</span>
            <Shelf id="wishlist" v-model="wishlist" title="Wishlist" />
            <EventLog title="inner monitor" />
          </div>
        </DnDProvider>
        <div v-else class="column">
          <Shelf id="wishlist" v-model="wishlist" title="Wishlist" />
        </div>
      </div>
    </div>
  </DnDProvider>
</template>

<style scoped>
.demo {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
}

.modes {
  display: inline-flex;
  padding: 3px;
  border-radius: 9px;
  background: color-mix(in srgb, currentColor 7%, transparent);
}

.modes label {
  padding: 5px 10px;
  border-radius: 6px;
  font-size: 12px;
  cursor: pointer;
}

.modes label.on {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
  font-weight: 600;
}

.modes label:has(input:focus-visible) {
  outline: 2px solid #42b883;
}

.modes input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.columns {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 230px));
  gap: 16px;
  max-width: 100%;
}

.column {
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-width: 0;
}

.island {
  position: relative;
  margin: -10px;
  padding: 10px;
  border: 1.5px dashed color-mix(in srgb, #42b883 60%, transparent);
  border-radius: 16px;
}

.tag {
  position: absolute;
  top: -9px;
  right: 14px;
  padding: 0 6px;
  background: Canvas;
  font: 10px/18px ui-monospace, Menlo, Consolas, monospace;
  color: #42b883;
}
</style>
<DnDProvider>
  <Shelf v-model="reading" />
  <DnDProvider>
    <Shelf v-model="wishlist" />
    <EventLog />            <!-- hears the inner provider's drags only -->
  </DnDProvider>
</DnDProvider>
  • Los providers son independientes: un arrastre nunca pasa de uno a otro.
  • Se permiten providers anidados. Gana el dueño más cercano: un ítem pertenece al provider más cercano por encima de él, y el provider exterior lo ignora.
  • Cada provider tiene su propia selección, su overlay, su región viva y sus monitores.

Un segundo provider solo tiene sentido para una parte de la página que necesita sus propios ajustes a nivel de provider (colisión, teclas del teclado, anuncios) y cuyos ítems nunca salen de ella. Si los ítems no pueden moverse entre dos partes de tu página, comprueba que no estén bajo dos providers.

Las reglas para una parte de la página (un tablero que se ajusta a una cuadrícula, un feed con una pulsación táctil más larga, un panel donde no se arrastra nada) no necesitan su propio provider:
makeDragArea(board, { modifiers: [snapToGrid(20)], activation: { touch: { delay: 400 } } });
Sus ítems se quedan en el provider de la app y pueden seguir moviéndose a cualquier sitio. Consulta las restricciones.

Una app montada en otro sitio, dentro de un shadow root o como una app de Vue aparte, puede unirse a un provider existente: pasa useDnDContext() a provideDnDContext(app, context) antes de montarla. La prueba de colisión y el overlay funcionan a través de los shadow roots.

SSR y Nuxt

El provider es seguro para SSR. Las listas y los ítems se renderizan en el servidor como cualquier otro marcado. El overlay y la región viva se teletransportan y se montan solo en el cliente, así que nunca causan un error de hidratación. Los ids por defecto vienen de useId() de Vue y coinciden entre servidor y cliente.

En Nuxt, el módulo registra DnDProvider globalmente e importa automáticamente los composables: consulta la instalación.