メインコンテンツまでスキップ

Angular Material & CDK

Angular Material は Google の Material Design に基づいた公式 UI コンポーネントライブラリです。CDK(Component Dev Kit)は、カスタムコンポーネント構築のための低レベルプリミティブを提供します。エンタープライズ開発において統一的な UI を構築する際の強力な基盤です。

1. Angular Material の導入​

ng add による導入​

Angular Material は Angular チームが公式にメンテナンスしており、Angular 本体とメジャーバージョンが揃っています。ng add を使うと、パッケージのインストールからテーマ設定までを対話的に行えます。

ng add @angular/material

実行時に次の項目が尋ねられます。

質問内容
テーマの選択プリビルトテーマ(Azure/Blue、Rose/Red など)またはカスタム
グローバル typographymat-typography クラスと Roboto フォントを適用するか
アニメーションアニメーション機能を有効にするか(バージョンにより項目が異なる)

ng add は @angular/cdk の追加、angular.json へのスタイル登録、index.html への Roboto と Material Icons の読み込みを自動で行います。

アニメーションについて​

以前は BrowserAnimationsModule(または provideAnimations())が必須でしたが、近年の Angular Material ではコンポーネントが CSS ベースのアニメーションへ移行しており、必須ではなくなりつつあります。使用中のバージョンのリリースノートを確認してください。旧バージョンで必要な場合は app.config.ts に登録します。

app.config.ts
import type { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
// 旧バージョンの Material でアニメーションが必要な場合のみ
provideAnimationsAsync(),
],
};

個別コンポーネントのインポート(スタンドアロン)​

現在の Angular Material のコンポーネントはすべてスタンドアロンです。使うコンポーネントだけを、利用側コンポーネントの imports に直接列挙します。NgModule は不要です。

user-form.component.ts
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatIconModule } from '@angular/material/icon';

@Component({
selector: 'app-user-form',
standalone: true,
imports: [MatButtonModule, MatFormFieldModule, MatInputModule, MatIconModule],
template: `
<mat-form-field appearance="outline">
<mat-label>氏名</mat-label>
<input matInput />
</mat-form-field>
<button mat-flat-button>
<mat-icon>save</mat-icon>
保存
</button>
`,
})
export class UserFormComponent {}

使用したエントリポイント(@angular/material/button など)だけがバンドルされるため、Tree Shaking と相性が良い構成です。

React: MUI との比較​

観点Angular MaterialMUI (Material UI)
提供元Angular チーム(公式)コミュニティ・企業(MUI 社)
バージョン追従Angular 本体と同時にメジャー更新React とは独立
スタイリングSCSS + CSS 変数(M3 トークン)Emotion / styled-system
低レベル部品CDK(同一リポジトリ)Base UI など別パッケージ
高機能グリッドなし(MatTable まで)有償の MUI X Data Grid

React 側は選択肢が多く自由度が高い反面、ライブラリ選定が必要です。Angular では「公式がある」ことが、長期保守やチーム標準化の面で利点になります。

2. よく使うコンポーネント​

業務アプリで頻出するコンポーネントを用途別に整理します。いずれも @angular/material/<name> からインポートします。

分類コンポーネント用途
ナビゲーションMatToolbarヘッダーバー
MatSidenavサイドナビ・ドロワー
MatMenuドロップダウンメニュー
MatTabsタブ切り替え
フォームMatFormField / MatInputラベル・エラー表示付き入力欄
MatSelectセレクトボックス
MatDatepicker日付選択(日付アダプターが必要)
MatAutocomplete入力補完
データ表示MatTable / MatPaginator / MatSort表・ページング・ソート
フィードバックMatSnackBar一時的な通知
MatDialogモーダルダイアログ
MatProgressSpinner処理中表示
ボタン・アイコンMatButton / MatIcon / MatChipsボタン・アイコン・タグ表示

レイアウトの例(Toolbar + Sidenav + Menu)​

app-shell.component.html
<mat-toolbar color="primary">
<button mat-icon-button (click)="drawer.toggle()" aria-label="メニューを開閉">
<mat-icon>menu</mat-icon>
</button>
<span>業務システム</span>
<span class="spacer"></span>
<button mat-icon-button [matMenuTriggerFor]="userMenu" aria-label="ユーザーメニュー">
<mat-icon>account_circle</mat-icon>
</button>
<mat-menu #userMenu="matMenu">
<button mat-menu-item (click)="logout()">ログアウト</button>
</mat-menu>
</mat-toolbar>

<mat-sidenav-container>
<mat-sidenav #drawer mode="side" opened>
<mat-nav-list>
<a mat-list-item routerLink="/orders" routerLinkActive="active">注文一覧</a>
<a mat-list-item routerLink="/customers">顧客一覧</a>
</mat-nav-list>
</mat-sidenav>
<mat-sidenav-content>
<router-outlet />
</mat-sidenav-content>
</mat-sidenav-container>

フォームの例(リアクティブフォームとの連携)​

MatFormField は Reactive Forms の FormControl と組み合わせると、バリデーションエラーを mat-error で宣言的に表示できます。

<mat-form-field appearance="outline">
<mat-label>メールアドレス</mat-label>
<input matInput type="email" [formControl]="email" />
@if (email.hasError('required')) {
<mat-error>必須項目です</mat-error>
} @else if (email.hasError('email')) {
<mat-error>形式が正しくありません</mat-error>
}
</mat-form-field>

Dialog と SnackBar​

MatDialog と MatSnackBar はサービスとして inject して使います。ダイアログ内のコンポーネントには MAT_DIALOG_DATA でデータを渡します。

import { Component, inject } from '@angular/core';
import { MatDialog, MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';

@Component({
selector: 'app-confirm-dialog',
standalone: true,
imports: [MatDialogModule],
template: `
<h2 mat-dialog-title>削除の確認</h2>
<mat-dialog-content>{{ data.name }} を削除しますか?</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close>キャンセル</button>
<button mat-flat-button [mat-dialog-close]="true">削除</button>
</mat-dialog-actions>
`,
})
export class ConfirmDialogComponent {
data = inject<{ name: string }>(MAT_DIALOG_DATA);
}

@Component({ selector: 'app-order-list', standalone: true, template: '' })
export class OrderListComponent {
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);

remove(name: string): void {
this.dialog
.open(ConfirmDialogComponent, { data: { name }, width: '400px' })
.afterClosed()
.subscribe((ok) => {
if (ok) {
this.snackBar.open('削除しました', '閉じる', { duration: 3000 });
}
});
}
}

3. テーマのカスタマイズ​

M3(Material 3)テーマシステム​

Angular Material は v18 以降、Material 3(M3)のデザイントークンに対応しています。テーマは Sass の mixin で定義し、実体は CSS カスタムプロパティ(システム変数)として出力されます。そのため実行時の切り替えやオーバーライドが容易です。

src/styles.scss
@use '@angular/material' as mat;

html {
color-scheme: light dark;

@include mat.theme(
(
color: mat.$azure-palette,
typography: Roboto,
density: 0,
)
);
}

mat.theme mixin は色・タイポグラフィ・密度をまとめて設定します。旧来の mat.define-light-theme と mat.all-component-themes を使う方式(M2)も、使用バージョンによっては引き続き動作しますが、新規開発では M3 方式が推奨されます。API は更新が早いため、導入時に使用バージョンの公式ガイドを確認してください。

ライト/ダークモードの切り替え​

color-scheme プロパティを使うと、CSS の light-dark() 関数を通じて OS 設定に追従できます。アプリ側のトグルで切り替える場合は、color-scheme をクラスで制御します。

html {
color-scheme: light;
@include mat.theme((color: mat.$azure-palette, typography: Roboto, density: 0));

&.dark-theme {
color-scheme: dark;
}
}
theme.service.ts
import { DOCUMENT } from '@angular/common';
import { Injectable, inject, signal, effect } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class ThemeService {
private document = inject(DOCUMENT);
readonly dark = signal(false);

constructor() {
effect(() => {
this.document.documentElement.classList.toggle('dark-theme', this.dark());
});
}

toggle(): void {
this.dark.update((v) => !v);
}
}

コンポーネント単位のオーバーライド​

M3 では各コンポーネントが --mat-<component>-<token> 形式の CSS 変数を公開しており、これを上書きすることでスタイルを調整できます。内部クラスを直接上書きする方法より、バージョン更新に強い手段です。

// 特定コンポーネントだけ角丸を変える
.rounded-button {
@include mat.button-overrides(
(
filled-container-shape: 4px,
)
);
}

// CSS 変数を直接上書きする
.compact-table {
--mat-table-row-item-container-height: 40px;
}

利用可能なトークン名はバージョンで変わりうるため、各コンポーネントのドキュメントにある Theming の節で確認します。

4. MatTable(データテーブル)​

エンタープライズで最もよく使うコンポーネントの一つです。ソート・ページング・フィルタリングを MatTableDataSource で簡単に実現できます。

基本構成​

テーブルは dataSource(データ)と displayedColumns(表示列)を指定し、各列を matColumnDef で定義します。

order-table.component.ts
import { AfterViewInit, Component, ViewChild } from '@angular/core';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatInputModule } from '@angular/material/input';

interface Order {
id: number;
customer: string;
amount: number;
}

@Component({
selector: 'app-order-table',
standalone: true,
imports: [MatTableModule, MatPaginatorModule, MatSortModule, MatInputModule],
templateUrl: './order-table.component.html',
})
export class OrderTableComponent implements AfterViewInit {
displayedColumns = ['id', 'customer', 'amount'];
dataSource = new MatTableDataSource<Order>([
{ id: 1, customer: 'A商事', amount: 120000 },
{ id: 2, customer: 'B工業', amount: 80000 },
]);

@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;

ngAfterViewInit(): void {
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
}

applyFilter(event: Event): void {
const value = (event.target as HTMLInputElement).value;
this.dataSource.filter = value.trim().toLowerCase();
}
}
order-table.component.html
<mat-form-field>
<mat-label>絞り込み</mat-label>
<input matInput (keyup)="applyFilter($event)" />
</mat-form-field>

<table mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef mat-sort-header>ID</th>
<td mat-cell *matCellDef="let row">{{ row.id }}</td>
</ng-container>

<ng-container matColumnDef="customer">
<th mat-header-cell *matHeaderCellDef mat-sort-header>顧客</th>
<td mat-cell *matCellDef="let row">{{ row.customer }}</td>
</ng-container>

<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef mat-sort-header>金額</th>
<td mat-cell *matCellDef="let row">{{ row.amount | number }}</td>
</ng-container>

<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>

<tr class="mat-row" *matNoDataRow>
<td class="mat-cell" [attr.colspan]="displayedColumns.length">該当データがありません</td>
</tr>
</table>

<mat-paginator [pageSizeOptions]="[10, 25, 50]" showFirstLastButtons />

サーバーサイドのデータソース​

データ件数が多い場合は、ソート・ページング・フィルタをサーバー側で処理します。MatSort と MatPaginator のイベントを統合し、変化のたびに API を呼び出します。

import { AfterViewInit, Component, ViewChild, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { merge, startWith, switchMap, map } from 'rxjs';

interface Order { id: number; customer: string; amount: number }
interface Page { items: Order[]; total: number }

@Component({ selector: 'app-remote-table', standalone: true, template: '' })
export class RemoteTableComponent implements AfterViewInit {
private http = inject(HttpClient);
@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;

rows = signal<Order[]>([]);
total = signal(0);

ngAfterViewInit(): void {
// ソート変更時はページを先頭に戻す
this.sort.sortChange.subscribe(() => (this.paginator.pageIndex = 0));

merge(this.sort.sortChange, this.paginator.page)
.pipe(
startWith({}),
switchMap(() =>
this.http.get<Page>('/api/orders', {
params: {
sort: this.sort.active ?? '',
dir: this.sort.direction,
page: this.paginator.pageIndex,
size: this.paginator.pageSize,
},
}),
),
map((res) => res),
)
.subscribe((res) => {
this.rows.set(res.items);
this.total.set(res.total);
});
}
}

テンプレート側では [dataSource]="rows()" と [length]="total()" を渡し、MatTableDataSource は使わずに生の配列を表示します。switchMap により、古いリクエストの結果が新しいものを上書きする問題を避けられます。

行の選択と操作​

複数行の選択には CDK の SelectionModel を使います。

import { SelectionModel } from '@angular/cdk/collections';

selection = new SelectionModel<Order>(true, []);

isAllSelected(): boolean {
return this.selection.selected.length === this.dataSource.data.length;
}

toggleAll(): void {
if (this.isAllSelected()) {
this.selection.clear();
} else {
this.selection.select(...this.dataSource.data);
}
}
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox (change)="toggleAll()" [checked]="isAllSelected()" />
</th>
<td mat-cell *matCellDef="let row">
<mat-checkbox (click)="$event.stopPropagation()"
(change)="selection.toggle(row)"
[checked]="selection.isSelected(row)" />
</td>
</ng-container>

行ごとの操作ボタンは、専用の列に mat-icon-button と MatMenu を置く構成が一般的です。

5. CDK(Component Dev Kit)​

CDK は UI ライブラリに依存しない、振る舞いのプリミティブ集です。Angular Material 自体も CDK の上に構築されています。見た目(Material Design)を持たず、ドラッグ、オーバーレイ、フォーカス管理といったロジックだけを提供します。React でいえば Radix UI や React Aria のような「ヘッドレス UI」に近い位置付けです。

よく使う CDK モジュール​

モジュール用途
@angular/cdk/drag-dropドラッグ&ドロップ、並べ替え
@angular/cdk/overlayオーバーレイ、ポップアップ、ツールチップの土台
@angular/cdk/scrolling仮想スクロール(大量データ表示)
@angular/cdk/a11yフォーカス管理、スクリーンリーダー通知
@angular/cdk/layoutブレークポイント検出
@angular/cdk/clipboardクリップボード操作
@angular/cdk/portal動的コンテンツの挿入

drag-drop​

リストの並べ替えは、cdkDropList と cdkDrag を使うだけで実装できます。

import { CdkDragDrop, CdkDrag, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';

@Component({
selector: 'app-task-list',
standalone: true,
imports: [CdkDropList, CdkDrag],
template: `
<div cdkDropList (cdkDropListDropped)="drop($event)">
@for (task of tasks; track task) {
<div cdkDrag class="item">{{ task }}</div>
}
</div>
`,
})
export class TaskListComponent {
tasks = ['要件定義', '設計', '実装', 'テスト'];

drop(event: CdkDragDrop<string[]>): void {
moveItemInArray(this.tasks, event.previousIndex, event.currentIndex);
}
}

リスト間の移動には transferArrayItem と cdkDropListConnectedTo を使い、カンバン風の UI も構築できます。

overlay​

Overlay サービスは、画面最前面に要素を表示する仕組みを提供します。ドロップダウン、ポップオーバー、独自のダイアログなどの基盤です。positionStrategy で基準要素に対する位置、scrollStrategy でスクロール時の挙動を指定します。

import { Overlay, OverlayModule } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';

private overlay = inject(Overlay);

open(origin: HTMLElement): void {
const positionStrategy = this.overlay
.position()
.flexibleConnectedTo(origin)
.withPositions([
{ originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top' },
]);

const ref = this.overlay.create({
positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.reposition(),
hasBackdrop: true,
backdropClass: 'cdk-overlay-transparent-backdrop',
});

ref.attach(new ComponentPortal(PopupComponent));
ref.backdropClick().subscribe(() => ref.dispose());
}

virtual-scrolling​

数千件以上のリストを表示する場合、画面に見える範囲の要素だけを DOM に生成することで、描画負荷を大きく減らせます。

<cdk-virtual-scroll-viewport itemSize="48" class="viewport">
<div *cdkVirtualFor="let item of items" class="row">{{ item.name }}</div>
</cdk-virtual-scroll-viewport>
.viewport {
height: 480px; // 高さの指定が必須
}

各行の高さが固定(itemSize)であることが前提です。可変高さが必要な場合は、autosize 戦略を使うか、別のライブラリを検討します。React の react-window / react-virtualized に相当します。

layout(BreakpointObserver)​

画面幅に応じた出し分けをテンプレートではなくコードで制御できます。

import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';

export class ShellComponent {
private bp = inject(BreakpointObserver);
isHandset = toSignal(
this.bp.observe(Breakpoints.Handset).pipe(map((r) => r.matches)),
{ initialValue: false },
);
}
<mat-sidenav [mode]="isHandset() ? 'over' : 'side'" [opened]="!isHandset()" />

clipboard と portal​

<button [cdkCopyToClipboard]="apiKey" (cdkCopyToClipboardCopied)="onCopied($event)">
コピー
</button>

portal は、「コンテンツ(Portal)」と「それを表示する場所(PortalOutlet)」を分離する仕組みです。Overlay や Dialog も内部で使っています。ComponentPortal や TemplatePortal を使うと、アプリの別の位置にコンポーネントやテンプレートを動的に描画できます。React の createPortal に近い概念です。

CDK のみで独自デザインシステムを構築する​

自社のデザインシステムが Material Design と異なる場合は、Angular Material を使わず CDK だけを採用する選択肢があります。

  • 見た目は自社の SCSS / CSS 変数で実装し、振る舞い(フォーカストラップ、キーボード操作、位置決め、仮想化)は CDK に任せる。
  • 例えば、自社のドロップダウンを cdk/overlay と cdk/a11y の ListKeyManager で組み立てる。
  • メニュー、リストボックス、タブなどの汎用部品は @angular/cdk/menu、@angular/cdk/listbox にも用意されている。

アクセシビリティの実装を一から行う負担を避けつつ、デザインの自由度を確保できる点が利点です。

6. アクセシビリティ (a11y)​

エンタープライズ要件として、WCAG 2.1(多くの組織では AA レベル)への準拠が求められる場面は少なくありません。Angular Material と CDK は、その実装を下支えします。

Angular Material の標準サポート​

  • 各コンポーネントには、適切な role、aria-* 属性が自動で付与される(例: MatSelect は role="combobox"、MatDialog は role="dialog" と aria-modal)。
  • キーボード操作(矢印キー、Enter、Esc、Tab)が標準でサポートされている。
  • ダイアログを閉じると、開く前にフォーカスされていた要素へ自動でフォーカスが戻る。

ただし、自動で満たされるのは部品単位の対応までです。アイコンのみのボタンへの aria-label 付与、フォームの mat-label、適切な色コントラストなどは開発者の責任です。

FocusTrap と FocusMonitor​

cdkTrapFocus は、モーダル内でフォーカスが外に出ないように閉じ込めます。FocusMonitor は、フォーカスの原因(キーボード、マウス、タッチ、プログラム)を判別できます。

<div cdkTrapFocus [cdkTrapFocusAutoCapture]="true" class="modal">
<input />
<button>閉じる</button>
</div>
import { FocusMonitor } from '@angular/cdk/a11y';

private focusMonitor = inject(FocusMonitor);

ngAfterViewInit(): void {
this.focusMonitor.monitor(this.el.nativeElement, true).subscribe((origin) => {
// origin: 'keyboard' | 'mouse' | 'touch' | 'program' | null
console.log('focus origin:', origin);
});
}

origin が keyboard のときだけフォーカスリングを表示する、といった使い分けができます。

LiveAnnouncer(スクリーンリーダー対応)​

画面上の非同期な変化(保存完了、検索結果件数の更新など)は、視覚的にしか伝わらないとスクリーンリーダー利用者に届きません。LiveAnnouncer は ARIA ライブリージョンを使って読み上げを行います。

import { LiveAnnouncer } from '@angular/cdk/a11y';

private announcer = inject(LiveAnnouncer);

onSearchDone(count: number): void {
this.announcer.announce(`${count} 件の結果が見つかりました`, 'polite');
}

緊急の通知には assertive、通常は polite を使います。

キーボードナビゲーション​

独自のリスト型コンポーネントでは、ListKeyManager 系のクラス(ActiveDescendantKeyManager、FocusKeyManager)で矢印キーによる移動を実装できます。

import { FocusKeyManager } from '@angular/cdk/a11y';

@ViewChildren(MyItemComponent) items!: QueryList<MyItemComponent>;
private keyManager!: FocusKeyManager<MyItemComponent>;

ngAfterViewInit(): void {
this.keyManager = new FocusKeyManager(this.items).withWrap().withHomeAndEnd();
}

onKeydown(event: KeyboardEvent): void {
this.keyManager.onKeydown(event);
}

WCAG 2.1 対応の実務ポイント​

観点対応
知覚可能色コントラスト比 4.5:1 以上、代替テキスト、色のみに頼らない表現
操作可能すべての機能がキーボードで操作可能、フォーカスの可視化
理解可能フォームのラベルとエラーメッセージの明示
堅牢正しいセマンティクス、ARIA の適切な使用

自動テストには axe-core(@axe-core/playwright など)を CI に組み込み、手動でのスクリーンリーダー確認(NVDA、VoiceOver)と併用することが推奨されます。

7. 他の UI ライブラリとの比較​

ライブラリ特徴向いているケース
Angular Material公式、Material Design、CDK と一体標準的な業務 UI、長期保守、a11y 重視
PrimeNGコンポーネント数が非常に多い、テーマが豊富、エンタープライズ向け多機能な部品を素早く揃えたい
NG-ZORROAnt Design ベース、管理画面向け部品が充実Ant Design のデザインを採用したい
DevExtreme高機能なデータグリッド・チャート(商用ライセンス)集計、グルーピング、Excel 出力など高度なグリッド要件
Taiga UIモダンな設計、軽量、TypeScript の型が強い新規で現代的な見た目と柔軟性を求める

選択基準​

  1. 要件の性質: 標準的な CRUD 画面なら Angular Material で十分なことが多い。高度なグリッド(ピボット、仮想化、行グルーピング)が必須なら DevExtreme や AG Grid を検討する。
  2. デザイン方針: 自社デザインシステムがあるなら CDK、または Tailwind などとヘッドレス系ライブラリの組み合わせが適する。
  3. 保守性とライセンス: 公式であること、Angular の更新への追従速度、商用ライセンスの費用を確認する。
  4. アクセシビリティ: WCAG 準拠が必須の場合、各ライブラリの a11y 対応状況を実際に検証する。
  5. 混在のリスク: 複数のライブラリを併用するとスタイルの競合とバンドル増加を招くため、基本は一つに絞る。

Angular Material を基盤にし、不足するグリッドだけを専用ライブラリで補う構成は、現実的で多く見られるパターンです。