アニメーション
並べ替え中の FLIP、自分の位置に着地するドロップ、アニメーションするデータの変更。お好みで GSAP や motion も。
カードをやり取りするボード
ひと押しでネスト
たくさんのアイテムを、一度に
ひとりでに並べ替わるリスト
デフォルトでアニメーション
キーボードもファーストクラス
1 万行
カードをドラッグしてください。または Tab でカードを選び、矢印キーで動かせます
Vue 3 のための並べ替え可能なリスト、カンバンボード、ツリー、マルチドラッグ。アニメーション、キーボード操作、アクセシビリティにすぐ対応。
無料のオープンソース。MIT ライセンス。
00仕組み
マークアップもスタイルもあなたのもの。テンプレート参照をコンポーザブルに渡せば、要素がドラッグ、並べ替え、ドロップできるようになります。アニメーション、キーボード、スクリーンリーダーへの対応は済ませてあります。
makeSortableListドラッグに合わせて並べ替わるリスト、グリッド、カンバンの列。グループ内でアイテムのやり取りも。makeSortableTree入れ子のデータをフラットに描画:横にずらして深さを変え、フォルダーにドロップし、仮想化にも対応。makeDraggable · makeDroppableそれ以外のすべて:ボード、キャンバス、アップロード。何をどこに置けるかは、あなたのルールで。01ドラッグ&ドロップ
ドラッグ可能な要素、ドロップターゲット、そして何をどこに置けるかのルール。ターゲットはドラッグを受け入れると光り、受け入れないものは拒否します。ドロップで何をするかはあなた次第です。
ドキュメントデモを読み込み中…
<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 に書き込まれます。縦、横、あらゆるサイズのグリッドに対応。
ドキュメントデモを読み込み中…
<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カンバン
同じグループのリストどうしでアイテムをやり取りします。パレットからのコピー、通常のドラッグ可能な要素の受け入れ、ずらす代わりの入れ替えも。受け取る側のリストは、ホバーに合わせて場所を空けます。
ドキュメントデモを読み込み中…
<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ツリー
深さ付きのフラットな形で描画するので、仮想化できます。横にドラッグして階層を変えたり、フォルダーにホバーして中にドロップしたり。深さを制限し、何をどこにネストできるかを決められます。
ドキュメントデモを読み込み中…
<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 クリックや枠で選んでから、1 つをドラッグします。選択全体がまとめて移動し、リストをまたいでも順番どおりに着地します。
ドキュメントデモを読み込み中…
<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制約
ドラッグエリアは、その中でドラッグされるすべてのもののルールを決めます:軸の固定、端の内側への制限、グリッドへのスナップ。しかもアプリの実行中に変更できます。
ドキュメントデモを読み込み中…
<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さらに
並べ替え中の FLIP、自分の位置に着地するドロップ、アニメーションするデータの変更。お好みで GSAP や motion も。
位置ごとに動かすキーボードドラッグ、ライブでの読み上げ、そしてあなたの設定を決して上書きしない ARIA 属性。
TanStack Virtual と VueUse:数千行でも、並べ替えもアニメーションもそのまま。
Pinia、サーバー、イミュータブルなデータ:デフォルトの書き込みをスキップして、結果を自分で適用できます。
08エコシステム
ドラッグに必要なものはすべてコアにあります。それ以外は必要になったときに追加してください。
Vue DevTools と Nuxt DevTools のタブ:ライブのミニマップ、何でもドラッグできるジョイスティック、アプリの実行中に編集できるオプション。
「上へ移動」「Done へ移動」、ガイドツアー、テスト。読み上げもアニメーションも伴う本物のドラッグを、コードから。
自動インポートされるコンポーザブル、グローバルコンポーネント、SSR セーフなデフォルト設定。
Claude Code などのアシスタントにライブラリの仕組みを教えるエージェントスキル。
01
1,243
3 つのブラウザーでのエンドツーエンドテスト
02
469
ユニットテスト
03
21KB
ドラッグ可能な要素とドロップゾーン(gzip 圧縮後)
04
9
このドキュメントの対応言語