Reactで、付せんをドラッグして自由に動かす方法
付せんの位置(x・y)と重なり順(z)を state に持ち、つまみで受け取る Pointer Events をもとにその値を書き換えれば、ライブラリを使わずに付せんを好きな位置へ動かせます。押したときに setPointerCapture() を呼ぶと、ポインターがつまみから外れても、離すまで付せんが付いてきます。
いちばん上に重ねるには、z-index に使う z の値を大きくします。配列の最後へ移して DOM の並びで重ねる方法もありますが、検証ではドラッグが途中で止まりました。その結果と、考えられる理由もあわせて書きます。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 で、Playwright でマウスとキーボードを操作して確かめました。タッチ操作、Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げは試していません。ボードの大きさが途中で変わる場合や、付せんがとても多い場合の動きも、この記事では扱いません。

付せんの位置と重なり順を持つコンポーネント
付せん1枚は { id, x, y, z, text, color } で表します。x と y はボードの左上から付せんの左上までの px、z は重なり順です。NoteBoard は付せんの一覧を親から受け取り、動かすたびに onChange で新しい一覧を渡します。ドラッグを終えて離したときと、矢印キーで動かしたときには、onDrop も呼びます。
import { useEffect, useRef } from 'react';
import type { KeyboardEvent, PointerEvent } from 'react';
export type Note = { id: number; x: number; y: number; z: number; text: string; color: string };
type Drag = { id: number; offsetX: number; offsetY: number };
type Props = {
notes: Note[];
onChange: (notes: Note[]) => void;
// ドラッグを終えて離したときと、矢印キーで動かしたときに呼ぶ(ドラッグ中は呼ばない)
onDrop?: (notes: Note[]) => void;
};
const clamp = (n: number, min: number, max: number) => Math.min(Math.max(n, min), max);
export function NoteBoard({ notes, onChange, onDrop }: Props) {
const boardRef = useRef<HTMLDivElement>(null);
const drag = useRef<Drag | null>(null);
// いちばん新しい付せんの一覧。props の notes はレンダリングした時点の値のままなので、
// 同じハンドラーの中で変えたばかりの値を onDrop に渡せるように、ref にも持っておく
const latest = useRef(notes);
useEffect(() => { latest.current = notes; }, [notes]);
// 付せんの左上を、ボードの中に収まる位置に直す
function fit(x: number, y: number, el: HTMLElement) {
const board = boardRef.current!;
return {
x: Math.round(clamp(x, 0, board.clientWidth - el.offsetWidth)),
y: Math.round(clamp(y, 0, board.clientHeight - el.offsetHeight)),
};
}
function update(id: number, patch: Partial<Note>) {
const next = latest.current.map((n) => (n.id === id ? { ...n, ...patch } : n));
latest.current = next;
onChange(next);
}
// いちばん上に重ねる(z をいまの最大より 1 大きくする)
function bringToFront(id: number) {
const top = Math.max(...latest.current.map((n) => n.z));
const note = latest.current.find((n) => n.id === id)!;
if (note.z !== top) update(id, { z: top + 1 });
}
function handlePointerDown(e: PointerEvent<HTMLDivElement>, note: Note) {
if (e.button !== 0) return;
const board = boardRef.current!.getBoundingClientRect();
// つかんだ位置と付せんの左上のずれ。これを保って動かすと、付せんが指の位置へ飛ばない
drag.current = {
id: note.id,
offsetX: e.clientX - board.left - note.x,
offsetY: e.clientY - board.top - note.y,
};
bringToFront(note.id);
// ポインターがつまみの外へ出ても、離すまでこの要素にイベントが届くようにする
e.currentTarget.setPointerCapture(e.pointerId);
}
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
const d = drag.current;
if (!d) return;
const board = boardRef.current!.getBoundingClientRect();
const el = e.currentTarget.parentElement!; // 付せん全体
update(d.id, fit(e.clientX - board.left - d.offsetX, e.clientY - board.top - d.offsetY, el));
}
function handlePointerUp() {
if (!drag.current) return;
drag.current = null;
onDrop?.(latest.current);
}
// 矢印キーで 10px ずつ動かす
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>, note: Note) {
const step = { ArrowLeft: [-10, 0], ArrowRight: [10, 0], ArrowUp: [0, -10], ArrowDown: [0, 10] }[e.key];
if (!step) return;
e.preventDefault();
update(note.id, fit(note.x + step[0], note.y + step[1], e.currentTarget.parentElement!));
onDrop?.(latest.current);
}
return (
<div className="board" ref={boardRef}>
{notes.map((note) => (
<div
key={note.id}
className="note"
data-id={note.id}
style={{ transform: `translate(${note.x}px, ${note.y}px)`, zIndex: note.z, background: note.color }}
>
<div
className="note-handle"
tabIndex={0}
role="button"
aria-label={`付せん${note.id}を動かす(矢印キーでも動きます)`}
onPointerDown={(e) => handlePointerDown(e, note)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onFocus={() => bringToFront(note.id)}
onKeyDown={(e) => handleKeyDown(e, note)}
/>
<textarea
aria-label={`付せん${note.id}の内容`}
value={note.text}
onChange={(e) => update(note.id, { text: e.target.value })}
onFocus={() => bringToFront(note.id)}
/>
</div>
))}
</div>
);
}
ドラッグ中の情報(どの付せんか、つかんだ位置と付せんの左上とのずれ)は drag という ref に入れています。ref を書き換えても再レンダリングは起きないので、画面に出さない値を置くのに向いています。
付せんの一覧は、latest という ref にも持たせています。props の notes は、そのレンダリングの時点の値のままです。矢印キーのハンドラーのように、位置を変えてすぐ onDrop に渡す場面では、notes ではなく latest.current を渡さないと、変える前の位置が保存されてしまいます。
付せんを動かせるのは、上の帯(つまみ)だけです。本文の textarea を押したときは、ふつうに文字を選んだり入力したりできます。付せんの表示位置は transform: translate() で指定し、fit でボードの外へはみ出さない位置に直しています。
見た目は CSS で付けています。付せんは .board の中で position: absolute にし、.note-handle には touch-action: none を付けています。
.board {
position: relative;
height: 360px;
background: #eefafd;
border: 1px solid #9aa5ab;
border-radius: 8px;
overflow: hidden;
}
.note {
position: absolute;
left: 0;
top: 0;
width: 160px;
height: 120px;
display: flex;
flex-direction: column;
box-shadow: 0 2px 6px rgb(0 0 0 / 0.2);
}
.note-handle {
height: 22px;
flex: none;
background: rgb(0 0 0 / 0.08);
cursor: grab;
/* タッチでドラッグしたときに、ページがスクロールしないようにする */
touch-action: none;
}
.note-handle:active { cursor: grabbing; }
.note-handle:focus-visible { outline: 3px solid #00c8e8; }
.note textarea {
flex: 1;
border: 0;
padding: 6px 8px;
background: transparent;
font: inherit;
resize: none;
}
タッチでは、ブラウザがスクロールや拡大を始めると pointercancel が送られ、ドラッグが続かなくなります(MDN の touch-action の説明)。touch-action: none は、つまみの上でブラウザにスクロールや拡大をさせないための指定です。ただし、筆者はタッチ操作では試していません。
位置を保存する App
使う側の App は、付せんの一覧を state に持ち、onDrop で localStorage に保存します。ページを開いたときは、保存してある一覧があればそれを使います。
import { useState } from 'react';
import { NoteBoard } from './NoteBoard';
import type { Note } from './NoteBoard';
const STORAGE_KEY = 'notes';
const initialNotes: Note[] = [
{ id: 1, x: 20, y: 20, z: 1, text: '牛乳を買う', color: '#fff3a8' },
{ id: 2, x: 120, y: 70, z: 2, text: '記事の図を描く', color: '#c9f1fa' },
{ id: 3, x: 220, y: 120, z: 3, text: '本を返す', color: '#ffd6e0' },
];
// 保存してある位置があれば、それを使う
function load(): Note[] {
try {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? (JSON.parse(saved) as Note[]) : initialNotes;
} catch {
return initialNotes;
}
}
export function App() {
const [notes, setNotes] = useState<Note[]>(load);
function save(next: Note[]) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
// 保存できなくても、画面の操作は続けられるようにする
}
}
return (
<main>
<NoteBoard notes={notes} onChange={setNotes} onDrop={save} />
<ul className="info">
{notes.map((n) => (
<li key={n.id}>付せん{n.id}:({n.x}, {n.y}) z {n.z}</li>
))}
</ul>
</main>
);
}
ドラッグ中は保存せず、離したときに保存します(矢印キーで動かしたときは、押すたびに保存します)。付せん1を 20回の pointermove でドラッグしたとき、localStorage.setItem が呼ばれたのは離したときの1回だけでした。ページを読み込み直すと、付せんは離した位置に出ました。
付せん1を 20回の pointermove でドラッグ: 付せん1:(140, 210) z 7 / 付せん2:(0, 0) z 5 / 付せん3:(220, 120) z 6
localStorage.setItem の回数: ドラッグ中 0回、離したあと 1回
ページを読み込み直したあと: 付せん1:(140, 210) z 7 / 付せん2:(0, 0) z 5 / 付せん3:(220, 120) z 6
localStorage に入れた値は、ブラウザを閉じても残ります。ただし、ブラウザの設定によっては保存できないことがあるので、setItem は try で囲んでいます。なお、このコードでは本文の欄で書き換えた文字は onDrop を通らないので、次に付せんを動かすまで保存されません。文字も保存したいときは、入力のたびに保存する処理を別に足します。
ドラッグしたときの動き
ボードの内側が 598×360px(ブラウザの表示領域の幅が 640px のとき)、付せんが 160×120px の状態で、Playwright でつまみをドラッグしました。付せんの位置は、ボードの左上からの px です。
付せん1:(20, 20) z 1 / 付せん2:(120, 70) z 2 / 付せん3:(220, 120) z 3
付せん1のつまみの左端から30px・上端から10pxをつかみ、右へ200px・下へ130pxドラッグ: 付せん1:(220, 150) z 4 / 付せん2:(120, 70) z 2 / 付せん3:(220, 120) z 3
離したときのポインターと、付せん1のつまみの左上とのずれ: (30, 10)
ボードの (230, 160) でいちばん上の付せん: 1
付せん2を、ボードの右下の外までドラッグ: 付せん1:(220, 150) z 4 / 付せん2:(438, 240) z 5 / 付せん3:(220, 120) z 3
付せん2を、ボードの左上の外までドラッグ: 付せん1:(220, 150) z 4 / 付せん2:(0, 0) z 5 / 付せん3:(220, 120) z 3
付せん1は、ドラッグした分だけ動いて (220, 150) になりました。離したときも、ポインターはつまみの左端から 30px・上端から 10px の所にあります。押したときのずれ(offsetX・offsetY)を保って動かしているので、つかんだ所が付せんの左上へ飛ぶことはありません。
付せん1の z は 4 になり、付せん3と重なった所では付せん1が上に見えるようになりました。CSS の z-index は、値の大きい要素が小さい要素の上に重なります。
付せん2をボードの外までドラッグしても、右下では (438, 240) で止まりました。ボードの内側の幅 598 から付せんの幅 160 を引くと 438、高さ 360 から 120 を引くと 240 です。左上の外まで引いたときは (0, 0) で止まりました。
付せん3のつまみをクリックしただけのときは、位置は変わらず、いちばん上に重なるだけでした(付せん3の z が 6 になり、付せん1との重なりで上に見えるようになりました)。本文の上でドラッグしたときは付せんは動かず、文字が選択されました。続けて入力した文字も、そのまま入りました。
付せん3の本文の上で、右へ110pxドラッグ: 付せん1:(220, 150) z 4 / 付せん2:(0, 0) z 5 / 付せん3:(220, 120) z 6 / 選ばれた文字: textarea の 0〜4 文字目
続けて入力: 付せん3の内容「本を返す(金曜まで)」
つまみにはキーボードでもフォーカスでき、矢印キーで 10px ずつ動きます。付せん1のつまみで → を2回と ↓ を1回押すと、(220, 150) から (240, 160) へ動きました。
setPointerCapture を呼ばないと、ポインターがつまみから外れたところで止まる
ポインターのイベントは、ふつうはポインターの下にある要素に届きます。setPointerCapture() を呼ぶと、そのポインターのイベントは、離すまで呼んだ要素に届くようになります(MDN の Pointer Events の説明)。
setPointerCapture を何もしない関数に置き換えたページと比べました。付せん1を、10回の pointermove で右へ 300px・下へ 150px ドラッグしています。つまみの上端から 10px の所をつかんでいて、1回で 15px 下がるので、付せんが付いてこなければ、ポインターは1回目で高さ 22px のつまみから外れます。
あり: 付せん1を右へ300px・下へ150pxドラッグして離す: 付せん1:(320, 170) z 4 / 押さずに、ポインターを元の位置へ戻す: 付せん1:(320, 170) z 4
ドラッグ中にイベントが届いた要素: 付せん1のつまみ 付せん1のつまみ 付せん1のつまみ 付せん1のつまみ 付せん1のつまみ 付せん1のつまみ 付せん1のつまみ 付せん1のつまみ 付せん1のつまみ 付せん1のつまみ 離す:付せん1のつまみ
なし: 付せん1を右へ300px・下へ150pxドラッグして離す: 付せん1:(200, 110) z 4 / 押さずに、ポインターを元の位置へ戻す: 付せん1:(140, 80) z 4
ドラッグ中にイベントが届いた要素: 付せん1の本文 付せん1の本文 付せん1の本文 付せん1の本文 付せん2の本文 付せん3のつまみ 付せん1の本文 付せん1の本文 付せん1の本文 付せん1の本文 離す:付せん1の本文
呼んだ場合は、10回の pointermove と離したときの pointerup が、すべて付せん1のつまみに届きました。呼ばない場合は、イベントはポインターの下にある本文の欄などに届きました。付せん1が動いたのは、途中で付せん3のつまみの上を通ったときの1回だけで、(200, 110) で止まっています。
さらに、離したときの pointerup も本文の欄に届いたので、ドラッグが終わったことが伝わりませんでした。そのあとボタンを押さずにポインターを元の位置へ戻しただけで、付せん1が (140, 80) へ動きました。
重なり順を配列の並べ替えで変えると、ドラッグが止まる
z-index を指定しなくても、position: absolute などを指定した要素は、HTML の中で後ろにあるものほど上に描かれます。そこで、押した付せんを配列の最後へ移して上に重ねる版も作り、比べました。bringToFront を次のように変え、zIndex の指定を外しています。
// 比べる用:z は変えず、配列の最後へ移して(DOM の最後に描いて)上に重ねる
function bringToFront(id: number) {
const list = latest.current;
if (list[list.length - 1].id === id) return;
const next = [...list.filter((n) => n.id !== id), list.find((n) => n.id === id)!];
latest.current = next;
onChange(next);
}
いちばん下にある付せん1を、上と同じように右へ 300px・下へ 150px ドラッグしました。
z を変える版: 押した直後の hasPointerCapture: true
z を変える版: 付せん1を右へ300px・下へ150pxドラッグ: 付せん1:(320, 170) z 4 / gotpointercapture 1回 / 付せんの DOM の並び: 1,2,3
配列の最後へ移す版: 押した直後の hasPointerCapture: false
配列の最後へ移す版: 付せん1を右へ300px・下へ150pxドラッグ: 付せん1:(200, 110) z 1 / gotpointercapture 0回 / 付せんの DOM の並び: 2,3,1
配列の最後へ移す版では、押した直後の hasPointerCapture() が false で、gotpointercapture も起きませんでした。付せん1は (200, 110) で止まり、setPointerCapture を呼ばなかった場合と同じ動きになりました。
setPointerCapture() は呼んでいますが、そのあと React が配列の並びに合わせて付せん1の要素を DOM の最後へ移したため、キャプチャが効かなくなったと考えられます。MDN の Pointer Events の説明にも、要素を DOM の中で移すときは、移したあとで setPointerCapture() を呼ぶようにと書かれています(キャプチャの節の注意書き)。
z を変える版では、DOM の並びは 1,2,3 のままで、付せん1は最後まで付いてきました。
ドラッグする要素の重なり順は、配列の並びではなく z-index で変えます。DOM の中で要素が動かないので、キャプチャが外れません。





