Reactで、表の列の幅をドラッグで変える方法
表の列の幅をドラッグで変えるには、列ごとの幅を state に持ち、<colgroup> の <col> に渡します。このとき、表に table-layout: fixed を付け、表全体の幅を列の幅の合計にしておくことが欠かせません。どちらかが欠けると、state の幅と、実際に描かれる列の幅がずれました。
作るのは、ライブラリを使わない ResizableTable コンポーネントです。見出しのセルの右端に取っ手を置き、ドラッグすると列の幅が変わります。取っ手のダブルクリックで中身に合う幅にし、キーボードの左右の矢印でも幅を変えられるようにします。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 です。マウスとキーボードは Playwright で操作し、タッチは Chromium の DevTools プロトコル(CDP)の Input.dispatchTouchEvent で、スマートフォンの表示を模した画面に送りました。実機のスマートフォン、Firefox と Safari、スクリーンリーダーでの読み上げは試していません。列の並べ替えや、決めた幅の保存は扱いません。

ResizableTable コンポーネントの全体
先にコード全体を載せます。ドラッグは Pointer Events で受け取ります。
import { useRef, useState } from 'react';
import type { KeyboardEvent, PointerEvent } from 'react';
export type Column<T> = {
key: keyof T & string;
label: string;
width: number; // 最初の幅(px)
};
const MIN_WIDTH = 60;
const MAX_WIDTH = 600;
const KEY_STEP = 10;
function clamp(n: number, lo: number, hi: number) {
return Math.min(hi, Math.max(lo, n));
}
type Drag = {
key: string;
pointerId: number;
startX: number; // 押したときのポインターの x 座標
startWidth: number; // 押したときの列の幅
};
type Props<T> = {
columns: Column<T>[];
rows: T[];
getRowKey: (row: T) => string;
};
export function ResizableTable<T>({ columns, rows, getRowKey }: Props<T>) {
const [widths, setWidths] = useState<Record<string, number>>(() =>
Object.fromEntries(columns.map((c) => [c.key, c.width])),
);
const drag = useRef<Drag | null>(null);
const tableRef = useRef<HTMLTableElement>(null);
function setWidth(key: string, width: number) {
setWidths((prev) => ({ ...prev, [key]: clamp(Math.round(width), MIN_WIDTH, MAX_WIDTH) }));
}
function handlePointerDown(e: PointerEvent<HTMLDivElement>, key: string) {
if (e.pointerType === 'mouse' && e.button !== 0) return;
e.preventDefault(); // ドラッグ中に文字が選ばれないようにする
// 取っ手の外へ出ても、離すまで取っ手にイベントが届くようにする
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { key, pointerId: e.pointerId, startX: e.clientX, startWidth: widths[key] };
}
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
const d = drag.current;
if (!d || d.pointerId !== e.pointerId) return;
setWidth(d.key, d.startWidth + (e.clientX - d.startX));
}
function handlePointerEnd(e: PointerEvent<HTMLDivElement>) {
if (drag.current?.pointerId === e.pointerId) drag.current = null;
}
// ダブルクリックで、その列のいちばん長い中身が収まる幅にする
function fitToContent(key: string) {
const cells = tableRef.current?.querySelectorAll<HTMLElement>(`[data-col="${key}"]`) ?? [];
let max = 0;
for (const cell of cells) {
const text = cell.querySelector<HTMLElement>('.cell-text');
if (!text) continue;
const style = getComputedStyle(cell);
const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
max = Math.max(max, text.getBoundingClientRect().width + padding);
}
if (max > 0) setWidth(key, Math.ceil(max));
}
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>, key: string) {
const delta = e.key === 'ArrowRight' ? KEY_STEP : e.key === 'ArrowLeft' ? -KEY_STEP : 0;
if (delta === 0) return;
e.preventDefault();
setWidth(key, widths[key] + delta);
}
const totalWidth = columns.reduce((sum, c) => sum + widths[c.key], 0);
return (
<div className="table-wrap">
<table ref={tableRef} className="resizable-table" style={{ width: totalWidth }}>
<colgroup>
{columns.map((c) => (
<col key={c.key} style={{ width: widths[c.key] }} />
))}
</colgroup>
<thead>
<tr>
{columns.map((c) => (
<th key={c.key} scope="col" data-col={c.key}>
<span className="cell-text">{c.label}</span>
<div
className="col-resizer"
role="separator"
aria-orientation="vertical"
aria-label={`${c.label}の列の幅`}
aria-valuemin={MIN_WIDTH}
aria-valuemax={MAX_WIDTH}
aria-valuenow={widths[c.key]}
tabIndex={0}
onPointerDown={(e) => handlePointerDown(e, c.key)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onDoubleClick={() => fitToContent(c.key)}
onKeyDown={(e) => handleKeyDown(e, c.key)}
/>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={getRowKey(row)}>
{columns.map((c) => (
<td key={c.key} data-col={c.key}>
<span className="cell-text">{String(row[c.key])}</span>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
使う側では、列の定義と行のデータを渡します。width は最初の幅です。
import { ResizableTable } from './ResizableTable';
import type { Column } from './ResizableTable';
type Item = { id: string; name: string; category: string; price: number; note: string };
const columns: Column<Item>[] = [
{ key: 'name', label: '商品名', width: 140 },
{ key: 'category', label: '分類', width: 90 },
{ key: 'price', label: '価格(円)', width: 110 },
{ key: 'note', label: '説明', width: 200 },
];
const rows: Item[] = [
{ id: '1', name: 'ステンレスの保温マグカップ', category: '食器', price: 2480, note: 'ふた付きで、飲み物が冷めにくいマグカップです。' },
{ id: '2', name: '木のまな板', category: '調理器具', price: 1980, note: '片面に溝があり、肉汁がこぼれにくくなっています。' },
{ id: '3', name: '綿のふきん(5枚組)', category: '日用品', price: 880, note: '吸水性が高く、乾きやすい薄手の生地です。' },
];
export function App() {
return (
<main>
<ResizableTable columns={columns} rows={rows} getRowKey={(r) => r.id} />
</main>
);
}
CSS は次のとおりです。
.table-wrap {
max-width: 100%;
overflow-x: auto; /* 表が画面より広くなったら、横にスクロールさせる */
}
.resizable-table {
table-layout: fixed; /* 列の幅を、中身ではなく col の指定で決める */
border-collapse: separate;
border-spacing: 0;
}
.resizable-table th,
.resizable-table td {
position: relative; /* 取っ手の位置の基準にする */
padding: 8px 12px;
border-bottom: 1px solid #d5dde1;
text-align: left;
/* 入りきらない文字は1行のまま隠し、末尾を「…」にする */
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.resizable-table th {
background: #eefafd;
}
.col-resizer {
position: absolute;
top: 0;
right: 0;
width: 8px;
height: 100%;
cursor: col-resize;
touch-action: none; /* 指でなぞっても、ページがスクロールしないようにする */
}
.col-resizer::after {
content: '';
position: absolute;
top: 20%;
right: 3px;
width: 2px;
height: 60%;
background: #9aa5ab;
}
.col-resizer:hover::after,
.col-resizer:focus-visible::after {
background: #00c8e8;
}
ドラッグした距離を、押したときの幅に足す
取っ手を押したとき(pointerdown)に、ポインターの x 座標と、その列の幅を drag に覚えておきます。pointermove では、押したときから動いた距離 e.clientX - d.startX を、押したときの幅に足します。幅は setWidth で 60〜600px に収め、整数に丸めてから state に入れます。
商品名の列(最初は 140px)の取っ手を動かし、state の幅と、実際に描かれた見出しのセルの幅を比べました。
左へ 300px(離さない): 商品名: state 60 / 実際 60
そのまま押した位置へ戻して離す: 商品名: state 140 / 実際 140
右へ 80px: 商品名: state 220 / 実際 220
左へ 60px(途中で下へ 120px 外れる): 商品名: state 160 / 実際 160
左へ 300px: 商品名: state 60 / 実際 60
入りきらずに隠れたセル(商品名): 4 / 4
表示されている文字(1行目の商品名のセル): ellipsis
最初の2行は、1回のドラッグの途中です。左へ 300px 動かすと最小の 60px で止まり、離さずに押した位置まで戻すと 140px に戻りました。前回の pointermove からの差を足していく書き方と違い、途中で幅が止められても、ずれが残りません。
その後の右へ 80px、左へ 60px でも、state と実際の幅は一致しました。左へ 60px 動かすときは、途中でポインターを取っ手から下へ 120px 外しましたが、幅は最後まで変わり続けました。pointerdown で setPointerCapture を呼び、離すまで取っ手に pointermove が届くようにしているためです。
60px まで狭めると、商品名の列は見出しと3行の、4つのセルすべてで文字が入りきらず、末尾が「…」になりました(上の GIF の2回目)。セルに overflow: hidden、white-space: nowrap、text-overflow: ellipsis を付けているためです。white-space: nowrap を外し、同じように左へ 300px ドラッグすると(完成版では、このドラッグで最小の 60px になります)、文字がセルの中で折り返し、1行目の商品名のセルの高さが変わりました。
white-space: nowrap あり: 41px
white-space: nowrap なし: 185px
取っ手は見出しのセルの右端に position: absolute で重ね、幅を 8px にしています。見た目の線は ::after で描いた 2px の線で、つかめる範囲はそれより広くしてあります。
table-layout: fixed がないと、幅が中身で決まる
表の列の幅の決め方は、CSS の table-layout で変わります。初期値の auto では、セルの中身に合わせて列の幅が決まります。fixed にすると、中身を見ずに、<col> に指定した幅で列の幅が決まります。
完成版から table-layout: fixed だけを外し、同じ幅を <col> に渡して比べました。
商品名: state 140 / 実際 232.015625、分類: state 90 / 実際 88.015625、価格(円): state 110 / 実際 104.015625、説明: state 200 / 実際 404.65625
商品名を左へ 80px: 商品名: state 60 / 実際 232.015625
商品名の列は、state では 140px なのに、実際は 232px でした。あとで出てくるダブルクリックで測った、いちばん長い商品名が1行に収まる幅(233px)とほぼ同じです。左へ 80px ドラッグして state を 60px にしても、実際の幅は 232px のまま変わりませんでした。ドラッグしても列が動かないので、取っ手が壊れているように見えます。
表の幅を 100% にすると、余った幅が各列に配られる
MDN の table-layout の説明には、fixed を効かせるには表の width を指定する必要があり、表の width が auto(指定なし)のままだと fixed は効かない、と書かれています。表を親の幅いっぱいに広げたくて、width: 100% を指定したくなるところです。
完成版の表の幅を、width: 100% に変えて比べました。表示領域の横幅は 800px で、main の左右の padding(16px ずつ)を除いた 768px が表の幅になります。
商品名: state 140 / 実際 199.109375、分類: state 90 / 実際 128、価格(円): state 110 / 実際 156.4375、説明: state 200 / 実際 284.453125
商品名を右へ 80px: 商品名: state 220 / 実際 272.515625、分類: state 90 / 実際 111.46875、価格(円): state 110 / 実際 136.25、説明: state 200 / 実際 247.765625
列の幅の合計(540px)より表の幅が広いので、余った幅が各列に足され、どの列も state の幅より広く描かれました。商品名の列を右へ 80px ドラッグすると、商品名は 199px から 272px へ広がりましたが、ほかの3つの列は狭くなりました。表の幅が 768px のまま変わらないので、広げた分をほかの列から取ったことになります。
完成版では、表の style の width に列の幅の合計 totalWidth を入れています。こうすると余りが出ないので、ある列を広げても、ほかの列の幅は変わりません。そのかわり、表全体の幅が変わります。
列の幅を state のとおりに描かせるには、table-layout: fixed と、表の幅を列の幅の合計にすることの両方が要ります。
広くなった表は、囲みの中で横にスクロールさせる
表の幅は列の幅の合計なので、列を広げると、画面より広くなることがあります。そこで、表を <div className="table-wrap"> で囲み、overflow-x: auto を付けています。
表示領域を 360px にして、overflow-x: auto がある場合とない場合を比べました。表の幅は 540px です。
overflow-x: auto あり: ページの横幅 360px(表示領域 360px)/ 囲みの幅 328px、中身の幅 540px
overflow-x: auto なし: ページの横幅 556px(表示領域 360px)/ 囲みの幅 328px、中身の幅 540px
overflow-x: auto がないと、ページ全体の横幅が 556px になり、ページごと横にスクロールするようになりました。あるときは、ページの横幅は 360px のままで、横にスクロールするのは囲みの中だけです。
ダブルクリックで、中身に合う幅にする
取っ手をダブルクリックすると、fitToContent で、その列のいちばん長い中身が収まる幅にします。列のセルには data-col を付けておき、セルの中の <span className="cell-text"> の幅を getBoundingClientRect() で測ります。<span> はインラインの要素なので、セルが狭くて文字が隠れていても、文字の幅そのものが測れます。それにセルの左右の padding を足し、いちばん大きい値を新しい幅にします。
商品名の列を 60px まで狭めてからダブルクリックし、続けて説明と価格の列の取っ手もダブルクリックしました。
商品名: 商品名: state 233 / 実際 233
入りきらずに隠れたセル(商品名): 0 / 4
説明: 説明: state 405 / 実際 405
入りきらずに隠れたセル(説明): 0 / 4
幅を 1px 減らすと隠れるセル(説明): 1 / 4
価格(円): 価格(円): state 105 / 実際 105
入りきらずに隠れたセル(価格(円)): 0 / 4
商品名の列は 233px、説明の列は 405px になり、どちらも文字が隠れたセルは 0 になりました。5行目は、説明の列をそこから 1px だけ狭めた結果です。いちばん長い説明のセル1つが入りきらなくなったので、測った幅に余分はありません。
見出しの文字も cell-text で包み、測る対象に入れています。価格の列は、中身(3〜4桁の数字)より見出しの「価格(円)」のほうが長い列です。ダブルクリックすると 105px になり、見出しも含めて隠れたセルは 0 でした。
取っ手はキーボードでも動かせるようにする
取っ手には tabIndex={0} を付けてフォーカスできるようにし、右の矢印で 10px 広げ、左の矢印で 10px 狭めます。役割は ARIA の separator ロールで伝えます。MDN の説明では、フォーカスできて動かせる separator には aria-valuenow が必要で、aria-valuemin と aria-valuemax も既定の 0 と 100 でなければ指定するとされています。そこで、列の幅と、60 と 600 を入れました。
表に特有なのは、取っ手が列の数だけあることです。MDN では、フォーカスできる separator が複数あるときは aria-label で名前を付けるとされているので、「価格(円)の列の幅」のように、どの列の取っ手かが分かる名前にしています。価格の列の取っ手で確かめた結果です。
ArrowRight→120 ArrowRight→130 ArrowLeft→120 Enter→120
属性: role=separator aria-orientation=vertical aria-label=価格(円)の列の幅 aria-valuemin=60 aria-valuemax=600 tabindex=0
指でなぞるときは、取っ手に touch-action: none を付ける
取っ手の CSS の touch-action: none は、指の動きをブラウザにパンやズームとして扱わせず、すべて pointermove としてページに渡すための指定です。取っ手の touch-action だけを auto に変え、商品名の列の取っ手を指で右へ 60px なぞって比べました。
touch-action: none: 右へ 60px なぞる: 商品名 200 / 囲みの scrollLeft 0 / イベント: pointerdown pointerup
touch-action: auto: 右へ 60px なぞる: 商品名 158 / 囲みの scrollLeft 0 / イベント: pointerdown pointercancel
auto のときは、ブラウザが横になぞる動きを自分で扱い始め、途中で pointercancel が届いてドラッグが 158px で止まりました。none のときは最後に pointerup が届き、140px に 60px を足した 200px になりました。どちらも囲みの scrollLeft が 0 なのは、囲みがいちばん左にあり、右へなぞってもそれより左へはスクロールできないからです。





