仮想スクロールは、画面に見えている行だけを DOM に置き、スクロールに合わせて中身を入れ替えるしくみです。Angular CDK では <cdk-virtual-scroll-viewport itemSize="行の高さ"> で囲み、@for の代わりに *cdkVirtualFor を書きます。

import { ScrollingModule } from '@angular/cdk/scrolling';

@Component({
  selector: 'app-virtual-list',
  imports: [ScrollingModule],
  template: `
    <cdk-virtual-scroll-viewport itemSize="48" class="box">
      <div *cdkVirtualFor="let u of users(); trackBy: trackById" class="row">
        {{ u.id }} {{ u.name }}
      </div>
    </cdk-virtual-scroll-viewport>
  `,
  styles: `.box { height: 480px; } .row { height: 48px; }`,
})
export class VirtualList {
  readonly users = signal(makeUsers(10_000));
  trackById = (_: number, u: User) => u.id;
}

1万件の一覧を、通常の @for と仮想スクロールで比べた結果です(5回測った中央値)。

測ったもの 通常の @for 仮想スクロール
DOM に置かれた行 10,000 行 15 行
ページ全体の要素数 50,022 100
表示されるまでの時間 1,359 ms 585 ms
1万件を逆順に並べ替えて描き直す時間 1,136 ms 17.9 ms
JS ヒープの使用量(GC 後) 9.4 MB 5.1 MB

確認環境:Angular CLI 22.2.2・@angular/core 22.2.2・@angular/cdk 22.2.2・@angular/material 22.2.2・@angular/cdk-experimental 22.2.2、Node.js 24.21.0、Linux(2コアの仮想マシン)。ng build(本番設定)したアプリを、Chromium 141 を playwright-core 1.56.1 で操作して測りました。時間は performance.now()、要素数は document.getElementsByTagName('*')、ヒープは Chrome DevTools Protocol の Performance.getMetrics で読んだ値です。仕様は angular/components の 22.2.x ブランチの「Scrolling」ドキュメント・表のドキュメントと、@angular/cdk 22.2.2 のソースで確かめました(確認日 2026年10月13日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angular CDK仮想スクロールの使い方|1万件の一覧を軽く表示」

しくみ:見えている分だけ描く

CDK の「Scrolling」ドキュメントでは、仮想スクロールを次のように説明しています。

  • 外側の要素の高さを「全件を並べたときの高さ」にして、全件があるように見せる
  • 実際には、画面に入る行だけを描く
  • 「最後まで来たら残りを描き足す」無限スクロールとは別のしくみ

itemSize="48" で1万件なら、中の高さは 48 × 10,000 = 480,000px になります。実際に測ると、scrollHeight は 480000 でした。スクロールすると、描いている行の塊を transform: translateY(…) で下へずらし、中身を差し替えます。

完成コード:1万件の一覧

データ

// data.ts
export interface User {
  id: number;
  name: string;
  email: string;
  dept: string;
  note: string;
}

const depts = ['営業', '開発', '総務', '人事', '経理'];

export function makeUsers(n: number, start = 0): User[] {
  return Array.from({ length: n }, (_, i) => {
    const id = start + i + 1;
    return {
      id,
      name: `ユーザー ${id}`,
      email: `user${id}@example.com`,
      dept: depts[id % depts.length],
      note: 'メモ。'.repeat((id * 7) % 40 + 1), // 高さが違う行の確認用
    };
  });
}

仮想スクロールの一覧

// virtual-list.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { makeUsers, User } from './data';

@Component({
  selector: 'app-virtual-list',
  imports: [ScrollingModule],
  template: `
    <button type="button" id="reverse" (click)="reverse()">逆順</button>
    <cdk-virtual-scroll-viewport itemSize="48" class="box">
      <div *cdkVirtualFor="let u of users(); trackBy: trackById" class="row">
        <span class="id">{{ u.id }}</span>
        <span class="name">{{ u.name }}</span>
        <span class="mail">{{ u.email }}</span>
        <span class="dept">{{ u.dept }}</span>
      </div>
    </cdk-virtual-scroll-viewport>
  `,
  styleUrl: './vs.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VirtualList {
  readonly users = signal<User[]>(makeUsers(10_000));
  trackById = (_: number, u: User) => u.id;
  reverse(): void {
    this.users.update((list) => [...list].reverse());
  }
}
// vs.scss
.box { height: 480px; display: block; border: 1px solid #ccc; }
.row {
  height: 48px;            // itemSize と同じにする
  box-sizing: border-box;  // 枠線・余白を高さに含める
  display: flex; gap: 12px; align-items: center;
  padding: 0 12px; border-bottom: 1px solid #eee;
}
.id { width: 56px; color: #666; }
.name { width: 120px; }
.mail { flex: 1; }

比べた通常の @for

<div class="box" style="overflow: auto">
  @for (u of users(); track u.id) {
    <div class="row">
      <span class="id">{{ u.id }}</span>
      <span class="name">{{ u.name }}</span>
      <span class="mail">{{ u.email }}</span>
      <span class="dept">{{ u.dept }}</span>
    </div>
  }
</div>

@for の書き方は、関連記事「Angular @if・@for・@switchの使い方|ngIfとの対応表」で扱っています。

押さえる点

点 内容
ビューポートに高さを付ける 高さが無いと clientHeight が 0 になり、何も見えなかった
行の高さを itemSize と合わせる box-sizing: border-box で、枠線を含めた高さを 48px に
@for ではなく *cdkVirtualFor 仮想スクロールの中の繰り返しは、このディレクティブで書く
trackBy を付ける 並べ替え・再取得のとき、行の作り直しを減らす

cdk-virtual-scroll-viewport には contain: strict(中身の大きさで外側の大きさが決まらない指定)が付いています。CSS で高さを auto にして確かめると、clientHeight は 0 で、画面には何も出ませんでした。height: 480px や height: 100%(親に高さがある場合)、flex: 1 などで高さを決めます。

実測:通常の @for と比べる

測り方

  • ページを開いて、最初の行が描かれた次のフレームの performance.now() を「表示までの時間」とした(ページの読み込み開始から)
  • 「逆順」ボタンを押してから2フレーム後までの時間を「並べ替えの時間」とした(1行目が 10000 に変わったことも確かめた)
  • 上から下まで100回に分けてスクロールし、その間のメインスレッドの仕事量を Performance.getMetrics の差で測った
  • 毎回新しいページで5回ずつ測り、中央値を取った

結果

測ったもの 通常の @for 仮想スクロール
.row の数 10,000 15
ページの要素数(getElementsByTagName('*')) 50,022 100
DevTools の Nodes(テキストなども含む) 90,057 192
表示までの時間(5回の値) 1,359 ms(1,159〜2,225) 585 ms(483〜620)
逆順の並べ替え(5回の値) 1,136 ms(776〜2,257) 17.9 ms(5〜30)
JS ヒープ(GC 後) 9.4 MB 5.1 MB
100回のスクロール中のメインスレッドの仕事 676 ms 377 ms

差が一番大きかったのは、データが変わったときの描き直しでした。 通常の @for は1万行すべての並びを入れ替えるので1秒を超え、その間は画面が固まります。仮想スクロールは見えている15行を差し替えるだけで、17.9 ms でした。検索や並べ替えで一覧が何度も変わる画面ほど、効果が出ます。

スクロールそのものの滑らかさは、この行の作りでは大きな差になりませんでした。 100回のスクロールにかかった時間はどちらも約1.66秒(1フレーム約16.6ms × 100)で、ブラウザーが追いついていました。仮想スクロールはスクロールのたびに行を差し替えるので、レイアウトとスタイルの計算(114 ms)やスクリプト(105 ms)が出ますが、メインスレッドの仕事の合計は通常の方が多い結果でした。行の中身が重い(画像・入れ子のコンポーネントが多い)ほど、通常の @for の負担は増えます。

数値は2コアの仮想マシンでの値です。端末の性能で変わるので、目安として読みます。

*cdkVirtualFor と viewport の API

*cdkVirtualFor で使える変数

ドキュメントによると、*ngFor と同じ使い方ができ、次の変数が使えます。値は「描いている範囲の中」ではなく、「データ全体の中」の位置です。

変数 内容
index データ全体での位置
count データの件数
first / last 最初・最後の要素か
even / odd 偶数・奇数番目か
<div *cdkVirtualFor="let u of users(); let i = index; let odd = odd; trackBy: trackById"
     [class.odd]="odd">{{ i + 1 }}. {{ u.name }}</div>

渡せるデータは、配列・Observable<配列>・DataSource(表やツリーと同じ形)です。

viewport の入力と主なメソッド

名前 既定(22.2.2 のソース) 内容
itemSize 20 1行の高さ(px)
minBufferPx 100 見えている範囲の外に、最低これだけ描いておく(px)
maxBufferPx 200 足りなくなったとき、ここまで描き足す(px)
orientation vertical horizontal で横スクロール
appendOnly false 一度描いた行を消さずに残す
cdkVirtualForTemplateCacheSize 20 使い回すために取っておく行の数
(scrolledIndexChange) — 見えている先頭の行の番号が変わると出る
scrollToIndex(i) — i 番目の行までスクロール
getRenderedRange() — いま描いている範囲 { start, end }
getDataLength() — データの件数
measureScrollOffset() — いまのスクロール位置(px)
checkViewportSize() — 外側の大きさが変わったときに測り直す

minBufferPx と maxBufferPx の関係は、ドキュメントの例が分かりやすいです。itemSize = 50・minBufferPx = 100・maxBufferPx = 250 で、残りの余白が 90px になると、100 を下回ったので描き足しが始まり、250 を超えるまで4行(200px)を足して 290px にします。スクロールの速い画面で端が一瞬白く見えるときは、この2つを大きくします。

ビューポートの大きさを CSS で後から変える(タブの切り替えで表示されるなど)と、行の数が合わなくなることがあります。そのときは checkViewportSize() を呼びます。

高さが違う行の扱い

itemSize は「全部の行が同じ高さ」という前提の設定です(FixedSizeVirtualScrollStrategy)。メモ欄の長さで高さが 61px〜161px と変わる行を作り、2つの方法で比べました。

itemSize のまま(誤った使い方)

<cdk-virtual-scroll-viewport itemSize="48" class="box">
  <div *cdkVirtualFor="let u of users(); trackBy: trackById" class="auto-row">
    <strong>{{ u.id }} {{ u.name }}</strong>
    <p>{{ u.note }}</p>
  </div>
</cdk-virtual-scroll-viewport>

autosize(@angular/cdk-experimental)

npm i @angular/cdk-experimental
import { ScrollingModule } from '@angular/cdk/scrolling';
import { ScrollingModule as ExperimentalScrollingModule } from '@angular/cdk-experimental/scrolling';

@Component({
  imports: [ScrollingModule, ExperimentalScrollingModule],
  template: `
    <cdk-virtual-scroll-viewport autosize class="box">
      <div *cdkVirtualFor="let u of users(); trackBy: trackById" class="auto-row">
        <strong>{{ u.id }} {{ u.name }}</strong>
        <p>{{ u.note }}</p>
      </div>
    </cdk-virtual-scroll-viewport>
  `,
})

結果

120px ずつ200回スクロールし、毎回「前後どちらにも描かれている行」が画面上で何 px 動いたかを測りました。正しくは毎回ちょうど 120px 動きます。

測ったもの itemSize="48" のまま autosize
行の高さ 61〜161px 61〜161px
見た目がずれた回数(200回中) 100回 0回
ずれの最大 365px 0px
スクロールできる全体の高さ 480,000px(48 × 1万) 1,024,286px → 1,063,715px
一番下まで行けたか 行けた(9996〜10000 が見えた) 行けた(9996〜10000 が見えた)

itemSize のままだと、200回中100回、行が飛ぶように動きました(最大 365px)。 描く範囲が切り替わるたびに、行の塊を「番号 × 48px」の位置に置き直すのに、実際の行はもっと高いためです。読んでいた行が急に消えたり戻ったりして見えます。

autosize では、ずれは0回でした。 描いた行の高さの平均から、まだ描いていない行の高さを見積もるしくみです(ソースの ItemSizeAverager)。そのため、スクロールしていくと全体の高さの見積もりが変わり(1,024,286px → 1,063,715px)、スクロールバーのつまみの大きさが少し変わります。

autosize の注意点です。

  • CDK のドキュメントでは「@angular/cdk-experimental で開発中で、本番で使える段階ではない」とされています
  • @angular/cdk-experimental 22.2.2 のソースでは、autosize の scrollToIndex() は開発モードで cdk-virtual-scroll: scrollToIndex is currently not supported for the autosize scroll strategy というエラーを投げます。「○番目へ移動」は使えません

本番の画面では、次のどれかを選ぶのが現実的です。

方法 内容
高さをそろえる 長い文は -webkit-line-clamp などで2行に切り、行の高さを固定して itemSize を使う
高さの種類が少ない 自分で VirtualScrollStrategy を作り、VIRTUAL_SCROLL_STRATEGY で渡す(ドキュメントに方法あり)
実験的と承知で使う autosize。scrollToIndex を使わない画面に限る

テーブルと組み合わせる(mat-table)

Angular Material の表のドキュメント(22.2.x)には「Virtual scrolling」の節があり、<table mat-table> を cdk-virtual-scroll-viewport で囲む方法が載っています。1万行で試したコードです。

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { MatTableModule } from '@angular/material/table';
import { makeUsers, User } from './data';

@Component({
  selector: 'app-table-list',
  imports: [ScrollingModule, MatTableModule],
  template: `
    <cdk-virtual-scroll-viewport class="table-box" [itemSize]="52" [minBufferPx]="400" [maxBufferPx]="1200">
      <table mat-table [dataSource]="users" [trackBy]="trackById">
        <ng-container matColumnDef="id">
          <th mat-header-cell *matHeaderCellDef>ID</th>
          <td mat-cell *matCellDef="let u">{{ u.id }}</td>
        </ng-container>
        <ng-container matColumnDef="name">
          <th mat-header-cell *matHeaderCellDef>名前</th>
          <td mat-cell *matCellDef="let u">{{ u.name }}</td>
        </ng-container>
        <ng-container matColumnDef="email">
          <th mat-header-cell *matHeaderCellDef>メール</th>
          <td mat-cell *matCellDef="let u">{{ u.email }}</td>
        </ng-container>
        <ng-container matColumnDef="dept">
          <th mat-header-cell *matHeaderCellDef>部署</th>
          <td mat-cell *matCellDef="let u">{{ u.dept }}</td>
        </ng-container>

        <tr mat-header-row *matHeaderRowDef="columns; sticky: true"></tr>
        <tr mat-row *matRowDef="let row; columns: columns"></tr>
      </table>
    </cdk-virtual-scroll-viewport>
  `,
  styles: `
    .table-box { height: 480px; overflow: auto; display: block; }
    .table-box table { width: 100%; }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TableList {
  readonly columns = ['id', 'name', 'email', 'dept'];
  readonly users: User[] = makeUsers(10_000);
  trackById = (_: number, u: User) => u.id;
}

スクロール位置を変えて、描かれた行の数と見出しの位置を測りました。

スクロール位置 描かれた行 先頭の行の ID 見出しの位置(枠の上端から) 要素数
0 33 1 0px 199
20,000px 41 377 0px 239
260,000px 41 4993 0px 239
520,000px(末尾) 17 9984 0px 119

*cdkVirtualFor を書かなくても、mat-table の行が見える分だけになり、sticky: true の見出しも上に残りました。 行の高さは Material の既定の 52px に合わせて itemSize を 52 にしています。ドキュメントには、仮想スクロールの表の制限として、次の2つが書かれています。

  • fixedLayout(列の幅を固定する表の配置)が常に有効になる。行の入れ替えで列の幅が揺れないようにするため
  • when で行のテンプレートを出し分ける機能は、今はまだ使えない

Material の表の基本は、関連記事「Angular Materialの使い方|導入からテーブル・ダイアログまで」で扱っています。

無限スクロールと組み合わせる

仮想スクロールは「全件が手元にある」前提のしくみです。サーバーから少しずつ読む無限スクロールと組み合わせるときは、(scrolledIndexChange) で末尾に近づいたことを知り、次のページを読み足します。

import { ChangeDetectionStrategy, Component, signal, viewChild } from '@angular/core';
import { CdkVirtualScrollViewport, ScrollingModule } from '@angular/cdk/scrolling';
import { makeUsers, User } from './data';

const PAGE = 50;
const TOTAL = 1_000;

@Component({
  selector: 'app-infinite-list',
  imports: [ScrollingModule],
  template: `
    <cdk-virtual-scroll-viewport itemSize="48" class="box" (scrolledIndexChange)="onScroll()">
      <div *cdkVirtualFor="let u of users(); trackBy: trackById" class="row">
        <span class="id">{{ u.id }}</span>
        <span class="name">{{ u.name }}</span>
      </div>
    </cdk-virtual-scroll-viewport>
    <p id="status">{{ users().length }} 件 / 読み込み {{ pages() }} 回 @if (loading()) { ・読み込み中 }</p>
  `,
  styleUrl: './vs.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InfiniteList {
  readonly viewport = viewChild.required(CdkVirtualScrollViewport);
  readonly users = signal<User[]>([]);
  readonly loading = signal(false);
  readonly pages = signal(0);
  trackById = (_: number, u: User) => u.id;

  constructor() {
    this.loadMore();
  }

  onScroll(): void {
    const end = this.viewport().getRenderedRange().end;
    const total = this.viewport().getDataLength();
    // 描いている範囲の終わりが、末尾から10件以内に来たら次を読む
    if (end >= total - 10) this.loadMore();
  }

  private async loadMore(): Promise<void> {
    if (this.loading() || this.users().length >= TOTAL) return;
    this.loading.set(true);
    await new Promise((r) => setTimeout(r, 300)); // API の代わり(300ms 待つ)
    this.users.update((list) => [...list, ...makeUsers(PAGE, list.length)]);
    this.pages.update((n) => n + 1);
    this.loading.set(false);
  }
}

一番下までのスクロールを30回くり返し、表示の変化を記録しました。

50 件 / 読み込み 1 回
100 件 / 読み込み 2 回
150 件 / 読み込み 3 回
…
950 件 / 読み込み 19 回
1000 件 / 読み込み 20 回
rendered rows: 13

1,000件まで、50件ずつ20回読み足され、最後まで DOM の行は13行でした。 読み込み中は loading で二重の読み込みを止め、全件読んだら止めています。実際の API では、HttpClient の結果を users.update で後ろに足します。

ポイントは2つです。

  • (scrolledIndexChange) は「先頭の行の番号」が変わったときに出ます。末尾の判定には getRenderedRange().end と getDataLength() を使います
  • 仮想スクロールを使わない無限スクロール(IntersectionObserver で末尾の要素を見張る方法)では、読み足すほど DOM の行が増えます。件数が多くなる画面では、この記事の形にすると行の数が増えません

IntersectionObserver で作る方法は、関連記事「IntersectionObserver使い方|無限スクロール・遅延読込レシピ集」で扱っています。

よくある質問

Q. *cdkVirtualFor の代わりに @for を使えますか。 A. 仮想スクロールの行の差し替えは *cdkVirtualFor が行っています。cdk-virtual-scroll-viewport の中に @for で1万件を書いて試すと、.row は 10,000 行すべて描かれ、仮想スクロールになりませんでした(エラーも出ません)。ScrollingModule(または CdkVirtualForOf)を imports に入れて、*cdkVirtualFor で書きます。

Q. ページ全体(ウィンドウ)のスクロールで仮想スクロールしたいです。 A. ドキュメントによると、<cdk-virtual-scroll-viewport scrollWindow> でウィンドウのスクロールを使えます。親の要素でスクロールさせるときは、親に cdkVirtualScrollingElement を付けます。

Q. ブラウザーの「ページ内検索」で、見えていない行が見つかりません。 A. 仮想スクロールでは、見えていない行は DOM にありません。ページ内検索やスクリーンリーダーで全件を読む使い方には向きません。アプリの中に検索欄を用意して、データを絞り込む形にします。

Q. 何件くらいから仮想スクロールにするのがよいですか。 A. この記事の1万件では、表示が 1,359 ms → 585 ms、並べ替えが 1,136 ms → 17.9 ms でした。件数だけでなく、1行の重さと、データが変わる回数で決まります。まず通常の @for で作り、Chrome DevTools の Performance パネルで描き直しが長いと分かった画面に使うと、手間に見合います。

まとめ

  • <cdk-virtual-scroll-viewport itemSize="…"> で囲み、*cdkVirtualFor で回す。ビューポートに高さが無いと何も見えない
  • 1万件で、DOM の行は 10,000 → 15、要素数は 50,022 → 100
  • 一番の差はデータが変わったときの描き直し(並べ替え 1,136 ms → 17.9 ms)
  • 高さが違う行に itemSize を使うと、200回中100回、最大 365px 行が飛んだ。autosize はずれ0回だが実験的で、scrollToIndex は使えない
  • mat-table は viewport で囲むだけで仮想スクロールになり、sticky の見出しも残った
  • 無限スクロールは (scrolledIndexChange) + getRenderedRange().end で末尾を判定し、読み足しても行は13行のままだった

参考・出典

確認日はいずれも 2026年10月13日です。

  • Angular CDK「Scrolling」:https://material.angular.dev/cdk/scrolling/overview
  • Angular CDK「Scrolling」API:https://material.angular.dev/cdk/scrolling/api
  • angular/components の Scrolling ドキュメントのソース(22.2.x):https://github.com/angular/components/blob/22.2.x/src/cdk/scrolling/scrolling.md
  • Angular Material「Table」(Virtual scrolling の節):https://material.angular.dev/components/table/overview
  • mat-table の仮想スクロールの例(22.2.x):https://github.com/angular/components/tree/22.2.x/src/components-examples/material/table/table-virtual-scroll
  • @angular/cdk-experimental の autosize(ソース):https://github.com/angular/components/tree/22.2.x/src/cdk-experimental/scrolling

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angular CDK仮想スクロールの使い方|1万件の一覧を軽く表示」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?