v3 — Vue 3를 위한 드래그 앤 드롭
01칸반

카드를 주고받는 보드

02트리

살짝 밀어서 중첩

03멀티 드래그

여러 항목을 한 번에

04정렬

스스로 정렬되는 리스트

05애니메이션

기본으로 움직입니다

06A11y

키보드도 일급 시민

07가상화

만 개의 행

카드를 드래그하거나, Tab으로 카드에 이동해 화살표 키를 사용하세요

Vue를 위한
드래그 앤 드롭.

Vue 3를 위한 정렬 가능한 리스트, 칸반 보드, 트리, 멀티 드래그. 애니메이션, 키보드 지원, 접근성을 기본으로 갖추었습니다.

npm i @vue-dnd-kit/core
시작하기

무료 오픈 소스. MIT 라이선스.

00작동 방식

컴포넌트가 아니라
컴포저블.

마크업도 스타일도 그대로입니다. 템플릿 ref를 컴포저블에 넘기면 요소가 드래그되고, 정렬되고, 드롭됩니다. 애니메이션, 키보드, 스크린 리더 대응은 라이브러리가 처리합니다.

  • 01makeSortableList드래그하는 동안 순서가 바뀌는 리스트, 그리드, 칸반 컬럼. 그룹 안에서 항목을 주고받습니다.
  • 02makeSortableTree평평하게 렌더링되는 중첩 데이터. 옆으로 살짝 밀어 깊이를 바꾸고, 폴더 안에 드롭하고, 가상화도 지원합니다.
  • 03makeDraggable · makeDroppable그 밖의 모든 것: 보드, 캔버스, 업로드. 무엇이 어디로 갈지는 직접 정합니다.

01드래그 앤 드롭

드래그 앤 드롭

draggable, 드롭 대상, 그리고 무엇이 어디로 갈지 정하는 규칙. 대상은 드래그를 받아들일 때 강조되고, 받지 않는 것은 거부합니다. 드롭이 무엇을 할지는 직접 정합니다.

문서
Live

데모 불러오는 중…

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

02정렬

리스트와 그리드

배열의 초안을 렌더링하면 드래그하는 동안 실시간으로 순서가 바뀝니다. 드롭하면 새 순서가 ref에 기록됩니다. 세로, 가로, 어떤 크기의 그리드든 됩니다.

문서
Live

데모 불러오는 중…

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

03칸반

칸반 보드

한 그룹의 리스트끼리 항목을 주고받습니다. 팔레트에서 복사하고, 일반 draggable을 받아들이고, 밀어내는 대신 맞바꿀 수 있습니다. 받는 리스트는 위에 올리는 동안 자리를 내줍니다.

문서
Live

데모 불러오는 중…

<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트리

트리

깊이와 함께 평평하게 렌더링되므로 가상화할 수 있습니다. 옆으로 드래그해 단계를 바꾸거나 폴더 위에 머물러 안에 드롭하세요. 깊이를 제한하고 무엇이 어디에 중첩될지 정할 수 있습니다.

문서
Live

데모 불러오는 중…

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

05멀티 드래그

멀티 드래그

Ctrl/Cmd-클릭하거나 사각형을 그려 선택한 뒤 하나를 드래그하세요. 선택 전체가 함께 이동해 순서대로 안착합니다. 리스트를 넘나들어도 마찬가지입니다.

문서
Live

데모 불러오는 중…

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

06제약

축, 경계, 그리드

드래그 영역은 그 안에서 드래그되는 모든 것의 규칙을 정합니다. 축을 고정하고, 가장자리 안에 가두고, 그리드에 스냅하세요. 앱이 실행되는 동안 바꿀 수도 있습니다.

문서
Live

데모 불러오는 중…

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

07그리고 더

실제 앱에 바로 쓸 수 있습니다.

01

애니메이션

정렬 중의 FLIP, 제자리에 안착하는 드롭, 애니메이션되는 데이터 변경. 원한다면 GSAP이나 motion을 쓰세요.

02

접근성

자리 단위로 움직이는 키보드 드래그, 실시간 안내, 직접 지정한 값을 덮어쓰지 않는 ARIA 속성.

03

가상 리스트

TanStack Virtual과 VueUse: 수천 개의 행도 여전히 정렬되고, 여전히 애니메이션됩니다.

04

제어 모드

Pinia, 서버, 불변 데이터: 기본 기록을 건너뛰고 결과를 직접 적용하세요.

08에코시스템

작은 코어, 몇 가지 날카로운 도구.

드래그는 코어만으로 됩니다. 나머지는 필요할 때 추가하세요.

01
@vue-dnd-kit/devtools

DevTools

Vue DevTools와 Nuxt DevTools의 탭: 실시간 미니맵, 무엇이든 드래그하는 조이스틱, 앱 실행 중에 편집하는 옵션.

02
@vue-dnd-kit/actions

프로그래밍 방식 드래그

“위로 이동”, “Done으로 이동”, 가이드 투어, 테스트. 코드에서 실행하는 진짜 드래그로, 안내되고 애니메이션됩니다.

03
@vue-dnd-kit/nuxt

Nuxt 모듈

자동 임포트되는 컴포저블, 전역 컴포넌트, SSR에 안전한 기본값.

04
npx skills add

AI 에이전트용

Claude Code와 다른 어시스턴트에게 라이브러리의 작동 방식을 가르치는 에이전트 스킬.

npx skills add ZiZIGY/vue-dnd-kit

01

1,243

세 가지 브라우저에서 실행하는 E2E 테스트

02

469

단위 테스트

03

21KB

draggable과 드롭 영역, gzip 기준

04

9

이 문서의 언어 수