> ## Documentation Index
> Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 21. Angular Material & CDK

> Build professional UIs with Angular Material components and CDK primitives

<Frame>
  <img src="https://mintcdn.com/devweeekends/AEOaWh79Ur7CdHHv/images/courses/angular-crash-course/angular-hero.svg?fit=max&auto=format&n=AEOaWh79Ur7CdHHv&q=85&s=32645ae19fa9bc25d3ec281022aba371" alt="Angular Material" width="1200" height="400" data-path="images/courses/angular-crash-course/angular-hero.svg" />
</Frame>

## Angular Material Overview

<Info>
  **Estimated Time**: 3 hours | **Difficulty**: Intermediate | **Prerequisites**: Components, Forms, Accessibility
</Info>

Angular Material is a comprehensive UI component library that implements Google's Material Design. The Component Dev Kit (CDK) provides behavior primitives for building custom components.

Think of Angular Material as two distinct layers. The **CDK** is like a chassis -- it provides the mechanical behavior (drag-and-drop physics, focus management, scroll virtualization, overlay positioning) without any visual opinion. **Angular Material** is the body that sits on top -- it adds Material Design styling to those behaviors. You can use the CDK without Material if you want custom-branded components that still have professional-grade behavior like keyboard navigation and screen reader support.

<Tip>
  **Practical decision**: If your company has its own design system, use the CDK directly and skip Angular Material's visual layer. If you need to ship fast and Material Design is acceptable, use Angular Material components as-is and customize via theming. Trying to make Material components look like a completely different design system (e.g., overriding 50+ CSS rules per component) is almost always more work than building on CDK from scratch.
</Tip>

```
┌─────────────────────────────────────────────────────────────────────────┐
│                    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

```bash theme={null}
# Add Angular Material -- the ng add schematic does the tedious setup
# that would otherwise take 15 minutes of manual configuration
ng add @angular/material

# This will:
# 1. Install @angular/material, @angular/cdk, @angular/animations
# 2. Add a pre-built theme to angular.json (you can swap later)
# 3. Add global typography and Roboto font imports
# 4. Set up animations provider (required for most Material components)
```

<Warning>
  **Common gotcha**: If you skip `ng add` and just `npm install @angular/material`, you will miss the theme setup, font imports, and animation provider. Your components will render but look completely unstyled -- no colors, no elevation, no transitions. Always use `ng add` for the initial setup.
</Warning>

### Modern Configuration

```typescript theme={null}
// 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

Material provides several button variants, each designed for a specific level of visual emphasis. Think of it like typography: `mat-button` is body text (low emphasis), `mat-raised-button` is a subheading (medium emphasis), and `mat-fab` is a headline (high emphasis). Picking the right variant is not just aesthetics -- it guides the user's eye to the most important action on screen.

```typescript theme={null}
// 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

Material's form fields are more than styled inputs -- they handle the entire lifecycle of a form control: floating labels, hint text, error messages, prefix/suffix icons, and character counts. The `mat-form-field` wrapper is what ties all of these together. One important design choice: always use `appearance="outline"` for forms where users enter data (it provides the clearest affordance), and reserve `appearance="fill"` for filter bars or search inputs where density matters more.

```typescript theme={null}
// 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

`MatTable` is one of Material's most powerful components, but also one of its most misunderstood. The key insight: `MatTableDataSource` is not just a wrapper around an array -- it provides built-in filtering, sorting, and pagination that work out of the box when you wire up the corresponding directives. For most CRUD tables, you will never need to write custom filter or sort logic.

<Tip>
  **Practical tip**: Use `MatTableDataSource` for tables with fewer than 1,000 rows. For larger datasets, use server-side pagination and sorting instead -- pass the parameters to your API and let the database handle the heavy lifting. Client-side sorting of 10,000 rows will freeze the UI for hundreds of milliseconds.
</Tip>

```typescript theme={null}
// 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

Dialogs are one of the most over-used patterns in web apps. Before reaching for a dialog, ask: "Could this be inline?" Confirmation for a delete? Inline. Editing a single field? Inline. A multi-step wizard that needs the user's full attention? That is a dialog. The rule of thumb: dialogs are for actions that need isolation from the rest of the page -- they force a decision before the user can continue.

```typescript theme={null}
// 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

CDK's drag-and-drop system handles the physics (momentum, reorder animation), accessibility (keyboard reordering), and cross-container transfers. You provide the data model and the visual template -- the CDK handles the rest. The most important concept is `cdkDropListGroup`: it tells the CDK that multiple drop lists are connected, enabling items to move between them (like a Kanban board).

```typescript theme={null}
// 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

Virtual scrolling is the CDK's answer to the "render 10,000 items without crashing the browser" problem. Instead of creating DOM nodes for every item (which would mean 10,000+ elements in the DOM), it only renders the items visible in the viewport plus a small buffer. As the user scrolls, old items are recycled and new ones are created. The result: a list of 100,000 items feels just as smooth as a list of 20.

<Warning>
  **Key constraint**: Fixed-size virtual scrolling requires that every item has the **exact same height** (specified via `itemSize`). If your items have variable heights, you need the experimental `autosize` strategy, which is significantly more complex and less performant. Design your list items to be fixed-height whenever possible.
</Warning>

```typescript theme={null}
// 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

The CDK Overlay system is what powers every Material popup -- dialogs, tooltips, select dropdowns, menus. It solves two hard problems: **positioning** (the dropdown should appear below the trigger, but flip above if there is not enough room) and **z-index management** (overlays should stack in creation order without you hardcoding z-index values). If you are building any custom popup component, use the CDK Overlay instead of rolling your own -- you will avoid dozens of edge cases around scroll positioning, viewport boundaries, and backdrop handling.

```typescript theme={null}
// 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

```scss theme={null}
// 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

<CardGroup cols={2}>
  <Card title="Import Only What You Need" icon="filter">
    Import individual modules to minimize bundle size
  </Card>

  <Card title="Use CDK for Custom Components" icon="cubes">
    Build on CDK primitives instead of from scratch
  </Card>

  <Card title="Follow a11y Guidelines" icon="universal-access">
    Material components are accessible by default - don't break them
  </Card>

  <Card title="Customize via Theming" icon="palette">
    Use Sass theming instead of CSS overrides
  </Card>
</CardGroup>

***

<Card title="Next: PWA & Service Workers" icon="arrow-right" href="/courses/angular-crash-course/22-pwa">
  Build offline-capable Progressive Web Apps with Angular
</Card>
