Reactで、ドラッグで範囲を囲んで複数の項目を選ぶ方法
押した位置といまのポインターの位置から四角い枠を求め、各項目の四角と重なるかを調べれば、ライブラリを使わずに「ドラッグで囲んで選ぶ」操作を作れます。項目の位置は、押したときに getBoundingClientRect() で一度だけ測っておき、ドラッグ中は数字の比較だけで選ぶ項目を決めます。
あわせて、Shift を押したまま囲んで選択に足す方法、クリックとドラッグの区別、ドラッグ中にページの文字が選択されてしまうのを防ぐ CSS も、Playwright で動かして確かめました。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 で、Playwright でマウスとキーボードを操作して確かめました。タッチ操作、macOS(Command キーを押したクリック)、Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げは試していません。項目の並ぶ領域がスクロールする場合や、項目がとても多い場合の動きも、この記事では扱いません。

枠を描いて、かかった項目を選ぶコンポーネント
MarqueeGrid は、項目の一覧と、選ばれている項目の id の Set を親から受け取ります。選ばれた項目が変わると、onSelectedChange で新しい Set を渡します。項目は button にして、選ばれているかどうかを aria-pressed で表しています。
import { useRef, useState } from 'react';
import type { MouseEvent, PointerEvent } from 'react';
export type Item = { id: number; name: string };
type Box = { left: number; top: number; right: number; bottom: number };
type Drag = {
startX: number;
startY: number;
// ドラッグを始める前から選んであった項目(Shift などを押して始めたときだけ)
base: Set<number>;
// ドラッグを始める前の選択(取り消されたときに戻す)
initial: Set<number>;
// 押した時点で測った、各項目の位置(領域の左上からの px)
rects: Map<number, Box>;
// しきい値を超えて動いたか(false のまま離したらクリック)
moved: boolean;
};
type Props = {
items: Item[];
selected: Set<number>;
onSelectedChange: (next: Set<number>) => void;
// 'touch':枠に少しでもかかった項目を選ぶ/'contain':枠に全体が入った項目だけを選ぶ
mode?: 'touch' | 'contain';
};
// 押した位置から、この px より動いたら範囲選択とみなす(それまではクリック)
const THRESHOLD = 4;
const clamp = (n: number, min: number, max: number) => Math.min(Math.max(n, min), max);
function hit(box: Box, r: Box, mode: 'touch' | 'contain') {
if (mode === 'contain') {
return r.left >= box.left && r.right <= box.right && r.top >= box.top && r.bottom <= box.bottom;
}
return r.left < box.right && r.right > box.left && r.top < box.bottom && r.bottom > box.top;
}
const sameSet = (a: Set<number>, b: Set<number>) => a.size === b.size && [...a].every((id) => b.has(id));
export function MarqueeGrid({ items, selected, onSelectedChange, mode = 'touch' }: Props) {
const areaRef = useRef<HTMLDivElement>(null);
const drag = useRef<Drag | null>(null);
// 画面に描く枠。null のときは描かない
const [box, setBox] = useState<Box | null>(null);
// ポインターの位置を、領域の左上からの px に直す(領域の外は端に寄せる)
function toLocal(e: PointerEvent) {
const area = areaRef.current!;
const r = area.getBoundingClientRect();
return {
x: clamp(e.clientX - r.left - area.clientLeft, 0, area.clientWidth),
y: clamp(e.clientY - r.top - area.clientTop, 0, area.clientHeight),
};
}
function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
// 項目の上で押したときは、項目のクリックに任せる
if (e.button !== 0 || (e.target as HTMLElement).closest('[data-id]')) return;
const area = areaRef.current!;
const a = area.getBoundingClientRect();
const rects = new Map<number, Box>();
area.querySelectorAll<HTMLElement>('[data-id]').forEach((el) => {
const r = el.getBoundingClientRect();
const left = r.left - a.left - area.clientLeft;
const top = r.top - a.top - area.clientTop;
rects.set(Number(el.dataset.id), { left, top, right: left + r.width, bottom: top + r.height });
});
const { x, y } = toLocal(e);
const keep = e.shiftKey || e.ctrlKey || e.metaKey;
drag.current = { startX: x, startY: y, base: keep ? new Set(selected) : new Set(), initial: selected, rects, moved: false };
// ポインターが領域の外へ出ても、離すまでこの要素にイベントが届くようにする
e.currentTarget.setPointerCapture(e.pointerId);
}
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
const d = drag.current;
if (!d) return;
const { x, y } = toLocal(e);
if (!d.moved && Math.hypot(x - d.startX, y - d.startY) <= THRESHOLD) return;
d.moved = true;
const next: Box = {
left: Math.min(d.startX, x),
top: Math.min(d.startY, y),
right: Math.max(d.startX, x),
bottom: Math.max(d.startY, y),
};
setBox(next);
const ids = new Set(d.base);
for (const [id, r] of d.rects) if (hit(next, r, mode)) ids.add(id);
// 選ばれた項目が変わったときだけ親に知らせる
if (!sameSet(ids, selected)) onSelectedChange(ids);
}
function handlePointerUp(e: PointerEvent<HTMLDivElement>) {
const d = drag.current;
if (!d) return;
drag.current = null;
if (e.type === 'pointercancel') {
// ブラウザに操作を取り上げられたときは、始める前の選択に戻す
if (d.moved) onSelectedChange(d.initial);
} else if (!d.moved && !(e.shiftKey || e.ctrlKey || e.metaKey) && selected.size > 0) {
// 何もない所をクリックしただけなら、選択を外す
onSelectedChange(new Set());
}
setBox(null);
}
function handleItemClick(e: MouseEvent<HTMLButtonElement>, id: number) {
// Ctrl(Mac は Command)を押したクリックと、キーボードでの操作(detail が 0)は、その項目だけを切り替える
if (e.ctrlKey || e.metaKey || e.detail === 0) {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
onSelectedChange(next);
} else {
onSelectedChange(new Set([id]));
}
}
return (
<div
className="marquee-area"
ref={areaRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{items.map((item) => (
<button
key={item.id}
type="button"
data-id={item.id}
className="marquee-item"
aria-pressed={selected.has(item.id)}
onClick={(e) => handleItemClick(e, item.id)}
>
{item.name}
</button>
))}
{box && (
<div
className="marquee-box"
style={{ left: box.left, top: box.top, width: box.right - box.left, height: box.bottom - box.top }}
/>
)}
</div>
);
}
押したときの位置、始める前の選択、各項目の位置は、drag という ref に入れています。ref を書き換えても再レンダリングは起きないので、画面に出さない値を置くのに向いています。画面に描く枠(box)だけを state にしています。
項目の位置は、押したときに一度だけ測る
handlePointerDown では、各項目の getBoundingClientRect() を呼び、領域の左上からの位置に直して rects に入れています。getBoundingClientRect() が返す値はブラウザの表示領域からの位置なので、領域の位置(と枠線の太さ clientLeft・clientTop)を引いて、枠と同じ基準にそろえます。
ドラッグ中の handlePointerMove は、測っておいた rects と枠の四角を比べるだけで、項目の DOM には触れません。そのかわり、ドラッグの途中で項目の位置が変わると、測った値とずれます。領域をスクロールできるようにする場合などは、別の手当てが必要になります(この記事では試していません)。
枠に「かかった」か「全体が入った」か
選ぶ条件は hit で決めています。mode が 'touch' のときは、枠と項目の四角が少しでも重なれば選びます。'contain' のときは、項目の四角が枠の中に全部入ったときだけ選びます。
クリックとドラッグを分ける
押してから 4px(THRESHOLD)より動くまでは、枠を描きません。動かないまま離したときはクリックとして扱い、何もない所のクリックなら選択を外します。手が少しぶれただけで、選択が消えたり枠が出たりしないようにするためです。
項目の上で押したときは、handlePointerDown は何もせず、項目の onClick に任せます。ふつうのクリックはその項目だけを選び、Ctrl(Mac では Command)を押したクリックは、その項目を選択に足したり外したりします。Shift を押したドラッグは選択に足しますが、Shift を押した項目のクリックは、このコードではふつうのクリックと同じ扱いです。
取り消されたときと、領域の外へ出たとき
ドラッグの途中で pointercancel が届いたときは、始める前の選択(initial)に戻します。pointercancel は、押したあとでブラウザがスクロールや拡大を始めたときなどに送られるイベントです。
また、押したときに setPointerCapture() を呼んでいるので、そのポインターのイベントは、離すまで領域の要素に届きます。ポインターが領域の外へ出たときは、toLocal で位置を領域の端に寄せ、枠が領域からはみ出さないようにしています。
選んだ項目を持つ App
使う側の App は、選ばれた項目の id を Set にして state に持ちます。onSelectedChange には、毎回新しい Set が渡されるので、setSelected をそのまま渡せます。
import { useState } from 'react';
import { MarqueeGrid } from './MarqueeGrid';
import type { Item } from './MarqueeGrid';
const items: Item[] = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, name: `写真${i + 1}` }));
export function App() {
const [selected, setSelected] = useState<Set<number>>(() => new Set());
// 確かめるために、URL の ?mode=contain で選び方を切り替える
const mode = new URLSearchParams(location.search).get('mode') === 'contain' ? 'contain' : 'touch';
return (
<main>
<MarqueeGrid items={items} selected={selected} onSelectedChange={setSelected} mode={mode} />
<p className="info" aria-live="polite">
{selected.size}件を選択中{selected.size > 0 && `:${[...selected].sort((a, b) => a - b).join(', ')}`}
</p>
</main>
);
}
検証のために、URL に ?mode=contain を付けたときだけ、選び方を 'contain' に切り替えています。
見た目は CSS で付けています。領域には position: relative、枠には position: absolute を指定し、枠の位置は領域の左上からの px で決めます。枠には pointer-events: none を付けて、枠の上でもイベントが下の領域に届くようにしています。
.marquee-area {
position: relative;
display: grid;
grid-template-columns: repeat(auto-fill, 104px);
gap: 16px;
padding: 16px;
background: #eefafd;
border: 1px solid #9aa5ab;
border-radius: 8px;
/* ドラッグしたときに、文字が選択されないようにする */
user-select: none;
/* タッチでドラッグしたときに、ページがスクロールしないようにする */
touch-action: none;
}
.marquee-item {
height: 56px;
border: 2px solid #c5d3d9;
border-radius: 6px;
background: #fff;
color: inherit;
font: inherit;
}
.marquee-item[aria-pressed='true'] {
border-color: #00c8e8;
background: #c9f1fa;
}
.marquee-item:focus-visible { outline: 3px solid #067a98; outline-offset: 2px; }
.marquee-box {
position: absolute;
border: 1px solid #067a98;
background: rgb(0 200 232 / 0.15);
pointer-events: none;
}
touch-action: none は、タッチでドラッグしたときに、ブラウザにスクロールや拡大をさせないための指定です。MDN の touch-action の説明によると、none を指定した要素の上では、ブラウザはスクロールも拡大もしません。領域が画面より大きくなるときや、拡大して見たい人がいる画面では、指定する範囲を見直してください。筆者はタッチ操作では試していません。
ドラッグしたときの動き
ブラウザの表示領域の幅が 640px のとき、領域の内側は 598×232px で、104×56px の項目が 4列×3行に並びました。座標は、領域の左上(枠線の内側)からの px です。
領域の内側: 598×232 / 項目の大きさ: 104×56 / 写真1 (16, 16)・写真2 (136, 16)・写真4 (376, 16)・写真5 (16, 88)・写真12 (376, 160)
0件を選択中
(8, 8) から (250, 100) まで動かした途中: 枠 left 8px top 8px width 242px height 92px / 4件を選択中:1, 2, 5, 6
離したあと: 4件を選択中:1, 2, 5, 6 / 枠の表示 0個
修飾キーなしで (368, 80) から (590, 230) まで: 2件を選択中:8, 12
(8, 8)〜(250, 100) を囲んだあと、Shift を押したまま (368, 80) から (590, 230) まで: 6件を選択中:1, 2, 5, 6, 8, 12
(8, 8) から (250, 100) まで動かすと、枠は左上 (8, 8)、幅 242px・高さ 92px で描かれました。写真1・2(上の段)と写真5・6(2段目)は、枠と少しでも重なっているので選ばれています。離すと、選択はそのまま残り、枠は消えました。
修飾キーなしで別の所を囲むと、それまでの選択は外れて、写真8・12だけになりました。Shift を押したまま囲んだときは、前の選択に写真8・12が足されています。始める前の選択を base に取っておき、そこへ枠にかかった項目を足しているからです。
(250, 100) まで広げたとき: 4件を選択中:1, 2, 5, 6 / (100, 60) まで戻したとき: 1件を選択中:1
枠を広げたあとで小さくすると、枠から外れた項目は選択から外れました。ドラッグ中は、毎回 base から選び直しているので、一度かかった項目が残り続けることはありません。
クリックとキーボードでの選び方
マウスのクリックと、Ctrl を押したクリックで選んだ結果です。
項目のすき間 (128, 80) をクリック: 0件を選択中
写真3をクリック: 1件を選択中:3
Ctrl を押して写真7と写真10をクリック: 3件を選択中:3, 7, 10
Ctrl を押して写真3をもう一度クリック: 2件を選択中:7, 10
修飾キーなしで写真5をクリック: 1件を選択中:5
すき間で押して右へ 3px だけ動かして離す: 0件を選択中 / 枠の表示 0個
すき間を押して右へ 3px だけ動かして離したときは、枠は出ず、クリックとして選択が外れました。
キーボードでは、写真2から始めて Tab で次の項目へ移り、Space か Enter を押すと、その項目が選択に足されました。選ばれている写真4でもう一度 Space を押すと、写真4だけが外れました。handleItemClick で、キーボードの操作を Ctrl を押したクリックと同じに扱っているからです。キーボードで起きた click イベントの detail(クリックの回数)は、検証環境の Chromium では 4回とも 0 で、マウスでクリックしたときは 1 でした。
写真2で Space、Tab で写真3へ移って Enter、Tab で写真4へ移って Space: 3件を選択中:2, 3, 4 / aria-pressed="true" の項目: 写真2, 写真3, 写真4
続けて写真4でもう一度 Space: 2件を選択中:2, 3
キーボードで起きた click の detail: 0, 0, 0, 0
マウスで写真6をクリック: 1件を選択中:6 / click の detail: 1
もし e.detail === 0 を見ないと、コードの上では、キーボードで次の項目を選ぶたびに前の選択が外れてしまいます。マウスを使わない人も複数の項目を選べるように、この分け方にしました。選ばれているかどうかは aria-pressed で表しています。
途中で pointercancel が届いたとき
写真3・7を選んだ状態から、修飾キーなしで囲んでいる途中に、領域へ pointercancel を送りました。実際のタッチ操作の代わりに、Playwright の dispatchEvent() でイベントを送っています。
写真3・7を選んだ状態: 2件を選択中:3, 7 / 修飾キーなしで囲んでいる途中: 4件を選択中:1, 2, 5, 6 / pointercancel のあと: 2件を選択中:3, 7 / 枠の表示 0個
そのあとボタンを離す: 2件を選択中:3, 7
囲んでいる途中では写真1・2・5・6に変わっていた選択が、pointercancel のあとは写真3・7に戻り、枠も消えました。そのあとボタンを離しても、選択は変わりません。
領域の外まで引っぱったとき
(8, 8) から、領域の右下の外 (800, 400) まで動かして、外で離す: 枠 left 8px top 8px width 590px height 224px / 12件を選択中:1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 / 離したあとの枠の表示 0個
ボタンを押さずに領域の中へ戻す: 12件を選択中:1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12
領域の右下の外まで動かしても、枠は左上 (8, 8)、幅 590px・高さ 224px で止まりました。598 − 8 = 590、232 − 8 = 224 で、ちょうど領域の内側の右下の端までです。12件すべてが選ばれ、外で離したあとは枠が消えました。そのあとボタンを押さずに領域の中へ戻しても、選択は変わりません。離したときの pointerup が領域に届き、ドラッグが終わったことが伝わっています。
「かかった項目」と「全体が入った項目」の違い
同じ (8, 8) から (250, 100) までのドラッグを、mode だけ変えて比べました。
mode 'touch': (8, 8) から (250, 100) まで: 4件を選択中:1, 2, 5, 6
mode 'contain': (8, 8) から (250, 100) まで: 2件を選択中:1, 2
枠の下の辺は y = 100 で、2段目の写真5・6(上の辺が y = 88、下の辺が y = 144)には少しだけかかります。'touch' ではこの2つも選ばれ、'contain' では全体が入った写真1・2だけが選ばれました。少し触れただけで選ばれるのが困る場面では、'contain' が向いています。
ドラッグで、ページの文字まで選択されてしまう
範囲選択の領域は、ブラウザにとってはただの要素の集まりです。そのままでは、何もない所から押してドラッグすると、ブラウザの文字の選択も始まります。CSS の user-select: none を外して、領域の (8, 8) から、その下の「…件を選択中」の行の下までドラッグし、getSelection() で選択された文字列を調べました。
user-select: none あり: 領域の (8, 8) から、下の「…件を選択中」の行の下まで: 6件を選択中:1, 2, 5, 6, 9, 10 / 選択された文字列: ""
user-select: none なし: 領域の (8, 8) から、下の「…件を選択中」の行の下まで: 6件を選択中:1, 2, 5, 6, 9, 10 / 選択された文字列: "写真1\n写真2\n写真3\n写真4\n写真5\n写真6\n写真7\n写真8\n写真9\n写真10"
user-select: none なし、pointerdown で preventDefault(): 領域の (8, 8) から、下の「…件を選択中」の行の下まで: 6件を選択中:1, 2, 5, 6, 9, 10 / 選択された文字列: ""
user-select: none がないと、項目の選択とは別に、項目の文字(写真1〜写真10)がページの文字として選択されました。user-select: none を付けた領域では、その中の文字が選択できなくなり、選択された文字列は空でした。
user-select: none を付けずに、pointerdown で preventDefault() を呼んだ場合も、検証環境の Chromium では文字は選択されませんでした(検証では、document に足した pointerdown のリスナーで呼んでいます)。Firefox と Safari では試していないので、この記事では CSS で止める書き方にしています。
範囲選択の領域には user-select: none を付けます。付けないと、項目を囲むのと同時に、ページの文字の選択も始まります。





