ドラッグ元・ドロップ領域・ドラッグ中の表示・重なり判定設計とは?ドラッグ&ドロップユーザーインターフェース完全ガイド
ドラッグ&ドロップは、ユーザーが画面上の要素をつかみ、別の場所へ移動させるための操作です。見た目だけを見ると、カードを動かす、ファイルを置く、画像を並び替える、部品をキャンバスへ配置するという単純な動きに見えます。しかし実際には、入力の開始判定、ポインター位置の追跡、ドラッグ元の状態管理、ドロップ領域の受け入れ条件、ドラッグ中の表示、重なり判定、スクロール制御、アニメーション、最終的な状態反映まで、多くの処理が連携しています。つまり、ドラッグ&ドロップは単なるイベント処理ではなく、ユーザーの操作意図を読み取りながら画面状態を変化させる、複合的なユーザーインターフェース設計です。
特に、カンバンボード、ノーコード編集画面、ノードベースの編集画面、ファイル管理画面、画像編集ツール、タイムライン編集、学習アプリのカード並び替え、ゲーム内の装備変更画面などでは、ドラッグ&ドロップの品質がそのまま操作感に直結します。ドラッグ中の表示がポインターから遅れる、置ける場所が分かりにくい、ドロップ先が急に切り替わる、スクロールと移動が衝突する、前面表示の要素が他のパネルに隠れるといった問題があると、ユーザーは画面全体を不安定に感じます。直感的に見える操作だからこそ、少しの遅延や判定の揺れが大きな違和感になります。
ここでは、ドラッグ&ドロップ設計を「ドラッグ元」「ドロップ領域」「ドラッグ中の表示」「重なり判定」という4つの中心要素から整理します。さらに、ドラッグ開始、移動中の処理、前面表示レイヤー、並び替え、スクロール連携、状態管理、操作感、よくある失敗まで含めて、実務で壊れにくいドラッグ&ドロップユーザーインターフェースを作るための考え方を解説します。
1. ドラッグ&ドロップシステムの全体構造
ドラッグ&ドロップを安定して設計するには、まず全体の流れを状態の変化として理解する必要があります。ユーザーが要素を押した瞬間にすぐドラッグが始まるわけではありません。最初に「これはクリックなのか、ドラッグなのか」を判断し、一定距離の移動や長押しなどの条件を満たしたときに、ドラッグ中の状態へ移行します。その後、ポインター位置を追跡しながら、ドラッグ中の表示を動かし、現在どのドロップ領域に近いかを判定し、最終的にドロップ結果を状態へ反映します。
この流れを整理せずに、入力処理、表示更新、重なり判定、データ更新を1つの処理にまとめてしまうと、実装はすぐに複雑になります。たとえば、ポインターが動くたびにリスト全体を再描画してしまう、ドロップ候補がちらつく、キャンセル時に元の位置へ戻せない、保存に失敗したときに復元できないといった問題が起きやすくなります。実務では、ドラッグ&ドロップを「入力を受け取る層」「状態を管理する層」「重なりを判定する層」「画面に表示する層」「確定処理を行う層」に分けて考えると、拡張しやすくなります。
1.1 ドラッグ&ドロップの基本フロー
ドラッグ&ドロップは、単発の操作ではなく、いくつかの段階を進む状態遷移です。どの段階で何を行うかを明確にすると、表示と処理を分離しやすくなります。特に、移動中の処理は高頻度で発生するため軽く保ち、ドロップ後の確定処理は別の段階として扱うことが重要です。
| 段階 | 内容 |
|---|---|
| ドラッグ準備 | ユーザーが要素を押した直後の状態です。クリックかドラッグかをまだ判定している段階です。 |
| ドラッグ開始 | 開始距離や長押し条件を満たし、ドラッグ中の状態へ入ります。 |
| 移動中 | ポインター位置を追跡し、ドラッグ中の表示やドロップ候補を更新します。 |
| 重なり判定 | 現在位置をもとに、どのドロップ領域が候補になるかを計算します。 |
| ドロップ | 最終的な移動先、挿入位置、親要素、座標などを決定します。 |
| 状態反映 | 並び替え、移動、追加、アップロードなどの結果を画面状態へ反映します。 |
| キャンセル | 無効な場所へのドロップ、Escキー、範囲外操作などで元の状態へ戻します。 |
このように段階を分けると、どの処理がどのタイミングで必要なのかが分かりやすくなります。たとえば、ポインター座標は移動中に高頻度で変わりますが、実際の並び順を確定するのはドロップ時だけでよい場合があります。移動中に毎回本データを書き換えると、キャンセルや復元が難しくなるため、仮の位置と確定した位置を分ける設計が重要になります。
プログラミング言語:TypeScript
ファイル名:drag-state-machine.ts
type DragPhase =
| "idle"
| "pending"
| "dragging"
| "dropping"
| "cancelled";
type DragPoint = {
x: number;
y: number;
};
type DragState = {
phase: DragPhase;
activeItemId: string | null;
activeItemType: "card" | "image" | "file" | "node" | null;
startPoint: DragPoint | null;
currentPoint: DragPoint | null;
currentDropTargetId: string | null;
};
const initialDragState: DragState = {
phase: "idle",
activeItemId: null,
activeItemType: null,
startPoint: null,
currentPoint: null,
currentDropTargetId: null,
};
このように状態を段階として定義しておくと、ドラッグ&ドロップ中の画面が今どの状態にあるのかを説明しやすくなります。状態が曖昧なまま実装すると、「ドラッグ中なのにドロップ先がない」「キャンセルされたのに表示だけ残っている」「処理中なのに再度ドラッグできる」といった矛盾が起こりやすくなります。
1.2 ユーザーインターフェースだけで完結しない理由
ドラッグ&ドロップは、見た目の動きだけでは完結しません。カードが動くように見えていても、その裏側では、移動対象の識別子、元の親要素、元の位置、現在のポインター座標、受け入れ可能な領域、仮の挿入位置、保存処理の結果が関係しています。つまり、ドラッグ&ドロップは、画面表示とデータ構造が強く結びついた操作です。
たとえば、カンバンボードでカードを別の列へ移動する場合、画面上ではカードが横へ動くだけに見えます。しかし内部では、カードの所属列を変更し、列内の順序を更新し、必要ならサーバーへ保存し、失敗したら元の列へ戻す必要があります。見た目だけを先に作ってしまうと、あとから状態管理や保存処理を追加したときに破綻しやすくなります。だからこそ、ドラッグ&ドロップは最初から構造として設計する必要があります。
1.3 低遅延で同期する必要がある
ドラッグ&ドロップでは、ユーザーの手の動きと画面の反応がほぼ同時であることが重要です。ポインターが動いたら、ドラッグ中の表示もすぐに動き、ドロップ領域に入ったらactive状態がすぐに変わり、並び替え位置も自然に更新される必要があります。ここに遅延があると、ユーザーは「自分がつかんでいる」という感覚を失います。
この低遅延を実現するには、移動中の処理をできるだけ軽く保つことが必要です。ポインターが動くたびに大きな状態を更新したり、リスト全体を再描画したり、すべてのドロップ領域を毎回重く計算したりすると、画面がカクつきます。高頻度で変わる座標は軽量な管理にし、意味のある状態変化だけを画面全体へ伝えるようにすると、滑らかな操作感を保ちやすくなります。
2. ドラッグ元設計
ドラッグ元とは、ユーザーがつかんで移動する対象です。カード、画像、ファイル、ノード、レイヤー、タイムライン上のクリップ、編集画面の部品などが該当します。ドラッグ元の設計では、「何を移動するのか」「どの条件で移動できるのか」「ドラッグ中に元の要素をどう見せるのか」「内部データとして何を渡すのか」を明確にする必要があります。
ドラッグ元の設計が曖昧だと、クリックしただけで要素が動く、入力欄を操作したいのにカード全体が動く、ドラッグ禁止の項目まで動かせる、ドラッグ中に元要素と移動中表示の関係が分からなくなるといった問題が起こります。ドラッグ元は、ドラッグ&ドロップ全体の入口になるため、ここが不安定だと、その後のドロップ領域や重なり判定も不安定になります。
2.1 ドラッグ元とは何か
ドラッグ元は、ユーザーが移動操作を始める対象です。ただし、画面上で見えている要素そのものと、内部的に移動されるデータは同じではありません。たとえば、ユーザーはカード全体を移動しているように見えますが、内部的にはカードの識別子、種類、元のリスト、元の位置だけを保持しておけば十分な場合があります。見た目をそのままデータとして持つのではなく、後で移動処理に使える意味のある情報を持つことが重要です。
この分離ができていると、ドロップ領域側で「この要素を受け入れられるか」を判断しやすくなります。画像専用の領域なら画像だけ、カードリストならカードだけ、ノードキャンバスならノードだけを受け入れるようにできます。ドラッグ元のデータ構造が整理されているほど、ドロップ後の処理や保存処理も安定します。
プログラミング言語:TypeScript
ファイル名:drag-item.ts
type DragItem = {
id: string;
itemType: "card" | "image" | "file" | "node";
sourceContainerId: string;
sourceIndex?: number;
extraData?: Record<string, unknown>;
};
const draggingCard: DragItem = {
id: "card-001",
itemType: "card",
sourceContainerId: "todo-list",
sourceIndex: 2,
};
このように、ドラッグ元の情報を小さく整理しておくと、ドラッグ中に不要なデータを持ち回らずに済みます。大きなオブジェクト全体をドラッグ状態に入れるより、識別子と必要な最小情報だけを持つ方が、状態管理も軽くなります。
2.2 ドラッグ元の状態管理
ドラッグ元には、通常状態、ドラッグ準備中、ドラッグ中、無効状態、キャンセル後の状態などがあります。これらを整理しておくと、ユーザーに対して「今この要素は動かせるのか」「現在移動中なのか」「なぜ動かせないのか」を分かりやすく示せます。単にドラッグできるかどうかだけでなく、状態に応じた見た目を変えることが大切です。
| 状態 | 内容 |
|---|---|
| 通常 | まだドラッグされていない待機状態です。必要に応じてドラッグ可能であることを示します。 |
| 準備中 | ユーザーが押しているが、まだドラッグ開始条件を満たしていない状態です。 |
| ドラッグ中 | 現在移動されている状態です。元の要素を薄くしたり、仮表示にしたりします。 |
| 無効 | 権限不足、固定項目、処理中などでドラッグできない状態です。 |
| キャンセル後 | ドラッグが確定せず、元の位置へ戻る状態です。 |
ドラッグ元の状態は、画面上の見た目と一致している必要があります。内部ではドラッグ中なのに元要素が通常表示のままだと、ユーザーはどの要素を動かしているのか分かりにくくなります。逆に、ドラッグ中の要素を完全に消してしまうと、元の位置が分かりにくくなる場合があります。元要素を薄く残し、移動中の表示は別レイヤーで動かす設計にすると、元位置と現在位置の両方を理解しやすくなります。
2.3 ポインター入力を維持する設計
ドラッグ中は、ユーザーが元要素の外へポインターを動かしても、入力を追跡し続ける必要があります。もしポインターが要素外へ出た瞬間に入力が途切れると、ドラッグが途中で止まったり、別の要素が反応したりしてしまいます。そのため、ポインター入力を開始要素へ固定する仕組みを使うと、ドラッグ操作が安定しやすくなります。
特に、ユーザーが素早くマウスを動かす場合や、移動中の表示が元要素から離れる場合には、入力の追跡が途切れないことが重要です。ドラッグ&ドロップでは、開始から終了まで入力を確実に扱えることが基本になります。
プログラミング言語:TSX
ファイル名:pointer-capture-drag.tsx
function DraggableCard() {
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
const startX = event.clientX;
const startY = event.clientY;
console.log("ドラッグ準備を開始しました", { startX, startY });
};
const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
console.log("ドラッグ操作を終了しました");
};
return (
<div
className="draggable-card"
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
>
移動できるカード
</div>
);
}
このような入力維持の処理を入れる場合は、終了時やキャンセル時に必ず解放する必要があります。ドラッグが途中で中断された場合、Escキーでキャンセルされた場合、ポインターがキャンセルされた場合にも、入力状態が残らないようにすることが重要です。
2.4 部分ドラッグ
部分ドラッグとは、要素全体ではなく、特定のハンドル部分だけをドラッグ開始領域にする設計です。カード内に入力欄、チェックボックス、メニュー、削除ボタンなどがある場合、カード全体をドラッグ可能にすると通常操作と衝突しやすくなります。そのため、専用のつかむ場所を用意し、その部分だけでドラッグを開始する方が安全です。
プログラミング言語:TSX
ファイル名:drag-handle-card.tsx
type TaskCardProps = {
title: string;
};
export function TaskCard({ title }: TaskCardProps) {
return (
<article className="task-card">
<button
className="drag-handle"
aria-label="カードを並び替える"
type="button"
>
::
</button>
<input
className="task-title-input"
defaultValue={title}
aria-label="タスク名"
/>
<button className="task-menu-button" type="button">
メニュー
</button>
</article>
);
}
部分ドラッグを使うと、ユーザーはどこをつかめば動かせるのかを理解しやすくなります。また、入力欄やボタンの操作を邪魔しにくくなります。特に、編集画面や管理画面のように1つのカード内に複数の操作がある場合は、ドラッグ開始領域を明確に分けることが重要です。
3. ドラッグ開始設計
ドラッグ開始設計では、ユーザーの操作がクリックなのか、スクロールなのか、ドラッグなのかを正しく判定します。これは小さな処理に見えますが、操作感に大きく影響します。開始判定が早すぎると、クリックしただけで要素が動いてしまいます。開始判定が遅すぎると、ドラッグしたいのに反応が悪く感じられます。ドラッグ&ドロップの自然さは、この最初の判定でかなり決まります。
ドラッグ開始時には、開始座標、元の位置、元の親領域、元の順序、スクロール位置などを保存しておく必要があります。これらは、キャンセル、無効な場所へのドロップ、保存失敗、やり直し機能で必要になります。ドラッグ開始は、単に移動を始めるだけではなく、後で安全に戻せるように準備する段階でもあります。
3.1 誤操作防止
誤操作を防ぐには、クリック、タップ、スクロール、ドラッグを区別する必要があります。たとえば、カードをクリックすると詳細を開く画面で、少し手が動いただけでドラッグが始まると、ユーザーは詳細を開きにくくなります。モバイルでは、スクロールしようとしただけなのにカードが動いてしまうこともあります。こうした誤操作は、ユーザーに強いストレスを与えます。
誤操作を防ぐ方法としては、開始距離を設ける、長押しで開始する、専用のドラッグハンドルを用意する、入力欄やボタン上ではドラッグを開始しない、などがあります。特に複雑なカードでは、ドラッグ可能領域を広くするより、操作意図を明確に分ける方が使いやすくなります。
3.2 開始距離
開始距離とは、ユーザーが要素を押してから何px以上動かしたらドラッグ開始とみなすかを決める値です。これにより、軽い手ブレや通常のクリックとドラッグ操作を分けられます。たとえば、6px以上動いたらドラッグ開始とすることで、クリック操作中のわずかな動きを誤ってドラッグと判定しにくくなります。
プログラミング言語:TypeScript
ファイル名:activation-distance.ts
type Point = {
x: number;
y: number;
};
function getDistance(a: Point, b: Point) {
const diffX = b.x - a.x;
const diffY = b.y - a.y;
return Math.sqrt(diffX * diffX + diffY * diffY);
}
function shouldStartDrag(startPoint: Point, currentPoint: Point, threshold = 6) {
return getDistance(startPoint, currentPoint) >= threshold;
}
開始距離は、マウスとタッチで調整することがあります。マウスでは小さな距離でも正確に操作できますが、タッチでは指のブレが大きくなりやすいため、やや大きめの閾値が必要になる場合があります。ユーザーが「動かしたつもり」のときだけドラッグが始まるように調整することが重要です。
3.3 長押し開始
長押し開始は、主にモバイル向けのドラッグ開始方式です。通常のタップやスクロールとドラッグを区別するために、一定時間押し続けた場合だけドラッグモードへ入ります。長押しを使うことで、スクロール中に誤ってカードが動く問題を減らせます。
ただし、長押しには開始までの待ち時間があります。そのため、押している間に軽いハイライトや振動、ハンドルの強調を入れると、ユーザーはドラッグ開始の準備が進んでいることを理解しやすくなります。長押しは誤操作防止には有効ですが、反応が鈍く感じられないように設計する必要があります。
プログラミング言語:TypeScript
ファイル名:long-press-start.ts
let longPressTimer: number | null = null;
function startLongPress(onStart: () => void) {
longPressTimer = window.setTimeout(() => {
onStart();
}, 320);
}
function cancelLongPress() {
if (longPressTimer !== null) {
window.clearTimeout(longPressTimer);
longPressTimer = null;
}
}
長押し処理では、指が大きく動いた場合や、ユーザーが指を離した場合に必ずキャンセルする必要があります。キャンセル処理が弱いと、スクロールした後に遅れてドラッグが始まるような不自然な挙動が起こります。
3.4 初期状態保存
ドラッグ開始時には、元の状態を保存しておくことが重要です。たとえば、カードの元リスト、元の順序、元の座標、元の親要素、スクロール位置などを保存しておくと、キャンセルや保存失敗時に元へ戻せます。これを保存していないと、途中で無効な場所へドロップした場合や、サーバー保存が失敗した場合に復元が難しくなります。
プログラミング言語:TypeScript
ファイル名:drag-origin-snapshot.ts
type DragOriginSnapshot = {
itemId: string;
sourceContainerId: string;
sourceIndex: number;
sourceRect: DOMRect;
scrollTop: number;
};
function createDragOriginSnapshot(params: {
itemId: string;
sourceContainerId: string;
sourceIndex: number;
element: HTMLElement;
scrollTop: number;
}): DragOriginSnapshot {
return {
itemId: params.itemId,
sourceContainerId: params.sourceContainerId,
sourceIndex: params.sourceIndex,
sourceRect: params.element.getBoundingClientRect(),
scrollTop: params.scrollTop,
};
}
このような初期状態を保存しておくと、ドラッグ操作を安全に取り消せます。実務では、ドラッグ&ドロップは必ず成功する操作ではありません。ユーザーが途中でキャンセルすることもあり、無効な場所へ置くこともあり、保存処理が失敗することもあります。そのため、開始時点で復元可能な情報を確保しておくことが重要です。
4. ドラッグ移動設計
ドラッグ移動設計では、ポインター位置を追跡し、その位置に合わせてドラッグ中の表示やドロップ候補を更新します。この部分は非常に高頻度で動くため、パフォーマンスに直結します。ユーザーがマウスや指を動かしている間、画面が遅れずについてくる必要があります。ここで遅延があると、どれだけ見た目が整っていても操作感は悪くなります。
移動中の処理では、座標の追跡、差分計算、表示の移動、重なり判定、ドロップ領域のactive表示が関係します。ただし、これらをすべて毎回重く処理すると、すぐにカクつきます。高頻度で変わる座標は軽く管理し、画面全体へ反映する状態は必要最小限にする設計が重要です。
4.1 ポインター追跡
ポインター追跡では、現在のclientXとclientYを取得します。マウス、タッチ、ペンなどを統一的に扱うには、Pointer Eventsを使うと整理しやすくなります。移動中はポインター位置だけでなく、移動方向や移動量、速度を計算することもあります。
プログラミング言語:TSX
ファイル名:pointer-tracking.tsx
function usePointerTracking() {
const positionRef = React.useRef({ x: 0, y: 0 });
const handlePointerMove = React.useCallback((event: PointerEvent) => {
positionRef.current = {
x: event.clientX,
y: event.clientY,
};
}, []);
React.useEffect(() => {
window.addEventListener("pointermove", handlePointerMove);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
};
}, [handlePointerMove]);
return positionRef;
}
ここでは、ポインター位置をReactの状態ではなくrefに保存しています。ポインター位置は非常に高頻度で変わるため、毎回Reactの状態として更新すると再描画が多くなります。ドラッグ中の座標のように、頻繁に変わるが画面全体の再描画を必要としない値は、軽量な管理にする方が安定します。
4.2 移動量の計算
移動量の計算では、ドラッグ開始時の座標と現在の座標との差を求めます。この差分を使って、ドラッグ中の表示を元位置からどれだけ移動させるかを決めます。単純な計算に見えますが、スクロール、前面表示レイヤー、拡大縮小、キャンバス座標が絡むと複雑になります。
プログラミング言語:JavaScript
ファイル名:drag-delta.js
const deltaX = currentX - startX;
const deltaY = currentY - startY;
const nextX = originX + deltaX;
const nextY = originY + deltaY;
この計算で重要なのは、どの座標系を使っているかを明確にすることです。画面基準の座標なのか、ページ全体の座標なのか、スクロールコンテナ内の座標なのか、キャンバス内の座標なのかが混ざると、ドラッグ中の表示がズレます。特に、前面表示レイヤーをbody直下に出す場合は、元要素の位置と表示レイヤーの基準が違うため、スクロール量や親要素の位置を補正する必要があります。
4.3 変形プロパティによる移動
ドラッグ中の表示を動かすときは、topやleftを頻繁に変更するより、transformを使う方が安定しやすくなります。transformはレイアウト全体の再計算を避けやすく、合成処理で動かしやすいため、ドラッグ中のような高頻度の移動に向いています。
プログラミング言語:CSS
ファイル名:drag-preview-transform.css
.drag-preview {
position: fixed;
top: 0;
left: 0;
transform: translate3d(var(--drag-x), var(--drag-y), 0);
will-change: transform;
pointer-events: none;
}
will-changeは、これから頻繁に変化する要素へブラウザが最適化を準備するための指定です。ただし、多用するとメモリ使用量が増えることがあります。ドラッグ中の表示のように、本当に高頻度で動く要素だけに限定して使うのが安全です。
4.4 メインスレッド負荷
ドラッグ中は、ポインター移動、スクロール、重なり判定、表示更新が同時に発生します。これらがすべてメインスレッド上で重くなると、ドラッグ中の表示がポインターに遅れてついてきます。ユーザーは、自分の手で要素を直接動かしている感覚を期待しているため、この遅れは非常に目立ちます。
プログラミング言語:TypeScript
ファイル名:request-animation-frame-drag.ts
let frameId: number | null = null;
let latestPoint = { x: 0, y: 0 };
function scheduleDragRender(point: { x: number; y: number }) {
latestPoint = point;
if (frameId !== null) return;
frameId = requestAnimationFrame(() => {
document.documentElement.style.setProperty("--drag-x", `${latestPoint.x}px`);
document.documentElement.style.setProperty("--drag-y", `${latestPoint.y}px`);
frameId = null;
});
}
このように、ポインターイベントごとに即座に描画を更新するのではなく、requestAnimationFrameで描画タイミングに合わせてまとめると、無駄な更新を減らせます。ドラッグ中の操作感を保つには、イベントの回数ではなく、画面の描画タイミングに合わせて処理することが重要です。
5. ドラッグ中の表示設計
ドラッグ中の表示は、ユーザーが今何を移動しているのかを理解するための重要な要素です。カードのコピー、ファイル名、画像のサムネイル、ノードの簡略表示、複数選択時の件数表示など、アプリの種類によって表現は変わります。ドラッグ中の表示が見づらいと、ユーザーは移動対象を見失いやすくなります。
ドラッグ中の表示で大切なのは、元要素を完全に再現することではなく、移動対象が分かり、ポインターに遅れず、ドロップ領域を隠しすぎないことです。表示が大きすぎると置き場所が見えにくくなり、薄すぎると何を持っているか分かりません。操作中の認知を支えるために、視認性と邪魔にならなさのバランスを取る必要があります。
5.1 ドラッグ中の表示とは何か
ドラッグ中の表示とは、移動している対象を画面上で示すための表示です。カードを移動しているならカードの見た目に近いコピーを表示し、複数ファイルを移動しているなら「3件のファイル」のようにまとめて表示し、ノードを配置しているならノードの簡略表示を出します。この表示があることで、ユーザーは自分が何をつかんでいるのかを確認できます。
ドラッグ中の表示が遅れたり、他のパネルに隠れたり、下のドロップ領域を見えなくしたりすると、操作の信頼性が下がります。そのため、複雑な画面では、通常のレイアウトから切り離した前面表示レイヤーとして扱うことが多くなります。
5.2 ゴースト表示
ゴースト表示は、元要素のコピーをドラッグ中に表示する方法です。元のカードや画像に近い見た目なので、ユーザーは何を動かしているか直感的に理解できます。ただし、通常状態とまったく同じ見た目にすると、移動中であることが分かりにくいため、少し透明にしたり、影を強くしたり、サイズを少し変えたりすることがあります。
| 用語 | 内容 |
|---|---|
| ゴースト表示 | 元要素に近い見た目の移動中表示です。 |
| 表示専用コピー | 実際のデータではなく、見た目だけを担当するコピーです。 |
| 元要素 | ドラッグ開始前に画面上に存在していた要素です。 |
| 仮表示 | 元位置や挿入予定位置を示すための仮の要素です。 |
ゴースト表示を使う場合は、元要素と表示専用コピーの役割を分けることが重要です。元要素をそのまま動かすと、周囲のレイアウトに影響することがあります。一方、表示専用コピーを前面表示レイヤーで動かし、元要素は薄く残すか仮表示にすると、操作中の見た目が安定しやすくなります。
5.3 前面表示レイヤー
前面表示レイヤーは、ドラッグ中の表示を通常のレイアウトから切り離し、画面の最前面に出すための仕組みです。親要素にoverflow: hiddenがある場合や、スクロールコンテナの中で移動する場合、通常のDOM階層に表示を置くと見切れたり隠れたりします。前面表示レイヤーを使うことで、こうした問題を避けやすくなります。
プログラミング言語:TSX
ファイル名:drag-floating-layer.tsx
import { createPortal } from "react-dom";
type DragFloatingLayerProps = {
children: React.ReactNode;
x: number;
y: number;
};
export function DragFloatingLayer({ children, x, y }: DragFloatingLayerProps) {
return createPortal(
<div
className="drag-floating-layer"
style={{
transform: `translate3d(${x}px, ${y}px, 0)`,
}}
>
{children}
</div>,
document.body
);
}
前面表示レイヤーを使う場合は、座標の基準に注意が必要です。元要素がスクロール領域の中にあり、前面表示レイヤーがbody直下にある場合、単純な座標だけではズレることがあります。スクロール量、親要素の位置、拡大縮小率を考慮して、正しい表示位置を計算する必要があります。
5.4 半透明表示
半透明表示は、ドラッグ中であることを伝える簡単な方法です。元要素を少し薄くすることで、「この要素は今移動中である」と伝えられます。ドラッグ中の表示も少し透明にすると、下にあるドロップ領域や挿入位置が見えやすくなる場合があります。
ただし、透明度を下げすぎると、何を移動しているのか分かりにくくなります。特にカードのタイトルや画像サムネイルが重要な場合、内容が読めなくなるほど薄くするのは避けるべきです。半透明表示は便利ですが、移動中であることを示しつつ、対象の識別性を保つことが重要です。
6. 前面表示レイヤー設計
前面表示レイヤーは、ドラッグ中の表示を安定して見せるための仕組みです。元要素を直接動かすと、親要素のoverflowに隠れたり、z-indexの影響を受けたり、周囲のレイアウトを動かしてしまったりします。前面表示レイヤーを使えば、通常のレイアウトから独立した表示として扱えるため、複雑な画面でも移動中の表示を見失いにくくなります。
前面表示レイヤーの設計では、Portal、重なり順、入力イベントの透過、独立した描画レイヤー、座標補正が重要になります。特に、モーダル、ポップアップ、ツールチップ、通知、サイドパネルなどが存在する画面では、ドラッグ中の表示も全体のレイヤー設計に組み込む必要があります。
6.1 Portal利用
Portalを使うと、ドラッグ中の表示を親DOMの外へ描画できます。これにより、親コンテナのoverflow: hiddenやtransformの影響を避けやすくなります。複雑な編集画面やスクロールコンテナ内のドラッグでは、Portalを使うことで表示が途中で切れる問題を避けられます。
ただし、Portalを使うと表示位置の基準が変わります。元要素はスクロールコンテナ内にあり、前面表示レイヤーはbody直下にある場合、スクロール量を考慮しなければ表示がズレます。Portalは強力ですが、座標補正とセットで設計する必要があります。
6.2 重なり順の管理
ドラッグ中の表示は、基本的に他の多くの要素より前面に出る必要があります。しかし、場当たり的に大きなz-indexを指定すると、モーダルやポップアップとの重なりが崩れます。ユーザーインターフェース全体で、どのレイヤーがどの順番で表示されるのかを決めておくことが重要です。
プログラミング言語:CSS
ファイル名:z-index-tokens.css
:root {
--z-base: 0;
--z-dropdown: 1000;
--z-popover: 1200;
--z-modal: 1400;
--z-toast: 1600;
--z-drag-floating-layer: 1800;
}
このように重なり順を変数として管理しておくと、後から数値を場当たり的に増やす必要が減ります。ドラッグ中の表示は一時的なものですが、他の前面表示要素と競合しやすいため、レイヤー設計の一部として扱うべきです。
6.3 入力イベントを邪魔しない設計
ドラッグ中の表示は、基本的には見た目だけを担当し、入力判定を邪魔しない方が安定します。前面表示レイヤー自体がポインターイベントを受け取ってしまうと、下にあるドロップ領域の判定が壊れることがあります。そのため、表示専用レイヤーにはpointer-events: none;を指定することが多くあります。
プログラミング言語:CSS
ファイル名:drag-floating-layer.css
.drag-floating-layer {
position: fixed;
top: 0;
left: 0;
z-index: var(--z-drag-floating-layer);
pointer-events: none;
will-change: transform;
}
この指定により、前面表示レイヤーは画面上に見えていても、入力イベントは下の要素へ通ります。ドラッグ中の表示はユーザーに見えるためのものであり、実際の重なり判定は座標と登録済みのドロップ領域を使って行う方が安定します。
6.4 通常レイアウトからの独立
前面表示レイヤーを通常レイアウトから独立させることで、ドラッグ中の移動が周囲の要素へ影響しにくくなります。元要素を直接動かすと、周囲の要素が再配置され、レイアウト計算が増える場合があります。一方、前面表示レイヤーを使えば、元レイアウトはそのまま維持し、移動中の表示だけを滑らかに動かせます。
この分離により、元要素、仮表示、ドラッグ中の表示、ドロップ領域の役割が明確になります。元要素は元の場所を示し、仮表示は挿入予定位置を示し、前面表示レイヤーは現在移動中の対象を示します。役割を分けることで、見た目も状態管理も整理しやすくなります。
7. ドロップ領域設計
ドロップ領域とは、ドラッグ中の要素を受け入れる可能性がある領域です。ファイルアップロードではアップロード枠、カンバンでは列、並び替えリストでは挿入位置、ノーコード編集画面ではキャンバスやコンテナ、ツリー構造ではフォルダやノードがドロップ領域になります。ドロップ領域は、単に「ここへ置ける」と示すだけでなく、受け入れ条件、優先順位、状態表示、ドロップ後の処理まで含む設計対象です。
良いドロップ領域は、ユーザーにとって予測しやすいものです。置ける場所は置けそうに見え、置けない場所は置けないことが分かり、現在の候補は明確に強調されます。見た目では置けそうなのに実際には置けない、逆に置けるのに何も反応しないという状態は、ユーザーにとって非常に不親切です。
7.1 ドロップ領域とは何か
ドロップ領域は、ドラッグ中の要素を受け取る候補です。ただし、見た目上の領域と実際の判定領域は必ずしも一致しません。たとえば、カードとカードの間に細い挿入線だけを表示していても、実際にはその上下に広い判定範囲を持たせることがあります。これは、ユーザーが細い線を正確に狙わなくても操作できるようにするためです。
ドロップ領域を設計するときは、見た目の美しさよりも操作の成功率を重視する必要があります。判定が狭すぎると、ユーザーは何度も置き直すことになります。見た目は控えめでも、裏側の判定範囲は十分に広くすることで、操作しやすいドラッグ&ドロップになります。
7.2 受け入れ条件
受け入れ条件とは、ドロップ領域がどの種類の要素を受け取れるかを決めるルールです。画像領域なら画像だけ、カードリストならカードだけ、ノードキャンバスならノードだけ、フォルダならファイルやフォルダを受け取る、といった条件があります。この条件が曖昧だと、不正なドロップやデータ構造の崩れが起こります。
プログラミング言語:TypeScript
ファイル名:drop-zone-config.ts
type DragItemType = "card" | "image" | "file" | "node";
type DropZoneConfig = {
id: string;
acceptedItemTypes: DragItemType[];
disabled?: boolean;
};
function canDrop(zone: DropZoneConfig, itemType: DragItemType) {
if (zone.disabled) return false;
return zone.acceptedItemTypes.includes(itemType);
}
const imageArea: DropZoneConfig = {
id: "hero-image-area",
acceptedItemTypes: ["image", "file"],
};
受け入れ条件は、視覚的なフィードバックと連動させる必要があります。受け入れ可能なら強調表示し、受け入れ不可なら無効表示にします。ロジック上は受け取れないのに、見た目では置けそうに見える状態は避けるべきです。ユーザーはドロップ前に結果を予測できる必要があります。
7.3 ネスト構造
ネスト構造を持つドロップ領域では、親と子のどちらが受け取るべきかが問題になります。たとえば、レイアウト編集画面では、セクション、カラム、部品がそれぞれドロップ領域になることがあります。ユーザーが部品を移動しているとき、セクション全体に入れるのか、カラムへ入れるのか、既存部品の前後へ挿入するのかを判定しなければなりません。
ネスト構造では、優先順位を明確にする必要があります。内側の領域を優先するのか、ポインターに最も近い領域を優先するのか、一定時間滞在した領域を優先するのかを決めます。また、複数の領域が同時に強調表示されると混乱するため、最終候補を1つに絞って分かりやすく表示することが重要です。
7.4 Active状態
Active状態は、現在ドロップ候補になっている領域を示す状態です。ユーザーがドラッグ中の要素をある領域へ近づけたとき、その領域が受け入れ可能であれば、枠線、背景色、挿入線、仮表示などで強調します。この表示があることで、ユーザーは「今ここに置ける」と理解できます。
プログラミング言語:CSS
ファイル名:drop-zone-states.css
.drop-zone {
border: 2px dashed #c8c8d4;
background: #fafafa;
transition: border-color 150ms ease, background 150ms ease;
}
.drop-zone[data-active="true"] {
border-color: #6c5ce7;
background: #f3f0ff;
}
.drop-zone[data-invalid="true"] {
border-color: #e74c3c;
background: #fff1f0;
}
Active状態では、単に色を変えるだけでなく、ドロップ後に何が起こるのかを伝えることが重要です。並び替えなら挿入位置を表示し、ファイルアップロードなら「ここにファイルを置く」と表示し、キャンバスなら配置予定位置を示します。結果が予測できることが、ドロップ領域設計の中心です。
8. 重なり判定設計
重なり判定とは、ドラッグ中の要素やポインターが、どのドロップ領域と関係しているかを計算する処理です。ドラッグ&ドロップの操作感は、この判定の安定性に大きく左右されます。判定が不安定だと、候補領域がちらついたり、意図しない場所へドロップされたり、挿入位置が急に飛んだりします。
重なり判定には、矩形同士の重なり、ポインター位置、中心点の距離、最も近い候補、複数条件を組み合わせた判定などがあります。どの方法が適しているかは、画面の種類によって変わります。広いファイルアップロード領域では矩形判定で十分な場合がありますが、並び替えリストやネスト構造では、より細かい判定が必要になります。
8.1 重なり判定とは何か
重なり判定は、現在のドラッグ位置から、どのドロップ領域が有効かを決める処理です。たとえば、ドラッグ中の要素の矩形がドロップ領域と重なっているか、ポインターが領域内にあるか、中心点がどの候補に近いかを計算します。この結果が、active表示や挿入位置の表示に反映されます。
重なり判定は、ユーザーの意図を推定する処理でもあります。数学的に近い候補が、必ずしもユーザーの意図に合うとは限りません。ユーザーがどこへ置こうとしているのかを自然に読み取るには、距離、移動方向、領域の優先順位、受け入れ条件を組み合わせる必要があります。
8.2 矩形判定
矩形判定は、ドラッグ中の要素とドロップ領域の四角形が重なっているかを比較する方法です。実装が分かりやすく、広いドロップ領域では安定して使えます。ファイルアップロード領域、カンバンの列、キャンバス全体などでは、まず矩形判定を基本にできます。
プログラミング言語:JavaScript
ファイル名:rectangle-collision.js
function isIntersecting(rectA, rectB) {
return !(
rectA.right < rectB.left ||
rectA.left > rectB.right ||
rectA.bottom < rectB.top ||
rectA.top > rectB.bottom
);
}
矩形判定は便利ですが、候補が密集している場合には複数の領域と同時に重なることがあります。その場合、どの候補を選ぶべきかを追加で判断する必要があります。重なり面積が大きいものを選ぶ、中心点が近いものを選ぶ、ポインターが入っている領域を優先するなど、補助ルールを追加すると安定します。
8.3 中心点判定
中心点判定は、ドラッグ中の要素やドロップ候補の中心点を使って距離を比較する方法です。並び替えリストやグリッド配置では、中心点を基準にすると自然な候補を選びやすい場合があります。矩形の重なりだけでは候補が曖昧なときに有効です。
プログラミング言語:TypeScript
ファイル名:center-point-distance.ts
type RectLike = {
left: number;
top: number;
width: number;
height: number;
};
function getCenterPoint(rect: RectLike) {
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
}
function getCenterDistance(a: RectLike, b: RectLike) {
const centerA = getCenterPoint(a);
const centerB = getCenterPoint(b);
const diffX = centerA.x - centerB.x;
const diffY = centerA.y - centerB.y;
return Math.sqrt(diffX * diffX + diffY * diffY);
}
中心点判定は分かりやすい方法ですが、ドラッグ対象のサイズが大きい場合や、ユーザーが要素の端をつかんでいる場合には、ポインター位置と中心点が大きくズレることがあります。そのため、中心点だけでなく、ポインター位置や移動方向も組み合わせると、より自然な判定になります。
8.4 最も近い候補の判定
最も近い候補の判定は、複数のドロップ候補の中から、現在位置に最も近いものを選ぶ方法です。キャンバス、ノード編集、グリッド配置、自由配置の画面では、この方法が有効です。候補が明確な矩形ではなく、点や線、ガイドに近い場合にも使えます。
ただし、候補が密集していると、少しの移動で候補が頻繁に切り替わることがあります。そのため、一定距離以上近づいた場合だけ切り替える、現在の候補を少し維持する、移動方向が変わったときだけ候補を更新するなど、ちらつきを防ぐ工夫が必要です。重なり判定では、精度だけでなく安定性も非常に重要です。
9. 重なり判定アルゴリズム
重なり判定アルゴリズムは、画面の性質に合わせて選ぶ必要があります。単純なアップロード領域では矩形判定で十分ですが、並び替えでは前後の挿入位置を判断する必要があり、キャンバスでは距離や吸着を考える必要があります。ネスト構造では、親子の優先順位も必要です。つまり、どのアルゴリズムが最も良いかは、ユーザーが何をしたい画面なのかによって変わります。
重要なのは、高度な判定を入れれば良いということではありません。判定が複雑になりすぎると、なぜその候補が選ばれたのか開発者にも説明しにくくなります。最初はシンプルな判定から始め、ユーザーの意図とズレる部分だけ補正する方が、実務では安定しやすくなります。
9.1 矩形判定
矩形判定は、領域が明確な画面に向いています。ファイルアップロード領域、カンバンの列、キャンバス全体など、広い四角形へ入ったかどうかを見る場合には扱いやすい方法です。実装が簡単で、結果も予測しやすいため、基本の判定として使いやすいです。
一方で、細かい並び替え位置を決めるには粗い場合があります。複数候補に重なる場合や、候補が密集している場合は、矩形判定だけでは最終候補を決めにくくなります。その場合は、矩形判定で候補を絞り、その後に距離や中心点、移動方向で補正する設計が向いています。
9.2 ポインター基準の判定
ポインター基準の判定は、ユーザーが実際に指している位置を使うため、直感的で軽量です。ポインターがドロップ領域の中にあるかどうかを見れば、現在の候補を判断できます。ファイルアップロードや大きめのドロップ領域では、非常に分かりやすい方法です。
ただし、ドラッグ対象が大きい場合には注意が必要です。ユーザーが大きなカードの端をつかんでいる場合、ポインター位置だけで判定すると、カード全体の位置とは違う結果になることがあります。ポインター基準は軽量ですが、要素サイズやつかんだ位置を考慮しないと不自然になる場合があります。
9.3 距離ベースの判定
距離ベースの判定は、ポインターや中心点から候補までの距離を比較し、最も近い候補を選ぶ方法です。キャンバスやノード編集、グリッド配置のように、自由度の高い画面で使いやすい方法です。単純に領域内かどうかを見るだけでなく、近さによって候補を選べるため、柔軟な設計ができます。
距離ベースの判定では、どの距離を使うかが重要です。候補の中心までの距離なのか、端までの距離なのか、挿入線までの距離なのかによって結果が変わります。また、候補が多いと計算量が増えるため、表示中の候補だけを対象にする、近い候補だけを先に抽出するなどの工夫が必要です。
9.4 複数条件を組み合わせる判定
複数条件を組み合わせる判定では、距離、重なり面積、移動方向、階層、受け入れ条件、現在の候補などを総合して最終候補を決めます。ネストした編集画面や複雑なビルダー画面では、単一の判定だけでは自然な結果にならないことがあるため、このような設計が必要になります。
| 手法 | 特徴 | 向いている画面 |
|---|---|---|
| 矩形判定 | 安定していて実装しやすいです。 | ファイルアップロード、広いドロップ領域 |
| ポインター基準 | 軽量で直感的です。 | シンプルなドロップ領域 |
| 距離判定 | 柔軟に候補を選べます。 | キャンバス、グリッド、ノード編集 |
| エッジ判定 | 前後左右の挿入位置を決めやすいです。 | 並び替えリスト、ツリー構造 |
| 複数条件判定 | 複雑な条件を統合できます。 | ネスト構造、ノーコードビルダー |
複数条件を組み合わせると精度は上がりますが、複雑になりすぎると保守が難しくなります。判定結果がなぜそうなったのか説明できる程度に保つことが大切です。ドラッグ&ドロップでは、アルゴリズムの高度さよりも、ユーザーが予測しやすい結果を安定して返すことが重要です。
10. 並び替えドラッグ&ドロップ
並び替えドラッグ&ドロップは、リストやカードの順序を変更するための操作です。タスク管理、画像ギャラリー、フォーム項目編集、メニュー管理、レイヤーパネル、タイムライン編集などでよく使われます。このパターンでは、単にドロップ領域に入ったかどうかではなく、どの位置へ挿入されるかを明確に伝える必要があります。
並び替えでは、ドロップ前に結果を予測できることが非常に重要です。ユーザーがカードを動かしているとき、挿入線や仮表示によって「ここに入る」と分かれば、操作ミスが減ります。逆に、ドロップしてから初めて順序が変わる画面では、ユーザーは結果を確認して何度もやり直すことになります。
10.1 順序変更処理
順序変更処理では、配列内の要素を別の位置へ移動します。単一リスト内の移動であれば比較的簡単ですが、別リストへ移動する場合は、元リストから削除し、移動先リストへ挿入する必要があります。さらに、サーバー保存ややり直し機能が入ると、処理を独立した関数として管理することが重要になります。
プログラミング言語:TypeScript
ファイル名:reorder-array.ts
function reorderItems<T>(items: T[], fromIndex: number, toIndex: number): T[] {
const nextItems = [...items];
const [movedItem] = nextItems.splice(fromIndex, 1);
nextItems.splice(toIndex, 0, movedItem);
return nextItems;
}
const result = reorderItems(["A", "B", "C", "D"], 1, 3);
// 結果: ["A", "C", "D", "B"]
このような基本関数を独立させておくと、画面側の処理が読みやすくなります。ドラッグ中の表示、重なり判定、最終的なデータ更新を分けることで、テストもしやすくなります。並び替え処理は小さく見えますが、実務ではバグが起きやすい部分なので、関数として明確に分けるのが安全です。
10.2 仮表示
仮表示は、ドラッグ中の要素が入る予定の場所を示すための表示です。カードの間に空きスペースを作る、点線の枠を表示する、挿入線を出すなどの方法があります。仮表示があると、ユーザーはドロップ後の結果を事前に理解できます。
仮表示のサイズは、実際に入る要素のサイズに近い方が自然です。サイズが大きく違うと、ドロップ後にレイアウトが急に変わって違和感が出ます。また、仮表示は通常の要素と見間違えないように、薄い背景や点線で「まだ確定していない場所」であることを示すと分かりやすくなります。
10.3 仮位置と確定位置
並び替えでは、ドラッグ中の仮位置と、ドロップ後の確定位置を分けることが重要です。ドラッグ中に毎回本データを変更してしまうと、キャンセルしたときに元へ戻すのが難しくなります。仮位置は表示用の状態として管理し、ドロップ時にだけ本データへ反映すると安全です。
この分離により、ドラッグ中の見た目は柔軟に変えられます。ユーザーが動かしている間は、仮の挿入位置だけを更新し、ドロップが確定したら配列を更新します。もしドロップ先が無効なら、仮位置を消して元状態へ戻せます。並び替え設計では、仮状態と確定状態を分けることが安定性につながります。
10.4 並び替えアニメーション
並び替えアニメーションは、周囲の要素が自然に移動するように見せるための表現です。カードが突然入れ替わるより、押し出されるように動く方が、ユーザーは位置関係を理解しやすくなります。ただし、ドラッグ中のアニメーションは短く軽くする必要があります。
アニメーションが長すぎると、ユーザーの手の動きに画面が遅れてついてくるように見えます。ドラッグ&ドロップでは、動きの美しさよりも追従性が重要です。アニメーションは、結果を理解しやすくするための補助として使い、操作そのものを重くしないように設計する必要があります。
11. スクロール連携
スクロール連携は、ドラッグ&ドロップの中でも難しい部分です。長いリストや大きなキャンバスでは、ドラッグ中に現在見えていない場所へ移動したくなることがあります。そのため、ポインターがスクロール領域の端へ近づいたときに、自動でスクロールする仕組みが必要になります。
スクロールが絡むと、座標計算、ドロップ領域の位置、前面表示レイヤーの位置、仮表示の位置がすべて変化します。さらに、ポインター移動とスクロールイベントが同時に発生するため、パフォーマンス負荷も高くなります。スクロール連携は、後から付け足す機能ではなく、ドラッグ&ドロップ設計の一部として最初から考えるべきです。
11.1 スクロール領域
スクロール領域とは、overflowによってスクロールできるコンテナです。ページ全体だけでなく、リスト、サイドバー、モーダル、キャンバス、パネルなどがスクロール対象になる場合があります。どの領域がスクロールするのかを把握していないと、座標計算がズレます。
スクロール領域内のドロップ領域は、スクロールによって画面上の位置が変わります。ドラッグ開始時に取得した位置情報をそのまま使い続けると、スクロール後に判定がズレることがあります。そのため、スクロール差分を補正するか、必要なタイミングで位置を再取得する設計が必要です。
11.2 自動スクロール
自動スクロールは、ドラッグ中にポインターがスクロール領域の端へ近づいたとき、自動的にスクロールする機能です。長いリストの下の方へカードを移動したい場合や、キャンバスの外側へノードを移動したい場合に必要になります。自動スクロールがないと、ユーザーは一度ドロップしてからスクロールし、もう一度ドラッグし直す必要があります。
プログラミング言語:TypeScript
ファイル名:auto-scroll.ts
function autoScroll(container: HTMLElement, pointerY: number) {
const rect = container.getBoundingClientRect();
const threshold = 48;
const maxSpeed = 18;
const distanceToTop = pointerY - rect.top;
const distanceToBottom = rect.bottom - pointerY;
if (distanceToTop < threshold) {
const ratio = 1 - distanceToTop / threshold;
container.scrollTop -= Math.ceil(maxSpeed * ratio);
}
if (distanceToBottom < threshold) {
const ratio = 1 - distanceToBottom / threshold;
container.scrollTop += Math.ceil(maxSpeed * ratio);
}
}
自動スクロールでは、端に近いほど速く、少し離れたら遅くするようにすると自然です。ただし、速度が速すぎると制御が難しくなります。ユーザーが意図しないスクロールに驚かないよう、発火範囲と速度を丁寧に調整する必要があります。
11.3 ネストしたスクロール
ネストしたスクロールとは、親と子の両方がスクロールできる状態です。たとえば、ページ全体がスクロールし、その中にカンバン列やリストがさらにスクロールする場合があります。このとき、ドラッグ中にどちらをスクロールさせるべきかを判断する必要があります。
ネストしたスクロールでは、現在のドロップ候補がどの領域に属しているか、ポインターがどのスクロール領域の端に近いか、ユーザーがどの方向へ動かしているかを考えます。単純に最初に見つかったスクロール親を動かすだけでは、不自然な挙動になる場合があります。スクロールの優先順位を設計しておくことが重要です。
11.4 ドラッグとスクロールの負荷
ドラッグとスクロールが同時に発生すると、処理負荷は高くなります。ポインター移動、スクロールイベント、重なり判定、前面表示の更新、ドロップ領域の位置再計算が重なるためです。大量リストや仮想リストでは、スクロールによって表示要素が入れ替わり、判定対象も変わります。
この負荷を抑えるには、更新をrequestAnimationFrameへまとめ、判定候補を表示中の範囲に絞り、DOMの読み取りを最小限にする必要があります。ドラッグとスクロールの連携は、見た目上は自然に見えるべきですが、内部では高頻度処理の集合です。設計段階からパフォーマンスを考慮する必要があります。
12. 状態管理設計
ドラッグ&ドロップでは、多くの状態が同時に存在します。現在ドラッグ中か、何をドラッグしているか、開始位置はどこか、現在位置はどこか、どのドロップ領域の上にいるか、仮の挿入位置はどこか、複数選択中か、保存中か、キャンセル可能か。これらを整理せずに扱うと、状態がすぐに複雑化します。
状態管理で重要なのは、高頻度で変わる一時状態と、ドロップ後に確定する状態を分けることです。ポインター座標のように毎フレーム変わる値をアプリ全体の状態へ入れると、再描画が増えて重くなります。一方で、ドロップ後の並び順や親子関係は確定状態として保存する必要があります。状態の寿命と更新頻度を基準に、管理場所を分けることが重要です。
12.1 全体ドラッグ状態
全体ドラッグ状態は、複数のドロップ領域や前面表示レイヤーが共有する、意味のあるドラッグ情報を管理します。たとえば、現在の対象ID、対象の種類、元の領域、現在の候補領域、仮の挿入位置などです。複数の部品が同じドラッグ状態を参照する場合、このような全体状態があると扱いやすくなります。
プログラミング言語:TypeScript
ファイル名:global-drag-state.ts
type GlobalDragState = {
phase: "idle" | "dragging" | "dropping";
activeItemId: string | null;
activeItemType: "card" | "image" | "file" | "node" | null;
sourceContainerId: string | null;
currentContainerId: string | null;
currentIndex: number | null;
};
function createDraggingState(params: {
activeItemId: string;
activeItemType: GlobalDragState["activeItemType"];
sourceContainerId: string;
}): GlobalDragState {
return {
phase: "dragging",
activeItemId: params.activeItemId,
activeItemType: params.activeItemType,
sourceContainerId: params.sourceContainerId,
currentContainerId: null,
currentIndex: null,
};
}
ここで重要なのは、座標のような高頻度の値と、対象IDや候補領域のような意味的な値を分けることです。座標は軽量に管理し、候補領域が変わったときだけ全体状態を更新するようにすると、再描画を抑えやすくなります。
12.2 楽観的更新
楽観的更新とは、ドロップ後にサーバー保存の完了を待たず、先に画面上の状態を更新する方法です。カードを別の列へ移動したとき、すぐに画面上でも移動して見えるため、ユーザーは操作結果をすぐ確認できます。体感速度を高めるために有効な方法です。
ただし、保存に失敗した場合は元へ戻す必要があります。そのため、移動前の状態を保存しておき、失敗時に復元する設計が必要です。楽観的更新はユーザー体験を良くしますが、成功時だけでなく失敗時の復元まで含めて初めて安全に使えます。
プログラミング言語:TypeScript
ファイル名:optimistic-card-move.ts
type BoardState = Record<string, string[]>;
type CardMove = {
cardId: string;
fromListId: string;
toListId: string;
toIndex: number;
};
function moveCard(board: BoardState, move: CardMove): BoardState {
const nextBoard = structuredClone(board);
nextBoard[move.fromListId] = nextBoard[move.fromListId].filter(
(id) => id !== move.cardId
);
nextBoard[move.toListId].splice(move.toIndex, 0, move.cardId);
return nextBoard;
}
async function applyOptimisticMove(params: {
currentBoard: BoardState;
move: CardMove;
saveMove: () => Promise<void>;
}) {
const previousBoard = params.currentBoard;
const optimisticBoard = moveCard(params.currentBoard, params.move);
try {
await params.saveMove();
return optimisticBoard;
} catch {
return previousBoard;
}
}
この例では、保存に失敗した場合に前の状態を返しています。実務では、失敗時にエラーメッセージを出したり、再試行ボタンを表示したりすることも必要です。楽観的更新は、速く見せるだけでなく、失敗時にユーザーを迷わせない設計が重要です。
12.3 やり直し機能
ドラッグ&ドロップは、やり直し機能と相性が良い操作です。ユーザーが誤ってカードを別の列へ移動したり、ノードを別の親へ入れたり、画像の順序を間違えたりした場合、すぐに戻せると安心です。特に編集画面やノーコードビルダーでは、ドラッグ操作が大きな変更になるため、やり直し機能は重要になります。
やり直し機能を実装するには、移動前と移動後の差分を履歴として保存します。対象ID、移動元、移動先、元の位置、新しい位置を記録しておけば、戻す処理を作りやすくなります。全状態を丸ごと保存する方法もありますが、大規模な画面では差分履歴の方が効率的です。
12.4 複数選択ドラッグ
複数選択ドラッグでは、複数の要素をまとめて移動します。ファイル管理、画像ギャラリー、レイヤーパネル、ノード編集などでよく使われます。単一要素のドラッグよりも、選択状態、移動中の表示、受け入れ条件、ドロップ後の順序が複雑になります。
複数選択の場合、ドラッグ中の表示には代表要素だけを出すのか、件数を表示するのかを決める必要があります。また、ドロップ領域側では、選択中のすべての要素を受け入れられるかどうかを判定します。1つでも無効な要素が含まれる場合、全体を無効にするのか、有効なものだけ移動するのかも、事前に設計しておく必要があります。
13. 操作感とドラッグ&ドロップ
ドラッグ&ドロップの操作感は、「思った通りに動かせるか」で決まります。ドラッグ中の表示が手に追従し、置ける場所が分かり、挿入位置が明確で、ドロップ後の結果が予測通りになる。この一連の流れが自然であれば、ユーザーは画面を直感的に操作できます。逆に、判定が揺れる、表示が遅れる、ドロップ先が急に変わる、アニメーションが重いと、ユーザーは操作に不安を感じます。
ドラッグ&ドロップの見た目を派手にすることは簡単ですが、操作感を良くするには、入力と表示の同期、安定した重なり判定、適切なフィードバック、軽いアニメーションが必要です。演出は操作を助けるために使うべきであり、ユーザーの意図を邪魔してはいけません。
13.1 視覚フィードバック
視覚フィードバックは、ドラッグ中の状態をユーザーへ伝えるために必要です。ドラッグ元が移動中であること、ドロップ領域が受け入れ可能であること、現在の挿入位置、無効な場所、ドロップ完了後の結果などを、適切に表示する必要があります。
フィードバックは段階的に設計すると分かりやすくなります。通常状態、ドラッグ中、候補上、受け入れ可能、受け入れ不可、ドロップ成功を分け、それぞれに適した見た目を用意します。すべてを派手にする必要はありませんが、状態の違いは明確にするべきです。
13.2 吸着感
吸着感とは、要素が自然に置ける場所へ近づいているように感じることです。ドロップ領域に近づくと仮表示が出る、グリッドに近づくと揃う、ガイドラインに近づくと少し補正される、といった動きが該当します。吸着感があると、ユーザーは置ける場所を理解しやすくなります。
ただし、吸着が強すぎると自由な操作を邪魔します。少し近づいただけで意図しない位置へ引っ張られると、細かい調整が難しくなります。吸着は、ユーザーの操作を補助するものであり、意図を上書きするものではありません。
13.3 スナップ
スナップとは、要素をグリッド、ガイドライン、他要素の端、中心線などに揃える機能です。デザインツール、ノード編集、ゲームの配置画面、キャンバス型の編集画面では重要な機能です。スナップがあると、ユーザーは正確な配置をしやすくなります。
スナップ設計では、発動距離、優先順位、ガイド表示が重要です。対象が多すぎると、どこへ吸着しているのか分かりにくくなります。グリッド、親コンテナ、隣接要素、中心線など、どのスナップを優先するかを決め、ユーザーが理解できる形で表示する必要があります。
13.4 慣性アニメーション
慣性アニメーションは、ドロップ後に要素が自然に位置へ収まるように見せる表現です。ドラッグを離した瞬間に少し滑らかに収まると、操作に自然さが出ます。並び替えでは、周囲の要素が押し出されるように動くことで、位置関係を理解しやすくなります。
ただし、慣性アニメーションは短く軽くする必要があります。長すぎると、操作完了まで待たされているように感じます。ドラッグ&ドロップでは、アニメーションの美しさよりも、追従性と結果の明確さを優先するべきです。
14. よくある失敗
ドラッグ&ドロップでよくある失敗は、見た目だけを先に作り、入力処理、状態管理、重なり判定、パフォーマンスを後回しにすることです。静止画では綺麗に見えても、実際に動かすと、表示が遅れる、候補がちらつく、置ける場所が分からない、スクロールと競合する、モバイルで使えないといった問題が出ます。
ドラッグ&ドロップは、動かして初めて品質が分かるユーザーインターフェースです。ゆっくり動かした場合、素早く動かした場合、端へ移動した場合、無効な場所へ置いた場合、スクロールしながら移動した場合、タッチ操作した場合まで確認する必要があります。
14.1 重なり判定が不安定
重なり判定が不安定だと、ドロップ先が頻繁に切り替わります。ユーザーがゆっくり動かしているのに候補がちらついたり、少し動いただけで別の領域へ飛んだりすると、どこへ置かれるのか分からなくなります。これは、判定基準が単純すぎる、候補が密集している、切り替えの閾値がない場合に起こりやすいです。
改善するには、現在の候補を少し維持する、一定距離以上離れたときだけ切り替える、移動方向や重なり面積を考慮するなどの工夫が必要です。重なり判定では、正確さだけでなく、候補が不必要に揺れない安定性が重要です。
14.2 ドラッグ中の表示が遅れる
ドラッグ中の表示がポインターに遅れてついてくると、ユーザーは強い違和感を覚えます。ドラッグ&ドロップでは、ユーザーが自分の手で要素を動かしている感覚が重要なので、表示の遅延は操作感を大きく壊します。原因としては、ポインター移動ごとの重い状態更新、大量の再描画、レイアウト再計算、重いスタイル表現などがあります。
改善するには、表示を前面表示レイヤーとして独立させ、transformで移動し、描画更新をrequestAnimationFrameにまとめます。また、高頻度で変わる座標を全体状態へ入れすぎないことも重要です。ドラッグ中の表示は、ドラッグ&ドロップ体験の中心なので、最優先で軽くするべきです。
14.3 再描画が多すぎる
再描画が多すぎると、ドラッグ中に画面がカクつきます。ポインターが動くたびに画面全体を再描画したり、大量のリスト項目を更新したりすると、すぐに重くなります。特にReactのようなライブラリでは、状態の置き場所を誤ると、関係ない部品まで何度も再描画されます。
改善するには、ドラッグ中の一時状態と確定状態を分けます。座標は軽量に管理し、候補領域や挿入位置が変わったときだけ意味的な状態を更新します。また、リスト項目をメモ化する、前面表示レイヤーを分離する、判定対象を絞るなどの対策も有効です。
14.4 モバイルで崩れる
モバイルでは、マウス操作と同じドラッグ&ドロップはそのまま成立しないことがあります。hoverがない、スクロールとドラッグが競合する、指で表示が隠れる、長押しが分かりにくい、判定範囲が狭いといった問題が起こります。PCでは自然に使えても、タッチ端末では操作しづらい場合があります。
改善するには、長押し開始、ドラッグハンドル、広めの判定範囲、スクロールとの分離、代替操作を用意します。モバイルでは、PCと同じ操作をそのまま再現するより、タッチ操作に合った形へ調整することが重要です。
14.5 重なり順が崩れる
重なり順が崩れると、ドラッグ中の表示が他のパネルやモーダルの下に隠れます。複雑な画面では、前面表示レイヤー、モーダル、ポップアップ、通知、ツールチップなどが同時に存在するため、重なり順の管理が非常に重要です。
改善するには、前面表示レイヤーをPortalでbody直下へ描画し、重なり順を変数として管理します。場当たり的に大きなz-indexを指定するのではなく、ユーザーインターフェース全体のレイヤー設計として扱うことが重要です。
おわりに
ドラッグ&ドロップ設計は、単なる「要素を動かすユーザーインターフェース」ではありません。入力処理、ドラッグ開始判定、ポインター追跡、ドラッグ中の表示、前面表示レイヤー、ドロップ領域、重なり判定、スクロール連携、状態管理、アニメーション、やり直し機能までを統合する設計課題です。ユーザーからは直感的に見える操作でも、内部では複数の処理が低遅延で同期しています。
重要なのは、「ドラッグ元」「ドロップ領域」「ドラッグ中の表示」「重なり判定」を独立した責務として分けることです。ドラッグ元は何を移動するかを定義し、ドロップ領域はどこへ置けるかを定義し、ドラッグ中の表示は操作中の視覚認識を支え、重なり判定は現在の候補を計算します。これらを1つの処理に詰め込むと、実装はすぐに複雑になります。逆に、責務を分けて同期させれば、複雑なドラッグ&ドロップ画面でも拡張しやすくなります。
実務で高品質なドラッグ&ドロップを作るには、操作中の低遅延、安定した重なり判定、分かりやすいドロップ領域、遅れないドラッグ中の表示、スクロール連携、モバイル対応、アクセシビリティ、パフォーマンスをすべて考える必要があります。ドラッグ&ドロップの品質は、ユーザーが「思った通りに動かせる」と感じられるかどうかで決まります。だからこそ、見た目の動きだけでなく、入力、描画、状態、判定を統合するユーザーインターフェースアーキテクチャとして扱うことが重要です。
EN
JP
KR