v3 — Arrastar e soltar para Vue 3
01Kanban

Quadros que trocam cards

02Árvores

Aninhamento com um empurrãozinho

03Multi-drag

Muitos itens, um movimento

04Ordenável

Listas que se ordenam sozinhas

05Animado

Movimento por padrão

06A11y

Teclado de primeira classe

07Virtual

Dez mil linhas

Arraste os cards — ou chegue a um com Tab e use as setas

Arrastar e soltar,
feito para Vue.

Listas ordenáveis, quadros kanban, árvores e multi-drag para Vue 3 — com animações, suporte a teclado e acessibilidade de fábrica.

npm i @vue-dnd-kit/core
Começar

Gratuito e open source. Licença MIT.

00Como funciona

Composables,
não componentes.

Sua marcação, seus estilos. Passe um template ref a um composable e o elemento é arrastado, ordenado e solto — com a animação, o teclado e o leitor de tela já resolvidos para você.

  • 01makeSortableListListas, grades e colunas de kanban que se reordenam enquanto você arrasta — e trocam itens dentro de um grupo.
  • 02makeSortableTreeDados aninhados, renderizados de forma plana: profundidade com um empurrãozinho para o lado, drops dentro de pastas, virtualização.
  • 03makeDraggable · makeDroppableTodo o resto: quadros, canvas, uploads — as suas próprias regras para o que vai aonde.

01Arrastar e soltar

Arrastar e soltar

Um draggable, um alvo de drop e uma regra para o que vai aonde. Os alvos se destacam quando aceitam o arraste e recusam o que não aceitam; o que um drop faz fica por sua conta.

Documentação
Live

Carregando a 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>

02Ordenável

Listas e grades

Renderize um rascunho do seu array e ele se reordena ao vivo enquanto você arrasta. No drop, a nova ordem é gravada no seu ref. Vertical, horizontal ou uma grade de qualquer tamanho.

Documentação
Live

Carregando a 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

Quadros kanban

Listas de um mesmo grupo trocam itens. Copie de uma paleta, receba draggables simples ou troque de lugar em vez de deslocar — a lista que recebe abre espaço enquanto você passa por cima.

Documentação
Live

Carregando a 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Árvores

Árvores

Renderizadas de forma plana com profundidades, então podem ser virtualizadas. Arraste para o lado para mudar o nível ou pare sobre uma pasta para soltar dentro dela; limite a profundidade e decida o que se aninha onde.

Documentação
Live

Carregando a 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

Multi-drag

Selecione com Ctrl/Cmd-clique ou desenhe um retângulo, depois arraste um item: a seleção inteira viaja junto e pousa em ordem, até entre listas.

Documentação
Live

Carregando a 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>

06Restrições

Eixos, limites e grades

Uma área de arraste define as regras para tudo o que é arrastado dentro dela: trave um eixo, mantenha os arrastes dentro das bordas, encaixe numa grade — e mude tudo isso com o app rodando.

Documentação
Live

Carregando a 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>

07E mais

Pronto para apps de verdade.

01

Animado

FLIP durante a ordenação, um drop que pousa na sua posição, mudanças de dados animadas. Traga GSAP ou motion se quiser.

02

Acessível

Arrastes por teclado de posição em posição, anúncios ao vivo e atributos ARIA que nunca sobrescrevem os seus.

03

Listas virtuais

TanStack Virtual e VueUse: milhares de linhas, ainda ordenáveis, ainda animadas.

04

Controlado

Pinia, um servidor ou dados imutáveis: pule a gravação padrão e aplique o resultado você mesmo.

08Ecossistema

Um núcleo pequeno, algumas ferramentas afiadas.

Tudo se arrasta com o núcleo. Adicione o resto quando precisar.

01
@vue-dnd-kit/devtools

DevTools

Uma aba no Vue DevTools e no Nuxt DevTools: um minimapa ao vivo, um joystick que arrasta qualquer coisa, opções que você edita com o app rodando.

02
@vue-dnd-kit/actions

Arrastes programáticos

“Mover para cima”, “mover para Concluído”, tours guiados e testes — arrastes de verdade, anunciados e animados, a partir do código.

03
@vue-dnd-kit/nuxt

Módulo Nuxt

Composables importados automaticamente, componentes globais e padrões seguros para SSR.

04
npx skills add

Para agentes de IA

Uma agent skill que ensina ao Claude Code e a outros assistentes como a biblioteca funciona.

npx skills add ZiZIGY/vue-dnd-kit

01

1.243

Testes end-to-end em três navegadores

02

469

Testes unitários

03

21KB

Draggables e zonas de drop, com gzip

04

9

Idiomas desta documentação