TypeScriptドラッグ&ドロップ発展:ゴースト要素でカンバンボードを実装する
HTML5 Drag and Drop API入門の発展編です。マウスイベント(mousedown・mousemove・mouseup)を使ってゴースト要素をカーソルに追従させる実装を紹介します。

HTML5 Drag APIとの違い
HTML5 Drag and Drop APIはブラウザがドラッグ画像の描画を担当するため、見た目のカスタマイズに限界があります。マウスイベントで自前実装すると:
- ゴースト要素がカーソルにぴったり追従する
- ドラッグ中のスタイルを自由に制御できる
canvasやSVG要素も問題なくドラッグできる
HTML構造
<div class="container"> <div class="list" id="todo"> <h3>Todo</h3> <div class="item">デザインレビュー</div> <div class="item">APIの実装</div> <div class="item">テストを書く</div> </div> <div class="list" id="done"> <h3>Done</h3> </div> </div>
CSS:ドラッグ中のスタイル
.item { cursor: grab; user-select: none; } .list.drag-over { border-color: #4c9aff; background: #e8f4ff; }
user-select: none でドラッグ中にテキストが選択されるのを防ぎます。
mousedown:ドラッグ開始とゴースト生成
let dragging: HTMLElement | null = null let ghost: HTMLElement | null = null let offsetX = 0 let offsetY = 0 document.querySelectorAll<HTMLElement>('.item').forEach(item => { item.addEventListener('mousedown', (e: MouseEvent) => { e.preventDefault() // テキスト選択を防ぐ dragging = item const rect = item.getBoundingClientRect() offsetX = e.clientX - rect.left offsetY = e.clientY - rect.top // 元アイテムのクローンをゴーストとして追加 ghost = item.cloneNode(true) as HTMLElement ghost.style.position = 'fixed' ghost.style.width = rect.width + 'px' ghost.style.left = rect.left + 'px' ghost.style.top = rect.top + 'px' ghost.style.margin = '0' ghost.style.opacity = '0.85' ghost.style.pointerEvents = 'none' // マウスイベントを透過させる ghost.style.zIndex = '100' document.body.appendChild(ghost) item.style.opacity = '0.3' // 元アイテムを半透明に }) })
getBoundingClientRect() でアイテムの位置を取得し、クリックした位置からのオフセットを記録します。ゴーストに pointerEvents: none を設定しないと mouseup 時にゴーストが邪魔になります。
mousemove:ゴーストを追従させる
document.addEventListener('mousemove', (e: MouseEvent) => { if (!ghost) return ghost.style.left = (e.clientX - offsetX) + 'px' ghost.style.top = (e.clientY - offsetY) + 'px' // ドロップ先をハイライト document.querySelectorAll<HTMLElement>('.list').forEach(list => { const rect = list.getBoundingClientRect() const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom list.classList.toggle('drag-over', inside) }) })
elementFromPoint() ではなく座標比較でハイライト判定をしているのは、ゴーストが pointerEvents: none でも elementFromPoint() の結果に影響しないようにするためです。
mouseup:ドロップ処理
document.addEventListener('mouseup', (e: MouseEvent) => { if (!dragging || !ghost) return // ゴーストを一時的に隠してドロップ先を判定 ghost.style.visibility = 'hidden' const el = document.elementFromPoint(e.clientX, e.clientY) const list = el?.closest<HTMLElement>('.list') dragging.style.opacity = '' if (list) list.appendChild(dragging) document.body.removeChild(ghost) ghost = null dragging = null document.querySelectorAll('.list').forEach(l => l.classList.remove('drag-over')) })
elementFromPoint() 直前にゴーストを visibility: hidden にすることで、ゴーストの下にある要素を正しく取得できます。
TypeScriptの型まとめ
| 値 | 型 |
|---|---|
mousedown / mousemove / mouseup のイベント |
MouseEvent |
getBoundingClientRect() の戻り値 |
DOMRect |
cloneNode(true) の戻り値(キャスト必要) |
HTMLElement |
elementFromPoint() の戻り値 |
Element | null |
closest('.list') の戻り値 |
Element | null |
HTML5 Drag APIとの比較
| 項目 | HTML5 Drag API | マウスイベント実装 |
|---|---|---|
| コード量 | 少ない | 多い |
| ゴースト追従 | ブラウザ任せ(カスタム困難) | 自前で自由に制御 |
canvas・SVG |
動作不安定 | 問題なし |
| タッチデバイス | △(touch-action 要調整) |
pointermove に変えれば対応可 |
| 向いている場面 | シンプルな並び替え | リッチなカンバン・エディタ |
まとめ
| やること | コード |
|---|---|
| ドラッグ開始 | mousedown でゴースト生成・オフセット記録 |
| 追従 | mousemove でゴーストの left/top を更新 |
| ドロップ先判定 | ゴーストを visibility: hidden にしてから elementFromPoint() |
| ドロップ | mouseup でゴースト削除・アイテムを移動 |
pointerEvents: noneをゴーストに設定しないとmouseupが拾えないelementFromPoint()の前にゴーストを隠すのが判定の要- タッチ対応するなら
mousedown→pointerdown、mousemove→pointermove、mouseup→pointerupに置き換える