Documentación/Guías

Estilos

Cada estado de un arrastre, con estilo — atributos data para CSS simple, refs para clases, destinos que dicen sí o no, el overlay y la página mientras arrastras, los estilos por defecto, Tailwind y kits de UI.

Editar esta página

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

La biblioteca casi no da estilo a nada. Cada estado de un arrastre te llega de dos formas: como un atributo data-dnd-*, para CSS simple, y como una ref que devuelve el composable, para clases y marcado condicional. Usa la que encaje con tu stack, o ambas.

Los atributos data

Las filas de abajo no tienen ninguna clase de estado: cada aspecto sale de un atributo. El panel muestra los atributos de la página a medida que aparecen y desaparecen. Selecciona dos filas con Ctrl o Meta y arrástralas: también aparecen los demás de un multi-drag.

Live

Cargando la demo…

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

const demo = useTemplateRef<HTMLElement>('demo');
</script>

<template>
  <DnDProvider>
    <div ref="demo" class="demo">
      <TaskList />
      <Inspector :scope="demo" />
    </div>
  </DnDProvider>
</template>

<style scoped>
.demo {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 20px;
}
</style>
AtributoEnCuándo
data-dnd-placeholderun ítem ordenableEs el hueco donde aterrizará un ítem arrastrado. "into" mientras el arrastre está sobre un destino de drop dentro de un ítem: el drop va ahí, y el hueco solo guarda su sitio.
data-dnd-draggingun ítem ordenableSe está arrastrando pero se queda en su sitio: los demás ítems de un multi-drag en modo 'gaps'.
data-dnd-dragging="pointer" o "keyboard"<html>Durante todo el arrastre.
data-dnd-overlay="dragging" o "dropping"la caja del overlaySiempre. dropping mientras aterriza, y mientras espera un onBeforeDrop asíncrono.
data-dnd-overlay-contentel contenido del overlaySiempre: el elemento que llena la caja y contiene lo que muestra. Animarlo te toca a ti; recibe el overlayClass del provider.
data-dnd-leavinguna copia dejada por animateChangesMientras se desvanece un ítem que se fue.
data-dnd-no-clonelo pones túDeja el elemento fuera de la copia del overlay: un tooltip, un menú, una insignia.
TaskRow.vue
/* The slot a dragged row will land in: a striped gap. */
.row[data-dnd-placeholder] {
  opacity: 1;
  background: repeating-linear-gradient(-45deg, color-mix(in srgb, #42b883 16%, transparent) 0 6px, transparent 6px 12px);
}

.row[data-dnd-placeholder] > * {
  visibility: hidden;   /* an empty slot of the row's exact size */
}

/* Selected rows left in place by a multi-drag (`multiple: 'gaps'`). */
.row[data-dnd-dragging] {
  opacity: 0.4;
}

/* The copy in the overlay, while it travels. */
[data-dnd-overlay='dragging'] .row {
  box-shadow: 0 0 0 1.5px #42b883, 0 14px 24px -10px rgb(0 0 0 / 0.55);
}
TaskRow.vue
<li ref="el" class="row" :aria-selected="selected">
  <span class="title">{{ task.title }}</span>
  <button type="button" data-dnd-no-clone @click.stop="task.done = !task.done">✓</button>
</li>
  • La copia de una fila en el overlay conserva sus clases y atributos, incluido el atributo de scope de Vue, así que [data-dnd-overlay] .row funciona en el propio <style scoped> de la fila. La copia no tiene marcas data-dnd-* propias: una copia del hueco no es un hueco.
  • La selección no tiene atributo. Refleja la ref selected en uno (aria-selected arriba), o usa una clase.
  • Los elementos arrastrables simples y los destinos de drop (makeDraggable, makeDroppable) no reciben atributos de estado data-dnd-*: dales estilo con sus refs.

Las refs devueltas

Los mismos estados como refs, más los que tienen las listas y los destinos. Cada fila y cada lista de abajo muestra las refs que son verdaderas en este momento, y sus clases salen de ellas. Icebox rechaza las tareas urgentes: arrastra una sobre ella.

Live

Cargando la demo…

<script setup lang="ts">
import { reactive } from 'vue';
import { DnDProvider, type SortablePutContext } from '@vue-dnd-kit/core';
import RefList from './RefList.vue';

const lists = reactive([
  {
    id: 'sprint',
    title: 'Sprint',
    group: { name: 'work' },
    tasks: [
      { id: 'login', title: 'Fix the login', urgent: true },
      { id: 'search', title: 'Search filters' },
      { id: 'avatars', title: 'Upload avatars' },
      { id: 'invoices', title: 'PDF invoices' },
    ],
  },
  {
    id: 'icebox',
    title: 'Icebox',
    // Urgent tasks can't go on ice.
    group: { name: 'work', put: ({ types }: SortablePutContext) => !types.includes('urgent') },
    tasks: [{ id: 'themes', title: 'Custom themes' }],
  },
]);
</script>

<template>
  <DnDProvider>
    <div class="lists">
      <RefList v-for="list in lists" :key="list.id" :list="list" />
    </div>
  </DnDProvider>
</template>

<style scoped>
.lists {
  display: flex;
  justify-content: center;
  gap: 12px;
}
</style>
RefRow.vue
<script setup lang="ts">
const { isDragging, isPlaceholder, selected } = makeSortableItem(el, { item: () => props.task });
</script>

<template>
  <li ref="el" class="row" :class="{ placeholder: isPlaceholder, dragging: isDragging, selected }">…</li>
</template>

<style scoped>
.row.selected { box-shadow: 0 0 0 1.5px #3b82f6; }
.row.placeholder { opacity: 1; box-shadow: inset 0 0 0 1.5px #42b883; }   /* beats the default 40% */
.row.placeholder .title { visibility: hidden; }
</style>
RefList.vue
<script setup lang="ts">
const { items, isOver, isAccepting } = makeSortableList(el, { items, group });
</script>

<template>
  <section :class="{ accepting: isAccepting, over: isOver && isAccepting, refused: isOver && !isAccepting }">…</section>
</template>
ComposableEstado
makeSortableItemisDragging, isPlaceholder, selected, indicator, index
makeSortableList, makeSortableTreeisOver, isAccepting, indicator, activeKey, activeIndex
makeDraggableisDragging, selected, transform
makeDroppableisOver, isAccepting, placement
makeDragAreaisDragging, isAutoScrolling
useDnD()status, active, over, pointer, activation.progress, isAutoScrolling
  • isPlaceholder: este elemento es el hueco donde aterrizará un ítem arrastrado. En un multi-drag en modo 'group', los huecos de los ítems que viajan con él también son placeholders.
  • isDragging: el ítem forma parte del arrastre; cada ítem arrastrado en un multi-drag.
  • isAccepting: se está arrastrando algo y este destino lo acepta. Resalta los sitios donde es posible un drop.
  • isOver: el arrastre está sobre este destino, lo acepte o no. isOver && !isAccepting es un rechazo: muéstralo.
  • indicator: dónde va la línea del drop en modo indicador.
  • activation.progress: de 0 a 1 mientras dura una pulsación larga táctil o un delay. Llena un anillo con él.

Destinos de drop: sí y no

Un destino tiene cuatro aspectos mientras dura un arrastre: acepta el arrastre, lo acepta y el puntero está encima, lo rechaza, lo rechaza y el puntero está encima. Muestra los cuatro, y dilo también en el ítem arrastrado.

Live

Cargando la demo…

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

const SWATCHES = [
  { id: 'orange', color: '#f97316', tone: 'warm' as const },
  { id: 'blue', color: '#3b82f6', tone: 'cool' as const },
  { id: 'stone', color: '#a8a29e', tone: 'neutral' as const },
  { id: 'red', color: '#ef4444', tone: 'warm' as const },
  { id: 'cyan', color: '#06b6d4', tone: 'cool' as const },
  { id: 'slate', color: '#475569', tone: 'neutral' as const },
  { id: 'amber', color: '#eab308', tone: 'warm' as const },
  { id: 'violet', color: '#8b5cf6', tone: 'cool' as const },
];
</script>

<template>
  <DnDProvider>
    <div class="studio">
      <div class="swatches">
        <Swatch v-for="swatch in SWATCHES" :key="swatch.id" :swatch="swatch" />
      </div>
      <div class="palettes">
        <Palette tone="warm" />
        <Palette tone="cool" />
        <Palette tone="neutral" />
      </div>
    </div>

    <!-- The dragged swatch says it too: over a palette, `accepted` tells yes from no. -->
    <template #overlay="{ active, over, accepted }">
      <div class="lifted" :class="{ yes: over && accepted, no: over && !accepted }" :style="{ background: (active.data as { color: string }).color }">
        <span v-if="over" class="badge">{{ accepted ? '+' : '✕' }}</span>
      </div>
    </template>
  </DnDProvider>
</template>

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

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

.palettes {
  display: flex;
  justify-content: center;
  gap: 10px;
  width: 100%;
}

/* Fills the overlay box, which has the swatch's size. */
.lifted {
  position: relative;
  width: 100%;
  height: 100%;
  border-radius: 10px;
  box-shadow: 0 12px 22px -8px rgb(0 0 0 / 0.55);
  transition:
    filter 0.15s,
    scale 0.15s;
}

.lifted.yes {
  scale: 1.12;
}

.lifted.no {
  filter: grayscale(1) brightness(0.7);
}

.badge {
  position: absolute;
  top: -7px;
  right: -7px;
  display: grid;
  place-items: center;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: #42b883;
  color: #fff;
  font: 700 12px/1 system-ui, sans-serif;
}

.no .badge {
  background: #f43f5e;
  font-size: 9px;
}
</style>
Palette.vue
const { isOver, isAccepting } = makeDroppable(el, { accept: props.tone, onDrop });

const { active } = useDnD();
const state = computed(() => {
  if (!active.value) return '';
  if (isOver.value) return isAccepting.value ? 'over' : 'refused';
  return isAccepting.value ? 'accepting' : 'refusing';
});
Palette.vue
.palette.accepting { border-style: dashed; border-color: #42b883; }          /* an invitation */
.palette.over { border-color: #42b883; background: color-mix(in srgb, #42b883 14%, Canvas); }
.palette.refusing { opacity: 0.45; }                                          /* out of the way */
.palette.refused { border-color: #f43f5e; animation: shake 0.3s; }            /* a drop here is cancelled */

La muestra arrastrada es el slot #overlay del provider. Recibe over y accepted: accepted es false sobre un destino que rechaza el arrastre.

index.vue
<template #overlay="{ active, over, accepted }">
  <div
    class="lifted"
    :class="{ yes: over && accepted, no: over && !accepted }"
    :style="{ background: (active.data as { color: string }).color }"
  >
    <span v-if="over" class="badge">{{ accepted ? '+' : '✕' }}</span>
  </div>
</template>

Fuera del overlay, useDnD().over.value?.accepted dice lo mismo. Más sobre los componentes de overlay en el overlay.

El overlay y la página

Dos atributos cubren el resto de un arrastre: data-dnd-overlay en la caja del overlay, y data-dnd-dragging en <html>, "pointer" o "keyboard". La barra que hay encima de las notas lee el segundo y dice qué teclas funcionan. Activa el guardado lento: el overlay se queda en dropping donde soltaste, hasta que se resuelve onBeforeDrop, y lo muestra.

Live

Cargando la demo…

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

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

<template>
  <DnDProvider>
    <div class="desk">
      <!-- `<html data-dnd-dragging="pointer | keyboard">` for the whole drag: the bar reads it in CSS. -->
      <p class="bar">
        <span class="idle">Drag a note to reorder</span>
        <span class="pointer">Release to drop · Esc cancels</span>
        <span class="keyboard">Arrows move · Space drops · Esc cancels</span>
      </p>
      <NoteBoard :slow-save="slowSave" />
      <label class="switch">
        <input v-model="slowSave" type="checkbox" />
        Save on drop (1 s)
      </label>
    </div>
  </DnDProvider>
</template>

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

.bar {
  margin: 0;
  padding: 7px 14px;
  border-radius: 99px;
  background: color-mix(in srgb, currentColor 7%, transparent);
  font: 12px/1 ui-monospace, Menlo, Consolas, monospace;
  transition:
    background 0.2s,
    color 0.2s;
}

.bar .pointer,
.bar .keyboard,
html[data-dnd-dragging] .bar .idle {
  display: none;
}

html[data-dnd-dragging] .bar {
  background: #42b883;
  color: #fff;
}

html[data-dnd-dragging='pointer'] .bar .pointer,
html[data-dnd-dragging='keyboard'] .bar .keyboard {
  display: inline;
}

.switch {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  cursor: pointer;
}

.switch input {
  accent-color: #42b883;
}
</style>
index.vue
.bar .pointer,
.bar .keyboard,
html[data-dnd-dragging] .bar .idle { display: none; }

html[data-dnd-dragging='pointer'] .bar .pointer,
html[data-dnd-dragging='keyboard'] .bar .keyboard { display: inline; }
StickyNote.vue
[data-dnd-overlay='dragging'] .note {
  box-shadow: 0 0 0 2px #42b883, 0 22px 30px -14px rgb(0 0 0 / 0.6);
}

/* Landing, and waiting for the save. */
[data-dnd-overlay='dropping'] .note { opacity: 0.85; }
[data-dnd-overlay='dropping'] .saving { display: flex; }
  • dropping cubre el aterrizaje y la espera de un onBeforeDrop asíncrono. Mientras espera, <html> sale del modo de arrastre: vuelven el cursor y la selección de texto de la página, así que un diálogo de confirmación funciona como siempre.
  • Da estilo a lo que hay dentro de la caja. La biblioteca mueve la propia caja con transform y le da tamaño con width y height. Su elemento de contenido, [data-dnd-overlay-content], la llena y es tuyo: levanta e inclina ahí el overlay entero (consulta animaciones).
  • La raíz de la copia tiene transform: none en línea: gírala o agrándala con rotate y scale. En ella funcionan tanto las transiciones como los keyframes: consulta animaciones.
  • La copia muestra el elemento tal como es y no adivina ningún fondo. Una fila que pinta la lista (un <li> en un <ul> blanco, una fila de tabla) necesita uno: [data-dnd-overlay] .row { background: Canvas; }.
  • La copia va dentro de un <div> que lleva las propiedades personalizadas que el elemento heredaba (colores del tema, --tw-*), así que siguen aplicándose en el overlay, y las reglas propias de la copia (in-data-[dnd-overlay]:ring-2) siguen ganándoles. Selecciona la copia por su clase, no por su profundidad dentro de la caja.

Estilos por defecto

El provider inyecta unas pocas reglas, una vez por documento. Van envueltas en :where(), así que su especificidad es cero y cualquier regla tuya gana:

:where([data-dnd-placeholder]) { opacity: 0.4; }
:where([data-dnd-dragging]:not(html)) { opacity: 0.4; }
:where(html[data-dnd-dragging]), :where(html[data-dnd-dragging] *) {
  user-select: none !important;
  overflow-anchor: none !important;
}
:where(html[data-dnd-dragging='pointer']), :where(html[data-dnd-dragging='pointer'] *) {
  cursor: var(--dnd-cursor, grabbing) !important;
  -webkit-touch-callout: none !important;
}
ReglaPor qué
El hueco al 40 %Para que una lista se vea bien sin nada de CSS. Todas las demos de arriba la sobrescriben.
Los ítems que se quedan atrás al 40 %Lo mismo, para un multi-drag en modo 'gaps'.
Sin user-selectUn arrastre no selecciona el texto por el que pasa.
Sin overflow-anchorLa página no se desplaza para mantener a la vista una fila en movimiento.
El cursor del arrastreDurante un arrastre de puntero, en todas partes. grabbing, a menos que el elemento agarrado tenga un cursor propio: el ns-resize de un asa de redimensionar, un move o una imagen conservan el suyo durante todo el arrastre (grab, pointer y similares se siguen convirtiendo en grabbing). cursor: grab en los ítems arrastrables lo añades tú. Pon --dnd-cursor en <html> para otro cursor por defecto.
Sin menú contextual táctiliOS no abre el menú de enlaces bajo una pulsación larga.

:default-styles="false" en el provider las omite. La biblioteca sigue marcando <html>, y sigue borrando la selección cuando empieza un arrastre e impidiendo nuevas hasta que termina: solo desaparece el CSS. Las reglas se inyectan una vez, el primer provider que las quiere, y se quedan: desactívalas en todos los providers de la página.

<DnDProvider :default-styles="false">

Tailwind CSS

Los atributos funcionan como variantes: data-[…] para el propio elemento, una variante arbitraria para un ancestro.

TaskRow.vue
<li
  ref="el"
  class="rounded-lg bg-white shadow-sm
         data-[dnd-placeholder]:bg-transparent data-[dnd-placeholder]:opacity-100
         data-[dnd-placeholder]:outline-dashed
         [[data-dnd-overlay=dragging]_&]:shadow-xl"
  :class="{ 'ring-2 ring-emerald-500': selected }"
>
Hint.vue
<p class="hidden [html[data-dnd-dragging=keyboard]_&]:block">Arrows move · Space drops</p>
Los estilos de la biblioteca (las opacidades del 40 %) están en una cascade layer propia, vue-dnd-kit, declarada antes que ninguna otra. Las utilidades de Tailwind v4 y los kits de UI construidos sobre él también viven en layers, que van después, así que ganan sin !. El CSS sin layer gana de todos modos.

En Tailwind v4, in-data-[dnd-overlay=dragging]:shadow-xl es una forma más corta de escribir la variante de ancestro.

En el CSS scoped de Vue, :global() se lleva el selector entero: :global(html[data-dnd-dragging]) .hint se compila como html[data-dnd-dragging] a secas, y oculta la página. Escribe el ancestro sin él, html[data-dnd-dragging] .hint: Vue solo aplica el scope a la última parte.

Kits de UI

  • Los composables aceptan tanto un componente como un elemento: useTemplateRef sobre una tarjeta o un ítem de lista de un kit funciona, y la biblioteca lee su elemento raíz.
  • Pasa el estado a las props propias del kit cuando las tenga (:active="selected", :color="isOver ? 'primary' : undefined"), y el resto como clases.
  • Los ítems de lista de un kit a menudo no tienen fondo propio: lo pinta la lista. Su copia en el overlay es entonces transparente. Dale uno:
    [data-dnd-overlay] .v-list-item { background: rgb(var(--v-theme-surface)); }
    
  • La copia deja fuera los ripples de pulsación de Vuetify, Quasar, PrimeVue y Naive UI. Marca cualquier otra cosa que no deba viajar, un tooltip o un botón de menú, con data-dnd-no-clone.

Qué no hacer

  • No pongas una transition CSS en el transform o el translate de los ítems ordenables: la biblioteca los anima ella misma, y cancela esas transiciones mientras mide.
  • No envuelvas los ítems ordenables en <TransitionGroup>: los movimientos se animarían dos veces. Usa el animateChanges de la lista.
  • No animes height, margin ni padding de las filas de una lista virtual.
  • Eso sí, detén bajo prefers-reduced-motion: reduce las transiciones que añadas alrededor de los arrastres. La biblioteca ya detiene las suyas.