shadcn/ui × TanStack Table 設計術:仮想化・列リサイズ・アクセシブルなグリッド
業務システムで「数千行のデータを表示したい」「列幅を自由に調整したい」「スクリーンリーダーでも操作できるテーブルが必要」という要件に直面したことはないでしょうか。本記事では、shadcn/ui と TanStack Table を組み合わせて、仮想スクロール・列リサイズ・アクセシビリティを同時に満たすデータグリッドを設計・実装する方法を解説します。実際のプロジェクトで採用した設計判断と、検証中に遭遇した問題の解決策を交えてお伝えします。
shadcn/ui × TanStack Table 機能早見表
| 項目 | 概要 | 採用判断のポイント |
|---|---|---|
| 仮想化 | @tanstack/react-virtual で表示領域外の DOM を削除し、数万行でも 60fps を維持 | 1,000 行以上のデータで必須 |
| 列リサイズ | TanStack Table の columnResizeMode で列幅をドラッグ調整 | onChange / onEnd の選択が UX を左右 |
| アクセシビリティ | WAI-ARIA の grid パターンと role 属性でスクリーンリーダー対応 | 公共系・金融系システムでは必須要件 |
| shadcn/ui 統合 | Headless UI + Tailwind CSS で一貫したデザイン | 既存の shadcn/ui プロジェクトとの親和性が高い |
| 型安全 | TanStack Table の ColumnDef で列定義を型付け | TypeScript strict モードとの相性が良い |
それぞれの詳細は後述します。
検証環境
- OS: macOS Sequoia 15.2
- Node.js: 24.13.0 (LTS)
- TypeScript: 5.9.3
- 主要パッケージ:
- @tanstack/react-table: 8.21.3
- @tanstack/react-virtual: 3.13.18
- shadcn: 3.7.0
- react: 19.0.0
- tailwindcss: 4.0.6
- 検証日: 2026 年 01 月 26 日
shadcn/ui と TanStack Table を組み合わせる背景
この章では、なぜ shadcn/ui と TanStack Table の組み合わせが注目されているのか、技術的・実務的な背景を説明します。
Headless UI という設計思想の普及
TanStack Table は「Headless UI」と呼ばれる設計パターンを採用しています。Headless UI とは、ロジック(状態管理・イベント処理)と見た目(HTML・CSS)を完全に分離したライブラリのことです。従来の UI ライブラリは見た目も含めて提供されるため、デザインのカスタマイズに限界がありました。
shadcn/ui も同様に、コンポーネントのコードを直接プロジェクトにコピーして使う方式を採用しています。npm パッケージとしてインストールするのではなく、ソースコードを所有することで自由にカスタマイズできます。
つまずきやすい点:shadcn/ui は「ライブラリ」ではなく「コード配布プラットフォーム」です。
npm install shadcn-uiではなく、CLI でコンポーネントを追加します。
業務システムにおけるデータグリッドの要件
業務システムでは、以下のような要件が同時に求められることが多いです。
- 大量データ表示:数千〜数万行のデータをスムーズに表示
- 列のカスタマイズ:ユーザーが列幅を調整、列の表示/非表示を切り替え
- アクセシビリティ:公共系システムや金融系システムでは JIS X 8341-3 準拠が求められる
従来は AG Grid や Handsontable といった高機能グリッドライブラリが選択肢でしたが、バンドルサイズが大きく、デザインのカスタマイズにも制約がありました。TanStack Table と shadcn/ui の組み合わせは、必要な機能だけを組み込める軽量な選択肢として注目されています。
従来の実装で発生していた課題
この章では、仮想化・列リサイズ・アクセシビリティを個別に実装しようとした際に遭遇した問題を説明します。
仮想スクロールと固定高さの衝突
仮想スクロール(Virtual Scrolling)とは、画面に表示されている行だけを DOM にレンダリングし、スクロール時に動的に要素を入れ替える技術です。
検証の結果、仮想スクロールを実装する際に最も問題になったのは「行の高さ」でした。TanStack Virtual は estimateSize で行の推定高さを指定しますが、実際のコンテンツが推定より大きいと、スクロール位置がずれる現象が発生しました。
typescript// 問題のあるコード:固定高さの推定
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35, // すべての行が35pxと仮定
});
実際に試したところ、長いテキストを含む行では高さが 35px を超え、スクロールバーの位置と実際の表示位置が一致しなくなりました。
列リサイズ時の再レンダリング問題
TanStack Table の列リサイズ機能には onChange と onEnd の 2 つのモードがあります。
- onChange:ドラッグ中にリアルタイムで列幅が更新される
- onEnd:ドラッグが終了した時点で列幅が確定する
検証中に業務で問題になったのは、onChange モードでの再レンダリングコストでした。数千行のテーブルで onChange を使用すると、ドラッグのたびに全行が再レンダリングされ、フレームレートが著しく低下しました。
アクセシビリティ対応の複雑さ
WAI-ARIA(Web Accessibility Initiative - Accessible Rich Internet Applications)の grid パターンに準拠するには、以下の role 属性を正しく設定する必要があります。
- テーブル全体:
role="grid" - ヘッダー行:
role="row"+role="columnheader" - データ行:
role="row"+role="gridcell"
さらに、キーボードナビゲーション(矢印キーでセル間を移動)も実装が求められます。これらを仮想スクロールと組み合わせると、フォーカス管理が複雑になりました。
以下の図は、仮想スクロール・列リサイズ・アクセシビリティの関係性を示しています。
mermaidflowchart TD
VirtualScroll["仮想スクロール<br/>@tanstack/react-virtual"]
ColumnResize["列リサイズ<br/>TanStack Table"]
A11y["アクセシビリティ<br/>WAI-ARIA grid"]
VirtualScroll -->|DOM の動的生成| Challenge1["フォーカス管理の複雑化"]
ColumnResize -->|リアルタイム更新| Challenge2["再レンダリングコスト"]
A11y -->|role 属性の付与| Challenge3["仮想化との整合性"]
Challenge1 --> Solution["統合設計が必要"]
Challenge2 --> Solution
Challenge3 --> Solution
この図は、3 つの機能それぞれが課題を生み出し、統合的な設計が必要であることを示しています。
採用した解決策と設計判断
この章では、実際に採用した設計方針と、採用しなかった選択肢について説明します。
仮想スクロール:動的な行高さ計測の採用
行高さの問題を解決するため、measureElement を使用した動的計測を採用しました。
typescript// 採用した設計:動的な行高さ計測
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48, // 初期推定値
measureElement: (element) =>
element.getBoundingClientRect().height,
});
採用しなかった案:CSS の line-clamp で行の高さを強制的に固定する方法も検討しましたが、長いテキストが切り捨てられることでユーザビリティが低下するため不採用としました。
列リサイズ:onEnd モードの選択
再レンダリング問題を解決するため、columnResizeMode: 'onEnd' を採用しました。
typescriptconst table = useReactTable({
data,
columns,
columnResizeMode: 'onEnd', // ドラッグ終了時のみ更新
enableColumnResizing: true,
// ...
});
採用しなかった案:onChange モードで useDeferredValue を使用してレンダリングを遅延させる方法も検証しましたが、視覚的なラグが発生し、ユーザー体験が悪化したため不採用としました。
つまずきやすい点:
onEndモードでは、ドラッグ中の列幅プレビューが表示されません。CSS で疑似的なプレビューを表示する実装が別途必要です。
アクセシビリティ:カスタム role 属性の付与
TanStack Table は role 属性を自動で付与しないため、手動で設定する必要があります。以下の設計を採用しました。
tsx<div role='grid' aria-label='ユーザー一覧'>
<div role='rowgroup'>
{/* ヘッダー */}
<div role='row'>
{headerGroups.map((headerGroup) =>
headerGroup.headers.map((header) => (
<div role='columnheader' key={header.id}>
{/* ヘッダーコンテンツ */}
</div>
))
)}
</div>
</div>
<div role='rowgroup'>
{/* ボディ */}
{virtualRows.map((virtualRow) => (
<div role='row' key={virtualRow.key}>
{row.getVisibleCells().map((cell) => (
<div role='gridcell' key={cell.id}>
{/* セルコンテンツ */}
</div>
))}
</div>
))}
</div>
</div>
具体的な実装例
この章では、動作確認済みのコードを使って、仮想化・列リサイズ・アクセシビリティを統合したデータグリッドの実装方法を説明します。
型定義とカラム設定
まず、テーブルに表示するデータの型と列定義を作成します。
typescript// types.ts
export interface User {
id: string;
name: string;
email: string;
department: string;
createdAt: Date;
}
列定義では、TanStack Table の ColumnDef<T> を使用して型安全に設定します。
typescript// columns.tsx
import { ColumnDef } from '@tanstack/react-table';
import { User } from './types';
export const columns: ColumnDef<User>[] = [
{
accessorKey: 'name',
header: '氏名',
size: 150,
minSize: 100,
maxSize: 300,
},
{
accessorKey: 'email',
header: 'メールアドレス',
size: 250,
minSize: 150,
},
{
accessorKey: 'department',
header: '部署',
size: 120,
},
{
accessorKey: 'createdAt',
header: '登録日',
size: 120,
cell: ({ getValue }) => {
const date = getValue<Date>();
return date.toLocaleDateString('ja-JP');
},
},
];
仮想化テーブルコンポーネント
次に、仮想スクロールを組み込んだテーブルコンポーネントを実装します。動作確認済みのコードです。
tsx// VirtualTable.tsx
'use client';
import { useRef } from 'react';
import {
useReactTable,
getCoreRowModel,
flexRender,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { cn } from '@/lib/utils';
import { columns } from './columns';
import { User } from './types';
interface VirtualTableProps {
data: User[];
}
export function VirtualTable({ data }: VirtualTableProps) {
const parentRef = useRef<HTMLDivElement>(null);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
columnResizeMode: 'onEnd',
enableColumnResizing: true,
});
const { rows } = table.getRowModel();
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
measureElement: (element) =>
element.getBoundingClientRect().height,
});
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
return (
<div
ref={parentRef}
className='h-[600px] overflow-auto rounded-md border'
role='grid'
aria-label='ユーザー一覧テーブル'
aria-rowcount={rows.length}
>
{/* ヘッダー */}
<div
className='sticky top-0 z-10 bg-muted'
role='rowgroup'
>
{table.getHeaderGroups().map((headerGroup) => (
<div
key={headerGroup.id}
className='flex'
role='row'
>
{headerGroup.headers.map((header) => (
<div
key={header.id}
role='columnheader'
className='relative flex items-center border-b px-4 py-3 font-medium'
style={{ width: header.getSize() }}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{/* リサイズハンドル */}
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={cn(
'absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none',
header.column.getIsResizing()
? 'bg-primary'
: 'bg-border hover:bg-primary/50'
)}
/>
</div>
))}
</div>
))}
</div>
{/* ボディ(仮想化) */}
<div
role='rowgroup'
style={{ height: totalSize, position: 'relative' }}
>
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<div
key={row.id}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
role='row'
aria-rowindex={virtualRow.index + 1}
className='absolute left-0 flex w-full border-b'
style={{
transform: `translateY(${virtualRow.start}px)`,
}}
>
{row.getVisibleCells().map((cell) => (
<div
key={cell.id}
role='gridcell'
className='flex items-center px-4 py-3'
style={{ width: cell.column.getSize() }}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</div>
))}
</div>
);
})}
</div>
</div>
);
}
キーボードナビゲーションの実装
アクセシビリティ対応として、キーボードで行間を移動できる機能を追加します。
tsx// useGridNavigation.ts
import { useCallback, useState } from 'react';
export function useGridNavigation(
rowCount: number,
colCount: number
) {
const [focusedCell, setFocusedCell] = useState({
row: 0,
col: 0,
});
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
setFocusedCell((prev) => ({
...prev,
row: Math.max(0, prev.row - 1),
}));
break;
case 'ArrowDown':
e.preventDefault();
setFocusedCell((prev) => ({
...prev,
row: Math.min(rowCount - 1, prev.row + 1),
}));
break;
case 'ArrowLeft':
e.preventDefault();
setFocusedCell((prev) => ({
...prev,
col: Math.max(0, prev.col - 1),
}));
break;
case 'ArrowRight':
e.preventDefault();
setFocusedCell((prev) => ({
...prev,
col: Math.min(colCount - 1, prev.col + 1),
}));
break;
}
},
[rowCount, colCount]
);
return { focusedCell, handleKeyDown };
}
以下の図は、コンポーネントの構成を示しています。
mermaidflowchart LR
subgraph VirtualTable["VirtualTable コンポーネント"]
direction TB
Table["useReactTable"]
Virtual["useVirtualizer"]
Nav["useGridNavigation"]
end
Data["User[] データ"] --> Table
Table --> Virtual
Virtual --> Render["仮想化された行"]
Nav --> Render
Render --> DOM["DOM<br/>(表示領域のみ)"]
この図は、データが useReactTable で処理され、useVirtualizer で仮想化されて DOM にレンダリングされる流れを示しています。
shadcn/ui コンポーネントとの統合
この章では、shadcn/ui の既存コンポーネントとテーブルを統合する方法を説明します。
Table コンポーネントのスタイル活用
shadcn/ui には Table コンポーネントが用意されていますが、仮想スクロールとの併用には工夫が必要です。検証の結果、スタイルのみを流用し、構造は独自に実装する方針を採用しました。
tsx// shadcn/ui のスタイルを流用
import { tableStyles } from '@/components/ui/table';
// または Tailwind クラスを直接使用
<div className='rounded-md border'>
{/* テーブル実装 */}
</div>;
ContextMenu との統合
行の右クリックメニューには、shadcn/ui の ContextMenu を活用できます。
tsximport {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
// 行のレンダリング部分
<ContextMenu>
<ContextMenuTrigger asChild>
<div role='row' className='flex w-full border-b'>
{/* セルのレンダリング */}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>編集</ContextMenuItem>
<ContextMenuItem>削除</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>;
設計判断の詳細比較
この章では、本記事で取り上げた各機能について、より詳細な比較表を示します。
仮想化ライブラリの比較
| ライブラリ | バンドルサイズ | TanStack Table 連携 | 動的高さ対応 | 採用判断 |
|---|---|---|---|---|
| @tanstack/react-virtual | 2.5KB | 公式サポート | measureElement で対応可 | 採用 |
| react-window | 6.4KB | 追加実装が必要 | 別途計測ロジックが必要 | 不採用 |
| react-virtuoso | 15KB | 追加実装が必要 | 標準対応 | 不採用 |
@tanstack/react-virtual を採用した理由は、TanStack Table との連携が公式にサポートされており、バンドルサイズも最小だったためです。
列リサイズモードの比較
| モード | リアルタイム更新 | パフォーマンス | UX | 向いているケース |
|---|---|---|---|---|
| onChange | あり | 大量データで低下 | ドラッグ中に結果が見える | 数百行以下のテーブル |
| onEnd | なし | 安定 | 結果がドラッグ後に反映 | 数千行以上のテーブル |
本プロジェクトでは数千行のデータを扱うため、onEnd モードを採用しました。
アクセシビリティ対応の比較
| アプローチ | 実装コスト | 仮想スクロール互換性 | スクリーンリーダー対応 |
|---|---|---|---|
| WAI-ARIA grid パターン | 中 | 対応可(工夫が必要) | 高い |
| table 要素を使用 | 低 | 非対応(DOM 構造に依存) | 標準対応 |
| カスタム実装 | 高 | 自由に設計可能 | 実装次第 |
仮想スクロールでは DOM が動的に生成されるため、<table> 要素ではなく <div> + role 属性で実装する必要があります。
よくある問題と対処法
この章では、実装中に遭遇しやすい問題と解決策を説明します。
React 19 での flushSync 警告
@tanstack/react-virtual を React 19 で使用すると、以下の警告が表示されることがあります。
cssflushSync was called from inside a lifecycle method.
この警告は useFlushSync: false を設定することで解消できます。
typescriptconst rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
useFlushSync: false, // React 19 対応
});
スクロール位置のずれ
行の高さが可変の場合、高速スクロール時に位置がずれることがあります。overscan オプションで表示領域外にも余分な行をレンダリングすることで緩和できます。
typescriptconst rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
overscan: 10, // 表示領域外に10行分のバッファを確保
});
まとめ
shadcn/ui と TanStack Table を組み合わせることで、仮想スクロール・列リサイズ・アクセシビリティを同時に満たすデータグリッドを実装できます。
採用した設計のポイントを整理すると、以下のようになります。
- 仮想スクロール:@tanstack/react-virtual の
measureElementで動的な行高さに対応 - 列リサイズ:
columnResizeMode: 'onEnd'でパフォーマンスを確保 - アクセシビリティ:WAI-ARIA の grid パターンで role 属性を手動設定
ただし、この設計がすべてのケースに適しているわけではありません。数百行以下の小規模なテーブルであれば、仮想化は不要です。また、AG Grid のような高機能グリッドが求められる場合は、専用ライブラリの採用も検討してください。
プロジェクトの要件とチームの技術スタックに応じて、適切な選択をしていただければと思います。
関連リンク
著書
articleshadcn/ui × TanStack Table 設計術:仮想化・列リサイズ・アクセシブルなグリッド
articleshadcn/ui コンポーネント置換マップ:用途別に最短でたどり着く選定表
articleshadcn/ui CLI 完全活用:add/update のカスタムテンプレート運用術
articleshadcn/ui のバンドルサイズ影響を検証:Tree Shaking・Code Split の実測データ
articleshadcn/ui × RSC 時代のフロント設計:Server/Client の最適境界を見極める指針
articleshadcn/ui のテンプレート差分を追従する運用:更新検知・差分マージ・回帰防止
articleshadcn/ui × TanStack Table 設計術:仮想化・列リサイズ・アクセシブルなグリッド
articleRemix のデータ境界設計:Loader・Action とクライアントコードの責務分離
articlePreact コンポーネント設計 7 原則:再レンダリング最小化の分割と型付け
articlePHP 8.3 の新機能まとめ:readonly クラス・型強化・性能改善を一気に理解
articleNotebookLM に PDF/Google ドキュメント/URL を取り込む手順と最適化
articlePlaywright 並列実行設計:shard/grep/fixtures で高速化するテストスイート設計術
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来
