Reactで、ドラッグしてパネルの幅を変えられるようにする方法
サイドバーの幅をドラッグで変えられるようにするには、サイドバーと本文のあいだに細い取っ手を置き、取っ手の onPointerDown で setPointerCapture() を呼びます。あとは onPointerMove で動いた距離を幅に足すだけで、マウスが取っ手から外れても幅が付いてきます。
ライブラリを使わずに、ResizablePanels という部品を作りました。タッチで途中で止まる、長い行で画面がはみ出す、キーボードで動かせない、といったつまずきどころも1つずつ確かめています。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作しました。マウスは Playwright の page.mouse、タッチは Chrome DevTools Protocol の Input.dispatchTouchEvent で再現しています。実機のスマートフォン、Firefox と Safari、サーバーでの描画(SSR)は試していません。

幅を state に持ち、動いた距離を足す
ResizablePanels は、サイドバーの見出し(title)と中身(sidebar)、本文(children)を受け取ります。幅は width という state に持ち、aside の style に渡します。
import {
useEffect,
useRef,
useState,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from 'react';
const MIN = 160;
const MAX = 480;
const STEP = 16;
const STORAGE_KEY = 'sidebar-width';
const clamp = (width: number) => Math.min(MAX, Math.max(MIN, width));
function readSavedWidth() {
const saved = Number(localStorage.getItem(STORAGE_KEY));
return saved > 0 ? clamp(saved) : 240;
}
type Props = {
title: string;
sidebar: ReactNode;
children: ReactNode;
};
export function ResizablePanels({ title, sidebar, children }: Props) {
const [width, setWidth] = useState(readSavedWidth);
const [dragging, setDragging] = useState(false);
// ドラッグを始めたときのマウスの位置と幅。描画には使わないので ref に持つ
const start = useRef({ x: 0, width: 0 });
// ドラッグ中は保存せず、手を離したあとに1回だけ保存する
useEffect(() => {
if (!dragging) localStorage.setItem(STORAGE_KEY, String(width));
}, [width, dragging]);
const handlePointerDown = (e: PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return; // 左ボタン(タッチとペンも 0)だけで始める
e.preventDefault(); // 文字の選択が始まらないようにする
e.currentTarget.setPointerCapture(e.pointerId);
start.current = { x: e.clientX, width };
setDragging(true);
};
const handlePointerMove = (e: PointerEvent<HTMLDivElement>) => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
setWidth(clamp(start.current.width + e.clientX - start.current.x));
};
// pointerup でも pointercancel でも、キャプチャが外れるとここに来る
const handleLostPointerCapture = () => setDragging(false);
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const keys: Record<string, number> = {
ArrowLeft: width - STEP,
ArrowRight: width + STEP,
Home: MIN,
End: MAX,
};
if (!(e.key in keys)) return;
e.preventDefault();
setWidth(clamp(keys[e.key]));
};
return (
<div className={dragging ? 'panels dragging' : 'panels'}>
<aside id="sidebar" style={{ width }}>
<h2 id="sidebar-title">{title}</h2>
{sidebar}
</aside>
<div
role="separator"
aria-orientation="vertical"
aria-labelledby="sidebar-title"
aria-controls="sidebar"
aria-valuenow={width}
aria-valuemin={MIN}
aria-valuemax={MAX}
tabIndex={0}
className="handle"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onLostPointerCapture={handleLostPointerCapture}
onKeyDown={handleKeyDown}
/>
<main>{children}</main>
</div>
);
}
CSS は次のとおりです。.panels を横並びの flex にし、aside と取っ手は縮まないようにして、残りを main に回します。
.panels {
display: flex;
height: 100vh;
}
.panels aside {
flex: none;
overflow: auto;
}
.handle {
flex: none;
width: 8px;
cursor: col-resize;
touch-action: none; /* タッチで動かしたときに、ブラウザのスクロールに取られないようにする */
background: #d7eef3;
}
.handle:hover,
.handle:focus-visible,
.panels.dragging .handle {
background: #00c8e8;
}
.panels main {
flex: 1;
overflow: auto; /* 長い行があっても main が縮み、はみ出た分は main の中でスクロールする */
}
/* ドラッグ中は、マウスが枠から外れても形を保ち、文字を選ばせない */
.panels.dragging {
cursor: col-resize;
user-select: none;
}
使う側は、次のように書きます。
<ResizablePanels title="目次" sidebar={<ul>{/* 目次の項目 */}</ul>}>
<h1>本文</h1>
{/* 本文 */}
</ResizablePanels>
新しい幅は、押したときの幅に「押したときから動いた距離」を足して求めます。押したときの値は描画に使わないので、useRef に持たせています。求めた幅は clamp で160〜480pxに収めます。検証で取っ手を左へ400px、右へ800pxドラッグすると、幅はそれぞれ160と480で止まりました。
setPointerCapture で、取っ手から外れても追いかける
取っ手の幅は8pxしかありません。マウスを少し速く動かすと、次の pointermove が届くころには、マウスはもう取っ手の上にいません。ふつうはイベントがマウスの下にある要素に届くので、取っ手の onPointerMove は呼ばれなくなります。
setPointerCapture() の説明にあるとおり、これを呼ぶと、そのポインターのイベントは、キャプチャを外すか pointerup が起きるまで、呼んだ要素に届きます。React でも、onPointerDown や onLostPointerCapture を div に渡せます。
setPointerCapture() を呼ばない版と比べました。10pxずつ動かしても、100pxを1回で動かしても、幅は変わりませんでした。取っ手から100px右にある要素は main で、マウスがそこへ移るとイベントは取っ手に届きません。1pxずつ動かしたときだけ、取っ手が付いてきました。
--- 2. setPointerCapture を呼ばない版 ---
最初の幅: 240
右へ100px(10回に分けて動かす): 240
右へ100px(1回で動かす): 240
右へ100px(1pxずつ100回に分けて動かす): 340
1回で動かした先にある要素: MAIN
ResizablePanels では、100pxを一度に動かしても、300px動かしても、幅が付いてきました(300pxのときは上限の480で止まっています)。
--- 1. ResizablePanels で、ゆっくりと速くドラッグする ---
最初の幅: 240 aria-valuenow: 240
右へ100px(10回に分けて動かす): 340
左へ100px(1回で動かす): 240
右へ300px(1回で動かす): 480 aria-valuenow: 480
handlePointerMove では、hasPointerCapture() でキャプチャ中かどうかを確かめています。コードの上では、押していないときにマウスが取っ手の上を通っても、キャプチャがないので幅は変わりません。pointermove では dragging の state を見なくても、キャプチャの有無だけでドラッグ中かを判定できます。
ドラッグの終わりは lostpointercapture イベントで受けています。Pointer Events の解説によれば、キャプチャは pointerup か pointercancel のあとに自動で外れます。どちらで終わっても onLostPointerCapture が呼ばれるので、終わりの処理を1か所にまとめられます。
ドラッグ中に文字が選ばれないようにする
取っ手から本文の上までドラッグすると、ブラウザが本文の文字を選んでしまうことがあります。pointerdown で選択の対策を何も入れない書き方では、実際に68文字が選ばれました。この版では、キャプチャを使わず、preventDefault() も user-select: none もない状態にしています。
--- 4. ドラッグ中に文字が選ばれるか(取っ手から本文の上までドラッグ) ---
キャプチャなし・preventDefault なし・user-select なし: 選ばれた文字数=68
キャプチャなし・preventDefault だけ: 選ばれた文字数=0
キャプチャなし・user-select: none だけ: 選ばれた文字数=0
setPointerCapture だけ: 選ばれた文字数=0
ResizablePanels(3つともあり): 選ばれた文字数=0
Chromium 153 では、pointerdown での preventDefault()、ドラッグ中の user-select: none、setPointerCapture() の、どれか1つがあれば文字は選ばれませんでした。ほかのブラウザでは試していないので、ResizablePanels には3つとも入れています。user-select: none を付ける dragging のクラスは、ドラッグ中にマウスが取っ手から外れても col-resize のカーソルを保つ役目も兼ねています。
タッチでは touch-action: none がないと途中で止まる
指で取っ手を横に動かすと、ブラウザはそれを画面のスクロールと受け取ることがあります。touch-action の説明によれば、ブラウザがスクロールやズームを始めると、ページには pointercancel が届きます。touch-action: none を指定した要素では、ブラウザはスクロールやズームをしません。
タッチの操作を Chrome DevTools Protocol で再現し、指を右へ100px動かしました。touch-action を指定しない版では、pointermove が2回届いたところで pointercancel になり、幅は20pxしか広がりませんでした。
--- 8. タッチで動かす(CDP の Input.dispatchTouchEvent で再現) ---
touch-action を指定しない版: touch-action=auto 指を右へ100px動かしたあとの幅=260
取っ手に届いたイベント: pointerdown×1 → pointermove×2 → pointercancel×1 → lostpointercapture×1
ResizablePanels: touch-action=none 指を右へ100px動かしたあとの幅=340
取っ手に届いたイベント: pointerdown×1 → pointermove×10 → pointerup×1 → lostpointercapture×1
比べた版も、pointerdown で preventDefault() を呼んでいます。それでも pointercancel になりました。touch-action の説明にも、Touch events の preventDefault() でブラウザの操作を止める場合でも、イベントの処理が呼ばれる前にブラウザが意図を知れるよう、touch-action も使うべきだと書かれています。スクロールさせたくない取っ手には、CSS で touch-action: none を指定します。
長い行があるときは main の overflow で縮める
本文に、折り返さない長い行(コードなど)があるときは注意が必要です。flex の子は、ふだんは中身の最小の幅より狭くなりません。min-width の説明によれば、flex の子の min-width: auto は中身の最小の幅になりますが、その子がスクロールする要素(overflow が auto・scroll・hidden のどれか)なら0になります。
画面の幅1000pxで、x を120個並べた折り返さない1行(全体で140文字)がある本文を置き、サイドバーを480pxに広げて比べました。
--- 7. main の overflow を変えた版(長い行がある) ---
overflow: visible: aside=480 main=1144 ページの横幅=1632 画面の幅=1000
overflow: visible と min-width: 0: aside=480 main=512 ページの横幅=1608 画面の幅=1000
ResizablePanels(overflow: auto): aside=480 main=512 ページの横幅=1000 画面の幅=1000
overflow: visible のままでは main が1144pxから縮まず、ページ全体が横にはみ出しました。min-width: 0 を足すと main は512pxに縮みますが、長い行は main の外へはみ出したままです。overflow: auto にすると main が縮み、はみ出た分は main の中で横にスクロールするようになって、ページの幅は画面に収まりました。
キーボードでも幅を変えられるようにする
マウスを使わない人も幅を変えられるように、取っ手に tabIndex={0} を付けてフォーカスできるようにし、矢印キーで動かします。役割や属性は、WAI-ARIA Authoring Practices Guide のウィンドウスプリッターのパターンに合わせました。
- 取っ手の
roleはseparatorにする aria-valuenowに今の位置、aria-valueminとaria-valuemaxに動かせる範囲を入れる- 見えている見出しがあれば、
aria-labelledbyで名前にする - 大きさが変わる側のパネルを
aria-controlsで指す - 左右の矢印キーで動かし、Home で最小、End で最大にする(Home と End は任意)
パターンでは、aria-valuenow などは0〜100で表すことが多いとされています。ResizablePanels では、幅のピクセル数をそのまま入れました。
Tab で取っ手にフォーカスし、キーを順に押した結果です。幅と aria-valuenow が同じように変わり、範囲の外へは出ませんでした。Playwright で取り出したアクセシビリティツリーでは、取っ手が「目次」という名前の separator になっています。
--- 5. キーボードで動かす ---
Tab でフォーカスした要素: separator
ArrowRight: 幅=256 aria-valuenow=256
ArrowRight: 幅=272 aria-valuenow=272
ArrowLeft: 幅=256 aria-valuenow=256
Home: 幅=160 aria-valuenow=160
ArrowLeft: 幅=160 aria-valuenow=160
End: 幅=480 aria-valuenow=480
ArrowRight: 幅=480 aria-valuenow=480
アクセシビリティツリー: - separator "目次"
パターンには、Enter でパネルをたたみ、もう一度押すと元の幅に戻す操作もあります。Home や End と違って任意とはされていませんが、ResizablePanels には入れていません。
手を離したときだけ幅を保存する
次にページを開いたときも同じ幅で出るように、幅を localStorage に保存しています。useEffect の中で、dragging が false のときだけ書き込むので、ドラッグ中の pointermove のたびには書きません。キーボードで動かすときは dragging が false のままなので、コードの上では1回押すごとに保存されます。
読み込むほうは、useState(readSavedWidth) のように関数そのものを渡しています。useState の説明にあるとおり、関数を渡すと最初の描画のときだけ呼ばれ、再描画のたびに localStorage を読み直しません。localStorage の値は文字列なので、Number() で数に戻し、clamp で範囲に収めてから使います。
setItem の呼び出しを数えながら12回に分けてドラッグすると、保存は手を離したあとの1回だけでした。読み込み直したあとも、幅は360pxのままでした。
--- 6. 幅を保存して、読み込み直しても残る ---
ドラッグ1回(12回動かす)で保存した回数: 1 ["sidebar-width=360"]
読み込み直したあとの幅: 360





