Reactで、つかんで横にスクロールできるリストを作る方法
横にはみ出すリストを overflow-x: auto の枠に入れておけば、指で触る操作ではそのまま横にスクロールできます。マウスでもつかんで動かせるようにするには、押した位置からマウスが動いた分だけ、枠の scrollLeft を逆向きに動かします。ポインターを枠に捕まえるのは、押した瞬間ではなく、数 px 動いてドラッグだと分かってからにします。こうすると、ふつうのクリックはカードのリンクに届き、ドラッグの後のクリックはリンクに届きません。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して確かめました。アプリ全体を StrictMode で囲み、ドラッグとクリックを3回ずつしても、コンソールにエラーや警告は出ませんでした。指での操作は、Chromium の DevTools Protocol でタッチのイベントを送って試したもので、スマートフォンの実機では試していません。Firefox と Safari でも試していません。

横にはみ出すカードを、スクロールできる枠に並べる
まず CSS です。カードを display: flex で横に並べ、枠からはみ出した分は overflow-x: auto でスクロールできるようにします。
.drag-scroll {
display: flex;
gap: 12px;
overflow-x: auto;
padding: 8px 4px 12px;
cursor: grab;
}
.drag-scroll.is-dragging {
cursor: grabbing;
}
.drag-scroll > * {
flex: 0 0 160px;
}
cursor の grab は「つかんで動かせる」ことを、grabbing は「つかんで動かしている」ことを示すマウスカーソルです。ドラッグしている間だけ、枠に is-dragging のクラスを付けて grabbing に切り替えます。
検証では、幅 160px のカードを10枚並べました。枠の見えている幅は 388px、中身の幅は 2036px で、scrollLeft は 0 から 1648 までの値をとります。
clientWidth=388 scrollWidth=2036 scrollLeft の最大=1648 / cursor=grab
マウスで引いた分だけ scrollLeft を動かすコンポーネント
DragScrollList は、中に並べるものを children で受け取ります。ドラッグの途中の値(押した位置や、押したときの scrollLeft)は、マウスが動くたびに書き換わるので useRef に持ちます。state にするのは、カーソルの形を変えるための「ドラッグしているかどうか」だけです。
import { useRef, useState, type ReactNode, type PointerEvent } from 'react';
// これだけ動いたら、クリックではなくドラッグとみなす(px)
const DRAG_THRESHOLD = 5;
type Props = {
children: ReactNode;
label: string;
};
export function DragScrollList({ children, label }: Props) {
const drag = useRef({ pointerId: -1, startX: 0, startScrollLeft: 0, moved: false });
const [dragging, setDragging] = useState(false);
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
// 指やペンはブラウザの標準のスクロールに任せ、マウスの左ボタンだけを扱う
if (e.pointerType !== 'mouse' || e.button !== 0) return;
drag.current = {
pointerId: e.pointerId,
startX: e.clientX,
startScrollLeft: e.currentTarget.scrollLeft,
moved: false,
};
};
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (e.pointerId !== d.pointerId) return;
const dx = e.clientX - d.startX;
if (!d.moved) {
if (Math.abs(dx) < DRAG_THRESHOLD) return;
// ここで初めてドラッグとみなし、ポインターを枠に捕まえる
d.moved = true;
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(true);
}
e.currentTarget.scrollLeft = d.startScrollLeft - dx;
};
const endDrag = (e: PointerEvent<HTMLDivElement>) => {
if (e.pointerId !== drag.current.pointerId) return;
drag.current.pointerId = -1;
setDragging(false);
};
return (
<div
className={dragging ? 'drag-scroll is-dragging' : 'drag-scroll'}
role="region"
aria-label={label}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
// リンクや画像を、ブラウザの標準の動きでドラッグさせない
onDragStart={(e) => e.preventDefault()}
>
{children}
</div>
);
}
押したとき(pointerdown)には、押した位置と、そのときの scrollLeft を覚えるだけです。マウスが動いたとき(pointermove)に、押した位置からの差 dx を求め、scrollLeft を「押したときの値 − dx」にします。マウスを左へ引くと dx は負になるので、scrollLeft が増えて中身が左へ流れます。マウスの下にあるカードが、マウスと一緒に動く形です。
scrollLeft には、0 より小さい値や最大値を超える値を入れても、0 か最大値に収まります。そのため、端で止める計算は書いていません。枠の右端近くから左端近くまで 380px 引くのを5回くり返したところ、5回目は 1900 ではなく、最大の 1648 で止まりました。
1回目: scrollLeft=380 / 選んだカード:なし / hash=""
2回目: scrollLeft=760 / 選んだカード:なし / hash=""
3回目: scrollLeft=1140 / 選んだカード:なし / hash=""
4回目: scrollLeft=1520 / 選んだカード:なし / hash=""
5回目: scrollLeft=1648 / 選んだカード:なし / hash=""
検証では、カードをリンクにして、次のように使いました。リンクを押すと、URL の # から後ろが変わり、下の「選んだカード」の表示が変わります。
import { useState } from 'react';
import { DragScrollList } from './DragScrollList';
const items = Array.from({ length: 10 }, (_, i) => ({
id: i + 1,
title: `カード ${i + 1}`,
text: '説明の文章が入ります。',
}));
export function App() {
const [selected, setSelected] = useState<number | null>(null);
return (
<main>
<DragScrollList label="おすすめのカード">
{items.map((item) => (
<a
key={item.id}
className="card"
href={`#card-${item.id}`}
onClick={() => setSelected(item.id)}
>
<strong>{item.title}</strong>
<span>{item.text}</span>
</a>
))}
</DragScrollList>
<p id="selected">選んだカード:{selected ?? 'なし'}</p>
</main>
);
}
カード 2 の上を押して、左へ 100px 引いたところと、合わせて 200px 引いて離したところを調べました。scrollLeft はマウスを動かした距離と同じだけ増え、押したまま動かしている間は、カーソルが grabbing になりました。離した後も、カード 2 のリンクは開いていません。
カード 2 の上から左へ100px(押したまま): scrollLeft=100 / 選んだカード:なし / hash="" / class="drag-scroll is-dragging" / cursor=grabbing
さらに左へ、合わせて200px引いて離す: scrollLeft=200 / 選んだカード:なし / hash="" / 離したときにマウスの下にあったカード: カード 2 / pointerdown(カード 2) dragstart(カード 2) pointerup(枠) click(枠)
各行の最後は、枠の中で起きたイベントと、そのイベントがどこで起きたか(どのカードか、カードの外の枠か)を、起きた順に並べたものです。
ポインターを捕まえるのは、動き始めてから
setPointerCapture() を呼ぶと、それ以降のそのポインターのイベントは、マウスがどこにあっても、呼んだ要素(ここでは枠)に届くようになります。DragScrollList では、これを pointermove の中で、押した位置から 5px 以上動いたときに呼んでいます。
setPointerCapture() を呼ばない版と、押した瞬間に呼ぶ版の2つと比べると、理由が分かります。
setPointerCapture を呼ばないと、ドラッグの後にリンクが開く
setPointerCapture() の行だけを消した版で、同じように 200px 引いて離しました。
さらに左へ、合わせて200px引いて離す: scrollLeft=200 / 選んだカード:2 / hash="#card-2" / 離したときにマウスの下にあったカード: カード 2 / pointerdown(カード 2) dragstart(カード 2) pointerup(カード 2) click(カード 2)
スクロールはしますが、離した瞬間にカード 2 のリンクが開いてしまいました。中身はマウスと一緒に動くので、離したときもマウスの下にはカード 2 があります。押した要素と離した要素が同じカード 2 なので、ふつうのクリックと同じように click が届きます。
setPointerCapture() を呼ばない版には、もう1つ困ることがあります。ドラッグの途中でマウスが枠の外に出ると、そこでスクロールが止まります。左へ 100px 引いてから枠の下へ 150px 出て、さらに左へ 100px 引いたところ、scrollLeft は、捕まえない版では 100、記事の版では 200 になりました。捕まえない版では、枠の外に出た後の pointermove が枠に届かないので、後半の 100px が反映されません。
押した瞬間に捕まえると、ふつうのクリックが効かなくなる
逆に、pointerdown の中で setPointerCapture() を呼ぶ版では、動かさずにカード 2 をクリックしても、リンクが開きませんでした。
動かさずにカード 2 をクリック: scrollLeft=0 / 選んだカード:なし / hash="" / pointerdown(カード 2) pointerup(枠) click(枠)
捕まえた後の pointerup は枠に届き、click もカードではなく枠に届いています。Pointer Events の仕様の click・auxclick・contextmenu の節では、ポインターを捕まえている間に起きた pointerup による click は、その pointerup と同じ要素(捕まえた要素)に送ると決めています。捕まえていなければ、pointerdown と pointerup の両方を含む、いちばん近い祖先の要素に送られます。
記事の版のように、ドラッグだと分かってから捕まえると、両方の良いところが得られます。ふつうのクリックはカードに届き、ドラッグの後の click は枠に届くので、リンクは開きません。しきい値の 5px より少しだけ動かしたとき、少しだけ多く動かしたときの結果も試しました。
3px: scrollLeft=0 / 選んだカード:2 / hash="#card-2" / pointerdown(カード 2) pointerup(カード 2) click(カード 2)
4px: scrollLeft=0 / 選んだカード:2 / hash="#card-2" / pointerdown(カード 2) dragstart(カード 2) pointerup(カード 2) click(カード 2)
6px: scrollLeft=6 / 選んだカード:なし / hash="" / pointerdown(カード 2) dragstart(カード 2) pointerup(枠) click(枠)
押してから 3px と 4px 動かして離したときは、スクロールせず、クリックとして扱われてリンクが開きました。6px 動かしたときは、スクロールして、リンクは開きませんでした。
リンクのドラッグを防がないと、少し動いたところでスクロールが止まる
200px 引いたときや、4px・6px 動かしたときの記録には、dragstart が出ています。draggable 属性の説明にあるとおり、テキストの選択範囲、画像、リンクは、何も指定しなくてもブラウザの標準の動きでドラッグできます。リンクを押したまま動かすと、ブラウザは標準のドラッグを始め、dragstart のイベントを送ります。
onDragStart の preventDefault() を消した版で、カード 2 の上から 200px 引きました。
カード 2 の上から左へ100px(押したまま): scrollLeft=10 / 選んだカード:なし / hash="" / class="drag-scroll" / cursor=grab
さらに左へ、合わせて200px引いて離す: scrollLeft=10 / 選んだカード:なし / hash="" / 離したときにマウスの下にあったカード: カード 1 / pointerdown(カード 2) dragstart(カード 2) pointercancel(カード 2)
dragstart の後に pointercancel が届き、pointerup も click も届きませんでした。scrollLeft は 10 で止まり、100px 引いた時点で、カーソルは grab でした。記事の版のように dragstart で preventDefault() を呼べば、ブラウザのドラッグは始まらず、200px 引くまでスクロールが続きます。
指での操作は、ブラウザのスクロールに任せる
onPointerDown の最初で、pointerType が "mouse" のときだけ処理を続けています。指の操作は、overflow-x: auto の枠であれば、ブラウザがもともと横にスクロールしてくれるからです(ペンの操作は試していません)。
Chromium をタッチの操作ができる設定(hasTouch、isMobile)で開き、DevTools Protocol でタッチのイベントを送って、カード 2 の上から指で左へ 200px 動かしました。pointerType を見ずに、指でも自前でスクロールさせる版とも比べています。
記事の版(マウスだけを扱う): scrollLeft=202 / 選んだカード:なし / hash="" / pointerdown(カード 2) pointercancel(カード 2)
pointerType を見ない版: scrollLeft=280 / 選んだカード:なし / hash="" / pointerdown(カード 2) pointercancel(枠)
記事の版では、指を動かした 200px とほぼ同じ 202px だけスクロールしました。どちらの版でも、pointerdown の次に pointercancel が届いています。pointercancel の MDN の説明のとおり、ポインターがスクロールに使われると、ブラウザは pointercancel を送ります。pointerType を見ない版は、指を動かした量より多い 280px まで動きました。pointercancel が枠に届いているので、ブラウザのスクロールが始まる前に、自前のドラッグの処理(ポインターを捕まえて scrollLeft を動かす)がすでに動いていたことになります。
キーボードでも横にスクロールできるようにする
マウスを使わない人のために、枠に tabIndex={0} を付けて、Tab キーでフォーカスできるようにしています。フォーカスした枠は、矢印キーで横にスクロールできます。あわせて role="region" と aria-label で、枠に「おすすめのカード」という名前を付けています。
Tab キーを1回押してから、右矢印キーを3回押しました。
フォーカス: region "おすすめのカード"
右矢印 3回: scrollLeft=120 / 選んだカード:なし / hash=""
最初の Tab で枠にフォーカスが入り、右矢印キーを3回押すと、scrollLeft が 120 になりました。
マウスで動いた分だけ scrollLeft を逆向きに動かし、押した位置から数 px 動いてから setPointerCapture() を呼びます。リンクや画像を並べるときは、dragstart で preventDefault() を呼んで、ブラウザの標準のドラッグを止めます。





