Angular Material Overview
Estimated Time: 3 hours | Difficulty: Intermediate | Prerequisites: Components, Forms, Accessibility
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Angular Material Architecture │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Your Application │ │
│ └───────────────────────────┬─────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┴─────────────────────────────────────┐ │
│ │ Angular Material │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │ │
│ │ │ Buttons │ │ Forms │ │ Tables │ │ Dialogs │ │ │
│ │ │ Cards │ │ Inputs │ │ Lists │ │ Snackbar │ │ │
│ │ │ Chips │ │ Selects │ │ Trees │ │ Tooltips │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └───────────┘ │ │
│ └───────────────────────────┬─────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┴─────────────────────────────────────┐ │
│ │ Component Dev Kit (CDK) │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │ │
│ │ │ Overlay │ │ Portal │ │ Drag/Drop │ │ A11y │ │ │
│ │ │ Scrolling │ │ Platform │ │ Layout │ │ Stepper │ │ │
│ │ │ Clipboard │ │ Observers │ │ Table │ │ Tree │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └───────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Setup
Copy
# Add Angular Material
ng add @angular/material
# This will:
# 1. Install @angular/material, @angular/cdk, @angular/animations
# 2. Add theme to angular.json
# 3. Add global typography and fonts
# 4. Set up animations
Modern Configuration
Copy
// app.config.ts
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
export const appConfig: ApplicationConfig = {
providers: [
provideAnimationsAsync(),
// ... other providers
]
};
// styles.scss
@use '@angular/material' as mat;
// Include core styles
@include mat.core();
// Define custom theme
$primary: mat.m2-define-palette(mat.$m2-indigo-palette);
$accent: mat.m2-define-palette(mat.$m2-pink-palette, A200, A100, A400);
$warn: mat.m2-define-palette(mat.$m2-red-palette);
$theme: mat.m2-define-light-theme((
color: (
primary: $primary,
accent: $accent,
warn: $warn,
),
typography: mat.m2-define-typography-config(),
density: 0,
));
// Apply theme
@include mat.all-component-themes($theme);
// Dark theme
.dark-theme {
$dark-theme: mat.m2-define-dark-theme((
color: (
primary: $primary,
accent: $accent,
warn: $warn,
),
));
@include mat.all-component-colors($dark-theme);
}
Core Components
Buttons & Indicators
Copy
// button-showcase.component.ts
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatBadgeModule } from '@angular/material/badge';
@Component({
selector: 'app-button-showcase',
standalone: true,
imports: [
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatBadgeModule
],
template: `
<!-- Basic buttons -->
<button mat-button>Basic</button>
<button mat-raised-button color="primary">Raised</button>
<button mat-flat-button color="accent">Flat</button>
<button mat-stroked-button color="warn">Stroked</button>
<!-- Icon buttons -->
<button mat-icon-button aria-label="Settings">
<mat-icon>settings</mat-icon>
</button>
<button mat-fab color="primary" aria-label="Add">
<mat-icon>add</mat-icon>
</button>
<button mat-mini-fab color="accent" aria-label="Edit">
<mat-icon>edit</mat-icon>
</button>
<!-- Extended FAB -->
<button mat-fab extended color="primary">
<mat-icon>add</mat-icon>
Create New
</button>
<!-- Loading button -->
<button mat-raised-button color="primary" [disabled]="loading()">
@if (loading()) {
<mat-spinner diameter="20"></mat-spinner>
} @else {
Submit
}
</button>
<!-- Button with badge -->
<button mat-icon-button matBadge="5" matBadgeColor="warn">
<mat-icon>notifications</mat-icon>
</button>
`
})
export class ButtonShowcaseComponent {
loading = signal(false);
}
Form Controls
Copy
// form-controls.component.ts
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatRadioModule } from '@angular/material/radio';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSliderModule } from '@angular/material/slider';
@Component({
selector: 'app-form-controls',
standalone: true,
imports: [
ReactiveFormsModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatAutocompleteModule,
MatDatepickerModule,
MatNativeDateModule,
MatCheckboxModule,
MatRadioModule,
MatSlideToggleModule,
MatSliderModule
],
template: `
<form [formGroup]="form" class="form-container">
<!-- Text input with validation -->
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput formControlName="email" type="email" />
<mat-icon matSuffix>email</mat-icon>
<mat-hint>We'll never share your email</mat-hint>
@if (form.get('email')?.hasError('required')) {
<mat-error>Email is required</mat-error>
}
@if (form.get('email')?.hasError('email')) {
<mat-error>Invalid email format</mat-error>
}
</mat-form-field>
<!-- Select -->
<mat-form-field appearance="outline">
<mat-label>Country</mat-label>
<mat-select formControlName="country">
@for (country of countries; track country.code) {
<mat-option [value]="country.code">
{{ country.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
<!-- Autocomplete -->
<mat-form-field appearance="outline">
<mat-label>City</mat-label>
<input
matInput
formControlName="city"
[matAutocomplete]="auto"
/>
<mat-autocomplete #auto="matAutocomplete">
@for (city of filteredCities(); track city) {
<mat-option [value]="city">{{ city }}</mat-option>
}
</mat-autocomplete>
</mat-form-field>
<!-- Date picker -->
<mat-form-field appearance="outline">
<mat-label>Birth Date</mat-label>
<input matInput [matDatepicker]="picker" formControlName="birthDate" />
<mat-datepicker-toggle matSuffix [for]="picker" />
<mat-datepicker #picker />
</mat-form-field>
<!-- Checkbox -->
<mat-checkbox formControlName="terms">
I agree to the terms and conditions
</mat-checkbox>
<!-- Radio buttons -->
<mat-radio-group formControlName="gender">
<mat-radio-button value="male">Male</mat-radio-button>
<mat-radio-button value="female">Female</mat-radio-button>
<mat-radio-button value="other">Other</mat-radio-button>
</mat-radio-group>
<!-- Slide toggle -->
<mat-slide-toggle formControlName="notifications">
Enable notifications
</mat-slide-toggle>
<!-- Slider -->
<mat-slider min="0" max="100" step="1" showTickMarks discrete>
<input matSliderThumb formControlName="volume" />
</mat-slider>
</form>
`
})
export class FormControlsComponent {
form = inject(FormBuilder).group({
email: ['', [Validators.required, Validators.email]],
country: [''],
city: [''],
birthDate: [null],
terms: [false],
gender: [''],
notifications: [true],
volume: [50]
});
countries = [
{ code: 'us', name: 'United States' },
{ code: 'uk', name: 'United Kingdom' },
{ code: 'de', name: 'Germany' }
];
cities = ['New York', 'Los Angeles', 'Chicago', 'Houston'];
filteredCities = computed(() => {
const value = this.form.get('city')?.value?.toLowerCase() ?? '';
return this.cities.filter(city =>
city.toLowerCase().includes(value)
);
});
}
Data Table
Copy
// data-table.component.ts
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule, Sort } from '@angular/material/sort';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { SelectionModel } from '@angular/cdk/collections';
interface User {
id: number;
name: string;
email: string;
role: string;
status: 'active' | 'inactive';
}
@Component({
selector: 'app-data-table',
standalone: true,
imports: [
MatTableModule,
MatPaginatorModule,
MatSortModule,
MatCheckboxModule,
MatChipsModule,
MatIconModule,
MatButtonModule,
MatMenuModule
],
template: `
<div class="table-container">
<!-- Search and filters -->
<mat-form-field appearance="outline" class="search-field">
<mat-label>Search</mat-label>
<input matInput (keyup)="applyFilter($event)" #searchInput />
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
<table mat-table [dataSource]="dataSource" matSort>
<!-- Checkbox Column -->
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox
(change)="$event ? toggleAll() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()"
/>
</th>
<td mat-cell *matCellDef="let row">
<mat-checkbox
(click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)"
/>
</td>
</ng-container>
<!-- Name Column -->
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Name</th>
<td mat-cell *matCellDef="let user">{{ user.name }}</td>
</ng-container>
<!-- Email Column -->
<ng-container matColumnDef="email">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Email</th>
<td mat-cell *matCellDef="let user">{{ user.email }}</td>
</ng-container>
<!-- Role Column -->
<ng-container matColumnDef="role">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Role</th>
<td mat-cell *matCellDef="let user">
<mat-chip>{{ user.role }}</mat-chip>
</td>
</ng-container>
<!-- Status Column -->
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let user">
<mat-chip [class.active]="user.status === 'active'">
{{ user.status }}
</mat-chip>
</td>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let user">
<button mat-icon-button [matMenuTriggerFor]="menu">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #menu="matMenu">
<button mat-menu-item (click)="edit(user)">
<mat-icon>edit</mat-icon>
<span>Edit</span>
</button>
<button mat-menu-item (click)="delete(user)">
<mat-icon>delete</mat-icon>
<span>Delete</span>
</button>
</mat-menu>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
<!-- No data row -->
<tr class="mat-row" *matNoDataRow>
<td class="mat-cell" [attr.colspan]="displayedColumns.length">
No data matching "{{ searchInput.value }}"
</td>
</tr>
</table>
<mat-paginator
[pageSizeOptions]="[5, 10, 25, 100]"
showFirstLastButtons
/>
</div>
`
})
export class DataTableComponent implements AfterViewInit {
displayedColumns = ['select', 'name', 'email', 'role', 'status', 'actions'];
dataSource = new MatTableDataSource<User>();
selection = new SelectionModel<User>(true, []);
@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;
users = input.required<User[]>();
constructor() {
effect(() => {
this.dataSource.data = this.users();
});
}
ngAfterViewInit() {
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
}
applyFilter(event: Event) {
const filterValue = (event.target as HTMLInputElement).value;
this.dataSource.filter = filterValue.trim().toLowerCase();
if (this.dataSource.paginator) {
this.dataSource.paginator.firstPage();
}
}
isAllSelected(): boolean {
const numSelected = this.selection.selected.length;
const numRows = this.dataSource.data.length;
return numSelected === numRows;
}
toggleAll() {
this.isAllSelected()
? this.selection.clear()
: this.dataSource.data.forEach(row => this.selection.select(row));
}
edit(user: User) { /* ... */ }
delete(user: User) { /* ... */ }
}
Dialogs & Overlays
Copy
// dialog.service.ts
import { MatDialog, MatDialogConfig } from '@angular/material/dialog';
import { ComponentType } from '@angular/cdk/portal';
@Injectable({ providedIn: 'root' })
export class DialogService {
private dialog = inject(MatDialog);
open<T, R = any>(
component: ComponentType<T>,
config?: MatDialogConfig
): Observable<R | undefined> {
const dialogRef = this.dialog.open(component, {
width: '500px',
disableClose: false,
autoFocus: true,
...config
});
return dialogRef.afterClosed();
}
confirm(message: string, title = 'Confirm'): Observable<boolean> {
return this.open(ConfirmDialogComponent, {
data: { title, message },
width: '400px'
});
}
alert(message: string, title = 'Alert'): Observable<void> {
return this.open(AlertDialogComponent, {
data: { title, message },
width: '400px'
});
}
}
// confirm-dialog.component.ts
@Component({
selector: 'app-confirm-dialog',
standalone: true,
imports: [MatDialogModule, MatButtonModule],
template: `
<h2 mat-dialog-title>{{ data.title }}</h2>
<mat-dialog-content>
<p>{{ data.message }}</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button [mat-dialog-close]="false">Cancel</button>
<button mat-raised-button color="primary" [mat-dialog-close]="true">
Confirm
</button>
</mat-dialog-actions>
`
})
export class ConfirmDialogComponent {
data = inject(MAT_DIALOG_DATA);
}
// user-form-dialog.component.ts
@Component({
selector: 'app-user-form-dialog',
standalone: true,
imports: [
MatDialogModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
],
template: `
<h2 mat-dialog-title>
{{ data.user ? 'Edit User' : 'Create User' }}
</h2>
<form [formGroup]="form" (ngSubmit)="save()">
<mat-dialog-content>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Name</mat-label>
<input matInput formControlName="name" />
<mat-error>Name is required</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Email</mat-label>
<input matInput formControlName="email" type="email" />
<mat-error>Valid email is required</mat-error>
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" mat-dialog-close>Cancel</button>
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="form.invalid"
>
Save
</button>
</mat-dialog-actions>
</form>
`
})
export class UserFormDialogComponent {
private dialogRef = inject(MatDialogRef<UserFormDialogComponent>);
data = inject<{ user?: User }>(MAT_DIALOG_DATA);
form = inject(FormBuilder).group({
name: [this.data.user?.name ?? '', Validators.required],
email: [this.data.user?.email ?? '', [Validators.required, Validators.email]]
});
save() {
if (this.form.valid) {
this.dialogRef.close(this.form.value);
}
}
}
CDK Features
Drag and Drop
Copy
// kanban-board.component.ts
import { CdkDragDrop, CdkDropList, CdkDrag, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
@Component({
selector: 'app-kanban-board',
standalone: true,
imports: [CdkDropList, CdkDrag],
template: `
<div class="board" cdkDropListGroup>
@for (column of columns(); track column.id) {
<div class="column">
<h3>{{ column.name }}</h3>
<div
cdkDropList
[cdkDropListData]="column.tasks"
(cdkDropListDropped)="drop($event)"
class="task-list"
>
@for (task of column.tasks; track task.id) {
<div cdkDrag class="task-card">
<div class="drag-placeholder" *cdkDragPlaceholder></div>
{{ task.title }}
</div>
}
</div>
</div>
}
</div>
`,
styles: [`
.board {
display: flex;
gap: 16px;
}
.column {
width: 300px;
background: #f5f5f5;
border-radius: 8px;
padding: 16px;
}
.task-list {
min-height: 100px;
}
.task-card {
background: white;
padding: 16px;
border-radius: 4px;
margin-bottom: 8px;
cursor: move;
}
.cdk-drag-preview {
box-shadow: 0 5px 20px rgba(0,0,0,0.2);
}
.cdk-drag-placeholder {
background: #e0e0e0;
border: 2px dashed #999;
border-radius: 4px;
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
`]
})
export class KanbanBoardComponent {
columns = input.required<Column[]>();
taskMoved = output<{ task: Task; fromColumn: string; toColumn: string }>();
drop(event: CdkDragDrop<Task[]>) {
if (event.previousContainer === event.container) {
moveItemInArray(
event.container.data,
event.previousIndex,
event.currentIndex
);
} else {
transferArrayItem(
event.previousContainer.data,
event.container.data,
event.previousIndex,
event.currentIndex
);
this.taskMoved.emit({
task: event.container.data[event.currentIndex],
fromColumn: event.previousContainer.id,
toColumn: event.container.id
});
}
}
}
Virtual Scrolling
Copy
// virtual-list.component.ts
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
selector: 'app-virtual-list',
standalone: true,
imports: [ScrollingModule],
template: `
<cdk-virtual-scroll-viewport
itemSize="50"
class="viewport"
(scrolledIndexChange)="onScroll($event)"
>
<div
*cdkVirtualFor="let item of items();
let index = index;
trackBy: trackById"
class="item"
>
<span>{{ index + 1 }}.</span>
<span>{{ item.name }}</span>
<span>{{ item.email }}</span>
</div>
</cdk-virtual-scroll-viewport>
`,
styles: [`
.viewport {
height: 400px;
width: 100%;
}
.item {
height: 50px;
display: flex;
align-items: center;
gap: 16px;
padding: 0 16px;
border-bottom: 1px solid #eee;
}
`]
})
export class VirtualListComponent {
items = input.required<Item[]>();
scrolled = output<number>();
trackById(index: number, item: Item) {
return item.id;
}
onScroll(index: number) {
this.scrolled.emit(index);
}
}
Overlay & Portal
Copy
// dropdown.component.ts
import { Overlay, OverlayRef, OverlayConfig } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
@Component({
selector: 'app-dropdown-trigger',
template: `
<button
#trigger
(click)="toggle()"
[attr.aria-expanded]="isOpen()"
>
{{ label() }}
<mat-icon>arrow_drop_down</mat-icon>
</button>
`
})
export class DropdownTriggerComponent {
private overlay = inject(Overlay);
private viewContainerRef = inject(ViewContainerRef);
@ViewChild('trigger') triggerRef!: ElementRef;
label = input('Select');
content = input.required<Type<any>>();
private overlayRef: OverlayRef | null = null;
isOpen = signal(false);
toggle() {
this.isOpen() ? this.close() : this.open();
}
open() {
const positionStrategy = this.overlay
.position()
.flexibleConnectedTo(this.triggerRef)
.withPositions([
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: 4
},
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetY: -4
}
]);
const config: OverlayConfig = {
positionStrategy,
hasBackdrop: true,
backdropClass: 'cdk-overlay-transparent-backdrop',
scrollStrategy: this.overlay.scrollStrategies.reposition()
};
this.overlayRef = this.overlay.create(config);
const portal = new ComponentPortal(this.content(), this.viewContainerRef);
this.overlayRef.attach(portal);
this.overlayRef.backdropClick().subscribe(() => this.close());
this.isOpen.set(true);
}
close() {
this.overlayRef?.dispose();
this.overlayRef = null;
this.isOpen.set(false);
}
}
Theming
Copy
// custom-theme.scss
@use '@angular/material' as mat;
@use 'sass:map';
// Custom palette
$custom-primary: (
50: #e3f2fd,
100: #bbdefb,
200: #90caf9,
300: #64b5f6,
400: #42a5f5,
500: #2196f3,
600: #1e88e5,
700: #1976d2,
800: #1565c0,
900: #0d47a1,
contrast: (
50: rgba(black, 0.87),
100: rgba(black, 0.87),
200: rgba(black, 0.87),
300: rgba(black, 0.87),
400: rgba(black, 0.87),
500: white,
600: white,
700: white,
800: white,
900: white,
)
);
$my-primary: mat.m2-define-palette($custom-primary);
$my-accent: mat.m2-define-palette(mat.$m2-amber-palette, A200, A100, A400);
$my-theme: mat.m2-define-light-theme((
color: (
primary: $my-primary,
accent: $my-accent,
),
typography: mat.m2-define-typography-config(
$font-family: 'Inter, sans-serif',
$headline-1: mat.m2-define-typography-level(96px, 1.2, 300),
$body-1: mat.m2-define-typography-level(16px, 1.5, 400),
),
density: -1,
));
@include mat.all-component-themes($my-theme);
// Component-specific overrides
.mat-mdc-button {
border-radius: 8px;
}
.mat-mdc-card {
border-radius: 12px;
}
Best Practices
Import Only What You Need
Import individual modules to minimize bundle size
Use CDK for Custom Components
Build on CDK primitives instead of from scratch
Follow a11y Guidelines
Material components are accessible by default - don’t break them
Customize via Theming
Use Sass theming instead of CSS overrides
Next: PWA & Service Workers
Build offline-capable Progressive Web Apps with Angular