라이브러리는 거의 아무것도 스타일링하지 않습니다. 드래그의 각 상태는 두 가지 방법으로 전달됩니다.
일반 CSS를 위한 data-dnd-* 속성, 그리고 클래스와 조건부 마크업을 위해 컴포저블이 반환하는 ref입니다.
스택에 맞는 쪽을 쓰거나 둘 다 쓰세요.
data 속성
아래 행에는 상태 클래스가 전혀 없습니다. 모든 모양은 속성에서 옵니다. 패널은 페이지의 속성이 생기고 사라지는 대로 나열합니다. Ctrl이나 Meta로 행 두 개를 선택해 드래그하면 멀티 드래그의 나머지 항목도 나타납니다.
데모 불러오는 중…
<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>| 속성 | 위치 | 시점 |
|---|---|---|
data-dnd-placeholder | 정렬 가능한 항목 | 드래그 중인 항목이 안착할 자리일 때. 드래그가 항목 안의 드롭 대상 위에 있는 동안에는 "into": 드롭은 그 대상으로 가고, 자리는 위치만 지킵니다. |
data-dnd-dragging | 정렬 가능한 항목 | 드래그되지만 제자리에 남을 때: 'gaps' 모드에서 멀티 드래그의 나머지 항목. |
data-dnd-dragging="pointer" 또는 "keyboard" | <html> | 드래그 내내. |
data-dnd-overlay="dragging" 또는 "dropping" | 오버레이 박스 | 항상. 안착하는 동안과 비동기 onBeforeDrop을 기다리는 동안은 dropping. |
data-dnd-overlay-content | 오버레이의 콘텐츠 | 항상. 박스를 채우고 보여 줄 내용을 담는 요소. 직접 애니메이션하면 되며, 프로바이더의 overlayClass가 붙습니다. |
data-dnd-leaving | animateChanges가 남긴 복사본 | 사라진 항목이 서서히 사라지는 동안. |
data-dnd-no-clone | 직접 설정 | 요소를 오버레이 복사본에서 뺍니다: 툴팁, 메뉴, 배지. |
/* 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);
}
<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>
- 오버레이 안의 행 복사본은 Vue의 스코프 속성을 포함해 클래스와 속성을 유지하므로, 행 자체의
<style scoped>에서[data-dnd-overlay] .row가 동작합니다. 복사본에는 자체data-dnd-*표시가 없습니다. 자리의 복사본은 자리가 아닙니다. - 선택에는 속성이 없습니다.
selectedref를 속성(위의aria-selected)에 반영하거나 클래스를 쓰세요. - 일반 draggable과 드롭 대상(
makeDraggable,makeDroppable)은data-dnd-*상태 속성을 받지 않습니다. ref로 스타일링하세요.
반환되는 ref
같은 상태를 ref로 받고, 리스트와 대상에만 있는 상태도 받습니다. 아래의 각 행과 리스트는 지금 참인 ref를 출력하며, 클래스도 그 ref에서 옵니다. Icebox는 긴급 작업을 거부합니다. 하나를 그 위로 드래그해 보세요.
데모 불러오는 중…
<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><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>
<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>
| 컴포저블 | 상태 |
|---|---|
makeSortableItem | isDragging, isPlaceholder, selected, indicator, index |
makeSortableList, makeSortableTree | isOver, isAccepting, indicator, activeKey, activeIndex |
makeDraggable | isDragging, selected, transform |
makeDroppable | isOver, isAccepting, placement |
makeDragArea | isDragging, isAutoScrolling |
useDnD() | status, active, over, pointer, activation.progress, isAutoScrolling |
isPlaceholder: 이 요소가 드래그 중인 항목이 안착할 자리입니다.'group'모드의 멀티 드래그에서는 함께 이동하는 항목의 자리도 플레이스홀더입니다.isDragging: 항목이 드래그에 포함됩니다. 멀티 드래그에서는 드래그 중인 모든 항목입니다.isAccepting: 무언가를 드래그하는 중이고 이 대상이 그것을 받습니다. 드롭할 수 있는 곳을 강조하세요.isOver: 드래그가 이 대상 위에 있습니다. 받든 안 받든 마찬가지입니다.isOver && !isAccepting은 거부이므로 표시하세요.indicator: 인디케이터 모드에서 드롭 선이 들어갈 위치.activation.progress: 터치 길게 누르기나delay가 진행되는 동안 0에서 1까지. 링을 채우는 데 쓰세요.
드롭 대상: 받기와 거부
드래그 중에 대상은 네 가지 모습을 가집니다. 드래그를 받음, 받으면서 포인터가 위에 있음, 거부함, 거부하면서 포인터가 위에 있음. 네 가지 모두 보여 주고, 드래그 중인 항목에도 표시하세요.
데모 불러오는 중…
<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>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.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 */
드래그 중인 색상 견본은 프로바이더의 #overlay 슬롯입니다. 슬롯은 over와 accepted를 받으며,
드래그를 거부하는 대상 위에서 accepted는 false입니다.
<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>
오버레이 밖에서는 useDnD().over.value?.accepted가 같은 것을 알려 줍니다. 오버레이 컴포넌트는
오버레이에서 자세히 다룹니다.
오버레이와 페이지
나머지 드래그 상태는 두 속성이 다룹니다. 오버레이 박스의 data-dnd-overlay, 그리고 <html>의
data-dnd-dragging("pointer" 또는 "keyboard")입니다. 메모 위의 바는 후자를 읽고 어떤 키가
동작하는지 알려 줍니다. 느린 저장을 켜 보세요. 오버레이는 onBeforeDrop이 resolve될 때까지 놓은
자리에서 dropping 상태로 머물며 이를 보여 줍니다.
데모 불러오는 중…
<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>.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; }
[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은 안착과 비동기onBeforeDrop의 대기를 모두 포함합니다. 기다리는 동안<html>은 드래그 모드에서 벗어납니다. 페이지의 커서와 텍스트 선택이 돌아오므로 확인 다이얼로그가 평소처럼 동작합니다.- 박스 안의 내용을 스타일링하세요. 박스 자체는 라이브러리가
transform으로 움직이고width와height로 크기를 정합니다. 그 콘텐츠 요소인[data-dnd-overlay-content]는 박스를 채우며 자유롭게 다룰 수 있습니다. 오버레이 전체를 들어 올리고 기울이는 것은 여기서 하세요 (애니메이션 참고). - 복사본의 루트에는
transform: none이 인라인으로 있습니다. 회전이나 확대는rotate와scale로 하세요. 트랜지션과 키프레임 모두 동작합니다. 애니메이션을 참고하세요. - 복사본은 요소를 있는 그대로 보여 주며 배경을 추측하지 않습니다. 리스트가 배경을 칠하는 행(흰
<ul>안의<li>, 표의 행)에는 배경이 필요합니다:[data-dnd-overlay] .row { background: Canvas; }. - 복사본은 요소가 상속받던 사용자 정의 속성(테마 색상,
--tw-*)을 지닌<div>안에 놓이므로, 오버레이 안에서도 그 속성이 적용되고 복사본 자체의 규칙(in-data-[dnd-overlay]:ring-2)도 여전히 그보다 우선합니다. 복사본은 박스 안의 깊이가 아니라 클래스로 선택하세요.
기본 스타일
프로바이더는 문서마다 한 번 몇 가지 규칙을 주입합니다. 규칙은 :where()로 감싸져 명시도가 0이므로,
직접 작성한 어떤 규칙이든 우선합니다.
: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;
}
| 규칙 | 이유 |
|---|---|
| 자리의 불투명도 40% | CSS가 전혀 없어도 리스트가 제대로 보이도록. 위의 모든 데모가 이를 덮어씁니다. |
| 남겨진 항목의 불투명도 40% | 같은 이유로, 'gaps' 모드의 멀티 드래그용. |
user-select 없음 | 드래그가 지나가는 텍스트를 선택하지 않습니다. |
overflow-anchor 없음 | 움직이는 행을 보이게 하려고 페이지가 스크롤되지 않습니다. |
| 드래그 커서 | 포인터 드래그 중에는 어디서나. 기본은 grabbing이지만, 집은 요소에 자체 커서가 있으면 다릅니다. 크기 조절 핸들의 ns-resize, move, 이미지는 드래그 내내 자기 커서를 유지합니다(grab, pointer 등은 그대로 grabbing으로 바뀝니다). draggable 항목의 cursor: grab은 직접 추가하세요. 다른 기본값을 쓰려면 <html>에 --dnd-cursor를 설정하세요. |
| 터치 콜아웃 없음 | iOS가 길게 누를 때 링크 메뉴를 열지 않습니다. |
프로바이더에 :default-styles="false"를 주면 이 규칙을 넣지 않습니다. 라이브러리는 여전히 <html>을
표시하고, 드래그가 시작될 때 텍스트 선택을 지우고 드래그가 끝날 때까지 새 선택을 막습니다. CSS만 사라집니다. 규칙은 이를 원하는
첫 번째 프로바이더가 한 번 주입하고 계속 남으므로, 페이지의 모든 프로바이더에서 끄세요.
<DnDProvider :default-styles="false">
Tailwind CSS
속성은 변형(variant)으로 동작합니다. 요소 자체에는 data-[…], 조상에는 임의 변형을 쓰세요.
<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 }"
>
<p class="hidden [html[data-dnd-dragging=keyboard]_&]:block">Arrows move · Space drops</p>
vue-dnd-kit에 있습니다. Tailwind v4의 유틸리티와 이를 기반으로 한 UI 키트도 레이어에 있고 그
레이어가 나중에 오므로, ! 없이도 우선합니다. 레이어 밖의 CSS는 어차피 우선합니다.Tailwind v4에서는 in-data-[dnd-overlay=dragging]:shadow-xl로 조상 변형을 더 짧게 쓸 수 있습니다.
:global()은 선택자 전체를 가져갑니다. :global(html[data-dnd-dragging]) .hint는
html[data-dnd-dragging]만으로 컴파일되어 페이지를 숨깁니다. 조상은 :global() 없이
html[data-dnd-dragging] .hint로 쓰세요. Vue는 마지막 부분에만 스코프를 붙입니다.UI 키트
- 컴포저블은 요소뿐 아니라 컴포넌트도 받습니다. 키트의 카드나 리스트 항목에
useTemplateRef를 쓰면 동작하며, 라이브러리는 그 루트 요소를 읽습니다. - 키트에 자체 props가 있으면 상태를 거기에 넘기고(
:active="selected",:color="isOver ? 'primary' : undefined"), 나머지는 클래스로 넘기세요. - 키트의 리스트 항목은 자체 배경이 없고 리스트가 칠하는 경우가 많습니다. 그러면 오버레이의 복사본이
비쳐 보이므로 배경을 주세요.
[data-dnd-overlay] .v-list-item { background: rgb(var(--v-theme-surface)); } - 복사본에서는 Vuetify, Quasar, PrimeVue, Naive UI의 누르기 물결 효과가 빠집니다. 함께 옮겨지면
안 되는 다른 것(툴팁, 메뉴 버튼)에는
data-dnd-no-clone을 표시하세요.
하지 말아야 할 것
- 정렬 가능한 항목의
transform이나translate에 CSStransition을 걸지 마세요. 라이브러리가 직접 애니메이션하며, 측정하는 동안 그런 트랜지션을 취소합니다. - 정렬 가능한 항목을
<TransitionGroup>으로 감싸지 마세요. 이동이 두 번 애니메이션됩니다. 리스트의animateChanges를 쓰세요. - 가상 리스트에서 행의
height,margin,padding을 애니메이션하지 마세요. - 드래그 주변에 추가한 트랜지션은
prefers-reduced-motion: reduce에서 멈추세요. 라이브러리는 자기 것을 이미 멈춥니다.