T-CREATOR

<div />

shadcn/ui × TanStack Table 設計術:仮想化・列リサイズ・アクセシブルなグリッド

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 の列リサイズ機能には onChangeonEnd の 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-virtual2.5KB公式サポートmeasureElement で対応可採用
react-window6.4KB追加実装が必要別途計測ロジックが必要不採用
react-virtuoso15KB追加実装が必要標準対応不採用

@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 のような高機能グリッドが求められる場合は、専用ライブラリの採用も検討してください。

プロジェクトの要件とチームの技術スタックに応じて、適切な選択をしていただければと思います。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;