Reactで、つかんで横にスクロールできるリストを作る方法

​​React​CSS,Playwright,Pointer Events,TypeScript

横にはみ出すリストを 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 でも試していません。

横に並んだカードの上でマウスを押して左へ引くと、カードが左へ流れ、scrollLeft の数字が 0 から 220、380 と増える。3回目は動かさずにクリックし、「選んだカード」が 3 になる
マウスで2回ドラッグしてから、カードを1回クリックしたときの動き(上の回数表示、マウスの絵、押している間の輪、scrollLeft の表示、変わった表示のピンクは、GIF 用に足したもの)

横にはみ出すカードを、スクロールできる枠に並べる

まず CSS です。カードを display: flex で横に並べ、枠からはみ出した分は overflow-x: auto でスクロールできるようにします。

css
.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 までの値をとります。

text
clientWidth=388 scrollWidth=2036 scrollLeft の最大=1648 / cursor=grab

マウスで引いた分だけ scrollLeft を動かすコンポーネント

DragScrollList は、中に並べるものを children で受け取ります。ドラッグの途中の値(押した位置や、押したときの scrollLeft)は、マウスが動くたびに書き換わるので useRef に持ちます。state にするのは、カーソルの形を変えるための「ドラッグしているかどうか」だけです。

tsx
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 で止まりました。

text
1回目: scrollLeft=380 / 選んだカード:なし / hash=""
2回目: scrollLeft=760 / 選んだカード:なし / hash=""
3回目: scrollLeft=1140 / 選んだカード:なし / hash=""
4回目: scrollLeft=1520 / 選んだカード:なし / hash=""
5回目: scrollLeft=1648 / 選んだカード:なし / hash=""

検証では、カードをリンクにして、次のように使いました。リンクを押すと、URL の # から後ろが変わり、下の「選んだカード」の表示が変わります。

tsx
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 のリンクは開いていません。

text
カード 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 引いて離しました。

text
さらに左へ、合わせて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 をクリックしても、リンクが開きませんでした。

text
動かさずにカード 2 をクリック: scrollLeft=0 / 選んだカード:なし / hash="" / pointerdown(カード 2) pointerup(枠) click(枠)

捕まえた後の pointerup は枠に届き、click もカードではなく枠に届いています。Pointer Events の仕様の click・auxclick・contextmenu の節では、ポインターを捕まえている間に起きた pointerup による click は、その pointerup と同じ要素(捕まえた要素)に送ると決めています。捕まえていなければ、pointerdown と pointerup の両方を含む、いちばん近い祖先の要素に送られます。

記事の版のように、ドラッグだと分かってから捕まえると、両方の良いところが得られます。ふつうのクリックはカードに届き、ドラッグの後の click は枠に届くので、リンクは開きません。しきい値の 5px より少しだけ動かしたとき、少しだけ多く動かしたときの結果も試しました。

text
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 引きました。

text
カード 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 を見ずに、指でも自前でスクロールさせる版とも比べています。

text
記事の版(マウスだけを扱う): 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回押しました。

text
フォーカス: region "おすすめのカード"
右矢印 3回: scrollLeft=120 / 選んだカード:なし / hash=""

最初の Tab で枠にフォーカスが入り、右矢印キーを3回押すと、scrollLeft が 120 になりました。

ポイント

マウスで動いた分だけ scrollLeft を逆向きに動かし、押した位置から数 px 動いてから setPointerCapture() を呼びます。リンクや画像を並べるときは、dragstart で preventDefault() を呼んで、ブラウザの標準のドラッグを止めます。

​Posted by 86