数千〜数万行のリストやテーブルを Vue 3 で描画すると、ブラウザが重くなりフレームレートが急激に落ちます。原因は DOM ノード数の爆発的な増加です。仮想スクロール(Virtual Scroll / バーチャルスクロール)は、ビューポートに映る行だけを DOM に配置し、見えない行を削除または再利用するテクニックで、表示件数が増えてもレンダリングコストを一定に保てます。

Vue 3 の Composition API や <script setup> 構文は仮想スクロールとの相性が優れており、状態管理・ライフサイクルフック・リアクティブ変数を活かした効率的な実装が可能です。

仮想スクロールの基本的な仕組み

通常のリスト描画では、1 万件のデータがあれば 1 万個の DOM ノードが生成されます。各ノードにはレイアウト計算・ペイント・コンポジットのコストが発生し、スクロール操作が 60fps を下回る原因になります。

仮想スクロールでは次の 3 つの処理を組み合わせて、この問題を回避します。

  1. ビューポート検知 — スクロールコンテナの高さとスクロール位置から「今見えている範囲」を特定
  2. スライス計算 — 全データ配列のうち、見える範囲 + バッファ(overscan)分のインデックスだけを切り出す
  3. 位置合わせ — 切り出したアイテムを transform: translateY()padding-top で正しい位置に配置し、スクロールバーの高さを全件分に見せかける

この手法を使うと、10 万件のリストでも実際の DOM ノードは 20〜50 個程度に抑えられます。

Vue 3 向け仮想スクロールライブラリの選び方

Vue 3 で仮想スクロールを導入するとき、ゼロから書くか既存ライブラリを採用するかは最初の判断ポイントです。主要なライブラリを横並びで比較します。

項目@tanstack/vue-virtualvirtuavue-virtual-scrollerVueUse useVirtualList
GitHub Star(2026年2月時点)約 6,700約 3,500約 10,600VueUse 全体で約 21,000
gzip 後サイズ10〜15 kB約 3 kB約 15 kBVueUse 依存
TypeScript 対応コア 98% TS完全対応型定義あり完全対応
可変高さアイテム対応(measureElement)ゼロ設定で自動計測DynamicScroller で対応itemHeight 関数で対応
水平スクロール対応対応非対応itemWidth で対応
グリッドレイアウト対応実験的(VGrid)非対応非対応
SSR 互換対応対応部分的対応
ヘッドレス設計あり(マークアップ自由)コンポーネント提供コンポーネント提供Composable
対応フレームワークReact / Vue / Svelte / Solid / Lit / AngularReact / Vue / Svelte / SolidVue 専用Vue 専用
公式の推奨状況TanStack 公式サポート公式ドキュメントで TanStack Virtual を推奨

※ Star 数・サイズは各 GitHub リポジトリ(TanStack/virtualinokawa/virtuaAkryum/vue-virtual-scroller)および VueUse useVirtualList の 2026 年 2 月時点の値です。

選定の目安

  • バンドルサイズを最小にしたい → virtua(約 3 kB)
  • マークアップやスタイルを完全に制御したい → @tanstack/vue-virtual(ヘッドレス設計)
  • 設定不要ですぐ動かしたい → virtua(ゼロコンフィグ)
  • Vue 専用で実績が豊富 → vue-virtual-scroller(Star 数最多、採用プロジェクト約 24,300 件)
  • 軽量な Composable で十分 → VueUse useVirtualList(ただし公式が @tanstack/vue-virtual への移行を推奨)

@tanstack/vue-virtual で実装する

TanStack Virtual はヘッドレス設計のため、DOM 構造を自由にカスタマイズできます。Vue 3 の Composition API との組み合わせ例を示します。

インストール

npm install @tanstack/vue-virtual

固定高さリストの基本例

<script setup lang="ts">
import { ref } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'

const items = Array.from({ length: 50000 }, (_, i) => ({
  id: i,
  label: `アイテム ${i + 1}`,
}))

const scrollContainerRef = ref<HTMLElement | null>(null)

const virtualizer = useVirtualizer({
  count: items.length,
  getScrollElement: () => scrollContainerRef.value,
  estimateSize: () => 40, // 各行の推定高さ(px)
  overscan: 5,
})
</script>

<template>
  <div
    ref="scrollContainerRef"
    style="height: 500px; overflow-y: auto;"
  >
    <div
      :style="{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }"
    >
      <div
        v-for="row in virtualizer.getVirtualItems()"
        :key="row.key"
        :style="{
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%',
          height: `${row.size}px`,
          transform: `translateY(${row.start}px)`,
        }"
      >
        {{ items[row.index].label }}
      </div>
    </div>
  </div>
</template>

ポイントは estimateSize で行の高さを概算値として渡すことです。実際の高さは描画後に measureElement で自動補正されるため、可変高さのリストにもそのまま対応できます。

可変高さへの対応

<script setup lang="ts">
import { ref } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'

interface Message {
  id: number
  text: string
}

const messages = ref<Message[]>(
  Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    text: `メッセージ ${i + 1}${'あ'.repeat(Math.floor(Math.random() * 200) + 10)}`,
  }))
)

const scrollContainerRef = ref<HTMLElement | null>(null)

const virtualizer = useVirtualizer({
  count: messages.value.length,
  getScrollElement: () => scrollContainerRef.value,
  estimateSize: () => 60,
  overscan: 8,
})
</script>

<template>
  <div ref="scrollContainerRef" style="height: 600px; overflow-y: auto;">
    <div :style="{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }">
      <div
        v-for="row in virtualizer.getVirtualItems()"
        :key="row.key"
        :ref="(el) => { if (el) virtualizer.measureElement(el as HTMLElement) }"
        :data-index="row.index"
        :style="{
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%',
          transform: `translateY(${row.start}px)`,
        }"
      >
        <div style="padding: 8px 12px; border-bottom: 1px solid #eee;">
          {{ messages[row.index].text }}
        </div>
      </div>
    </div>
  </div>
</template>

:refmeasureElement を渡すと、要素の実高さが自動で取得され、スクロール位置が補正されます。estimateSize はあくまで初期表示時の仮値です。

virtua で手軽に導入する

virtua は設定なしで動的サイズ計測を行ってくれるため、最小限のコードで仮想スクロールを実現できます。

インストール

npm install virtua

基本例

<script setup lang="ts">
import { VList } from 'virtua/vue'

const items = Array.from({ length: 100000 }, (_, i) => `項目 ${i + 1}`)
</script>

<template>
  <VList :data="items" style="height: 500px;">
    <template #default="{ item, index }">
      <div style="padding: 8px 16px; border-bottom: 1px solid #f0f0f0;">
        {{ item }}
      </div>
    </template>
  </VList>
</template>

virtua は各アイテムの高さを描画時に自動計測するため、itemSize のような設定は不要です。gzip 後わずか約 3 kB と軽量で、パフォーマンスに敏感なプロジェクトに向いています。

vue-virtual-scroller で実装する

Vue エコシステムで最も Star 数の多い仮想スクロールライブラリです。RecycleScroller と DynamicScroller という 2 つのコンポーネントを提供しています。

インストール

npm install vue-virtual-scroller
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'

const app = createApp(App)

// グローバル登録
import VueVirtualScroller from 'vue-virtual-scroller'
app.use(VueVirtualScroller)

app.mount('#app')

RecycleScroller(固定高さ)

<script setup lang="ts">
const items = Array.from({ length: 30000 }, (_, i) => ({
  id: i,
  name: `ユーザー ${i + 1}`,
}))
</script>

<template>
  <RecycleScroller
    class="scroller"
    :items="items"
    :item-size="48"
    key-field="id"
    v-slot="{ item }"
  >
    <div class="user-row">
      {{ item.name }}
    </div>
  </RecycleScroller>
</template>

<style scoped>
.scroller {
  height: 500px;
}
.user-row {
  height: 48px;
  display: flex;
  align-items: center;
  padding: 0 16px;
  border-bottom: 1px solid #eee;
}
</style>

RecycleScroller は DOM ノードを「プール」として再利用する仕組みです。スクロールで画面外に出た DOM を破棄せず、新しいデータを差し込んで再配置するため、GC 圧力が低く安定したフレームレートを維持できます。

DynamicScroller(可変高さ)

<script setup lang="ts">
interface ChatMessage {
  id: number
  body: string
}

const messages: ChatMessage[] = Array.from({ length: 5000 }, (_, i) => ({
  id: i,
  body: `投稿 ${i + 1}${'テスト'.repeat(Math.floor(Math.random() * 30) + 1)}`,
}))
</script>

<template>
  <DynamicScroller
    :items="messages"
    :min-item-size="40"
    key-field="id"
    class="scroller"
  >
    <template v-slot="{ item, index, active }">
      <DynamicScrollerItem
        :item="item"
        :active="active"
        :data-index="index"
      >
        <div class="message-card">
          {{ item.body }}
        </div>
      </DynamicScrollerItem>
    </template>
  </DynamicScroller>
</template>

<style scoped>
.scroller {
  height: 600px;
}
.message-card {
  padding: 12px 16px;
  border-bottom: 1px solid #eee;
}
</style>

DynamicScroller は内部で各アイテムの高さをキャッシュし、スクロール位置を適切に補正します。チャットやタイムラインのような行ごとに長さが異なる UI に適しています。

Composition API で仮想スクロールを自作する

ライブラリに依存したくない場合や仕組みを深く理解したい場合は、Composable として自作できます。固定高さのリストを対象にした最小限の実装を示します。

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'

// --- Composable ---
function useVirtualScroll<T>(
  allItems: T[],
  itemHeight: number,
  containerHeight: number,
  overscan = 3
) {
  const scrollTop = ref(0)
  const containerRef = ref<HTMLElement | null>(null)

  const totalHeight = computed(() => allItems.length * itemHeight)

  const startIndex = computed(() =>
    Math.max(0, Math.floor(scrollTop.value / itemHeight) - overscan)
  )

  const endIndex = computed(() =>
    Math.min(
      allItems.length,
      Math.ceil((scrollTop.value + containerHeight) / itemHeight) + overscan
    )
  )

  const visibleItems = computed(() =>
    allItems.slice(startIndex.value, endIndex.value).map((data, i) => ({
      data,
      index: startIndex.value + i,
      offsetY: (startIndex.value + i) * itemHeight,
    }))
  )

  function handleScroll() {
    if (containerRef.value) {
      scrollTop.value = containerRef.value.scrollTop
    }
  }

  onMounted(() => {
    containerRef.value?.addEventListener('scroll', handleScroll, { passive: true })
  })

  onUnmounted(() => {
    containerRef.value?.removeEventListener('scroll', handleScroll)
  })

  return { containerRef, totalHeight, visibleItems }
}

// --- 使用例 ---
const data = Array.from({ length: 100000 }, (_, i) => `行 ${i + 1}`)
const ROW_HEIGHT = 36
const CONTAINER_HEIGHT = 500

const { containerRef, totalHeight, visibleItems } = useVirtualScroll(
  data,
  ROW_HEIGHT,
  CONTAINER_HEIGHT
)
</script>

<template>
  <div
    ref="containerRef"
    :style="{ height: `${CONTAINER_HEIGHT}px`, overflowY: 'auto' }"
  >
    <div :style="{ height: `${totalHeight}px`, position: 'relative' }">
      <div
        v-for="entry in visibleItems"
        :key="entry.index"
        :style="{
          position: 'absolute',
          top: `${entry.offsetY}px`,
          left: 0,
          right: 0,
          height: `${ROW_HEIGHT}px`,
          lineHeight: `${ROW_HEIGHT}px`,
          padding: '0 12px',
          borderBottom: '1px solid #f0f0f0',
        }"
      >
        {{ entry.data }}
      </div>
    </div>
  </div>
</template>

この実装は固定高さに限定されますが、仮想スクロールの核心 ── ビューポート検知・スライス計算・位置合わせ ── を最小のコードで示しています。可変高さに対応するには、各行の高さキャッシュと ResizeObserver による再計測が必要になり、複雑さが大幅に増します。その場合はライブラリの利用が現実的です。

パフォーマンスを最大化するテクニック

仮想スクロールを導入しただけでは最適とは限りません。さらにフレームレートを安定させるためのテクニックを紹介します。

overscan の調整

overscan(プリレンダリングする画面外の行数)が小さすぎると高速スクロール時に白い領域が見え、大きすぎると DOM ノードが増えてパフォーマンスが落ちます。一般的な目安は 3〜10 行 です。モバイルでは慣性スクロールの速度が大きいため、やや多めに設定すると体感が改善します。

requestAnimationFrame によるスロットル

scroll イベントは 1 フレーム中に何度も発火する可能性があります。requestAnimationFrame で 1 フレーム 1 回に抑えると、無駄な再計算を排除できます。

let rafId: number | null = null

function onScroll() {
  if (rafId !== null) return
  rafId = requestAnimationFrame(() => {
    // スクロール位置を更新
    scrollTop.value = containerRef.value?.scrollTop ?? 0
    rafId = null
  })
}

CSS contain プロパティの活用

仮想スクロールのコンテナに contain: strict を指定すると、ブラウザが「この要素の中身は外部レイアウトに影響しない」と判断し、レイアウト再計算の範囲を狭められます。

.virtual-container {
  contain: strict;
  overflow-y: auto;
}

仮想スクロール + 無限スクロールの組み合わせ

大量データを一度に取得するのではなく、スクロール末尾到達時に追加読み込みする「無限スクロール」と仮想スクロールを組み合わせるパターンも有効です。IntersectionObserver でリスト末尾のセンチネル要素を監視し、交差時に次ページを取得するのが定番の実装です。

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'

const items = ref<string[]>([])
const isLoading = ref(false)
const page = ref(1)
const sentinelRef = ref<HTMLElement | null>(null)
const scrollContainerRef = ref<HTMLElement | null>(null)

async function loadMore() {
  if (isLoading.value) return
  isLoading.value = true
  // API から追加データを取得する想定
  const newItems = Array.from(
    { length: 50 },
    (_, i) => `ページ${page.value} - 項目${i + 1}`
  )
  items.value = [...items.value, ...newItems]
  page.value++
  isLoading.value = false
}

const virtualizer = useVirtualizer({
  get count() { return items.value.length },
  getScrollElement: () => scrollContainerRef.value,
  estimateSize: () => 40,
  overscan: 5,
})

onMounted(() => {
  loadMore()

  const observer = new IntersectionObserver(
    (entries) => {
      if (entries[0].isIntersecting) loadMore()
    },
    { root: scrollContainerRef.value, rootMargin: '200px' }
  )

  if (sentinelRef.value) observer.observe(sentinelRef.value)
})
</script>

仮想スクロール導入時の注意点

仮想スクロールは強力ですが、万能ではありません。導入前に把握しておくべき制約があります。

ブラウザ内検索(Ctrl+F / Cmd+F)が効かない

DOM に存在しない行はブラウザのテキスト検索でヒットしません。対策としては次の方法があります。

  • リスト上部に独自の検索ボックスを設置し、フィルタリングやスクロール位置の移動で対応する
  • 検索時のみ仮想スクロールを無効化する(件数が少ない場合に限る)
  • 検索用のサーバー API を用意し、結果のインデックスへ scrollToIndex で移動させる

アクセシビリティへの配慮

スクリーンリーダーは DOM に存在する要素しか読み上げません。WAI-ARIA 属性を適切に付与しましょう。

<div role="list" aria-label="検索結果一覧" aria-rowcount="10000">
  <div
    v-for="row in virtualizer.getVirtualItems()"
    :key="row.key"
    role="listitem"
    :aria-rowindex="row.index + 1"
  >
    <!-- 行の内容 -->
  </div>
</div>

aria-rowcount で全体の行数を伝え、aria-rowindex で現在の位置を示すことで、支援技術がリスト全体を認識できるようになります。

テスト時の工夫

Vitest や Jest の JSDOM 環境では scrollTop や要素の高さが正しくシミュレートされないため、仮想スクロールのテストには注意が必要です。

  • 単体テスト — ビューポート計算ロジック(startIndex / endIndex の算出)を純粋関数として切り出し、DOM 無しでテストする
  • コンポーネントテスト@testing-library/vuescrollTop をモックし、正しい行が描画されるか検証する
  • E2E テスト — Playwright や Cypress で実ブラウザ上のスクロール操作とスナップショットを組み合わせる

SSR(サーバーサイドレンダリング)との互換性

Nuxt 3 などの SSR 環境では、サーバー側に windowscrollTop が存在しません。ほとんどのライブラリはクライアント側でのみ仮想スクロールを有効化する仕組みを備えていますが、初期描画で空リストが一瞬表示される場合があります。estimateSize を使って初期表示分の行をサーバー側で静的に描画し、ハイドレーション後に仮想スクロールへ切り替える方法が有効です。

表示件数が少ない場合は不要

数百件以下のリストに仮想スクロールを導入すると、初期化コストやコードの複雑化というデメリットだけが目立ちます。一般的に 500〜1,000 件を超えるリストで効果が顕著になるため、それ以下であれば通常の v-for で十分です。

UIフレームワーク組み込みの仮想スクロール

Vue 3 向けの UI コンポーネントライブラリには、仮想スクロール機能が組み込まれているものがあります。すでにこれらを使用しているプロジェクトでは、追加ライブラリなしで仮想スクロールを利用できます。

UIフレームワークコンポーネント名特徴
Vuetify 3v-virtual-scrollVuetify のデザインシステムと統合済み。v-intersect ディレクティブで無限スクロールも実現可能
QuasarQVirtualScrollテーブル・リスト・グリッドに対応。Quasar CLI との組み合わせで設定不要
PrimeVueVirtualScrollerDataTable や Listbox との連携が容易。遅延読み込みモード搭載
Element Plusel-table-v2テーブル特化の仮想スクロール。列固定やソートとの併用が可能

既存プロジェクトで上記フレームワークを使っている場合は、まずフレームワーク組み込みの機能を検討するのが合理的です。独立ライブラリの導入は、フレームワークの仮想スクロールでは不足する場合(水平スクロール、グリッドレイアウト、より細かいチューニングが必要な場合など)に検討します。

まとめ

Vue 3 での仮想スクロール導入は、リスト件数が 500〜1,000 件を超えた段階で検討する価値があります。ライブラリ選定は用途に応じて以下のように使い分けます。

  • 最小バンドル・ゼロ設定 → virtua(GitHub
  • ヘッドレスで自由度を重視 → @tanstack/vue-virtual(GitHub
  • 採用実績と安定性 → vue-virtual-scroller(GitHub
  • Composable で軽く使いたい → VueUse useVirtualList(ドキュメント

導入後はブラウザ内検索の制限やアクセシビリティへの対応を忘れずに行い、テストでは E2E を含めた多層的な検証を推奨します。仮想スクロールの仕組みを理解したうえでライブラリを選べば、大規模リストの描画パフォーマンスを安定して改善できます。