Homeβ€Ί JavaScriptβ€Ί Angular Material Expansion Panel: Production-Grade Accordions

Angular Material Expansion Panel: Production-Grade Accordions

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: Advanced JS β†’ Topic 23 of 24
Angular Material expansion panels done right β€” accordion patterns, lazy loading, state management, and the gotchas that bite teams in production.
βš™οΈ Intermediate β€” basic JavaScript knowledge assumed
In this tutorial, you'll learn:
  • Never manage accordion open/closed state as a boolean[]. Use a single stable string ID as source of truth β€” it's serialisable, testable, and survives dynamic list reordering without desyncing.
  • matExpansionPanelContent is not the same as ngIf. It defers first render but persists after close. If your panel content must reset on close (forms especially), ngIf on the panel body is the right call β€” not matExpansionPanelContent.
  • Reach for mat-expansion-panel when your team is already on Material and you need accessibility + theming for free. Reach for a CSS max-height div when you have 40+ panels, are building SSR-first, or are in a bundle-sensitive micro-frontend.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑ Quick Answer
Picture a filing cabinet where only one drawer can be open at a time. Pull one out, the others snap shut automatically. That's an accordion. Angular Material's expansion panel is that filing cabinet β€” except it's programmable, it remembers which drawer was open, it can disable specific drawers, and it can load the drawer's contents only when someone actually pulls it open. The tricky part isn't building the cabinet. It's deciding how many drawers share a lock.

I watched a team ship an Angular dashboard with 40 expansion panels, every single one eagerly loading its full dataset on page init. The page took 11 seconds to become interactive. Not because the panels were wrong β€” because nobody thought about what 'open' and 'closed' actually mean at runtime.

Angular Material's mat-expansion-panel is deceptively simple to drop into a template. Slap it in, add a title, dump your content inside β€” it works. But 'it works' and 'it works correctly under real load with real state management' are two completely different sentences. The accordion pattern specifically introduces coordination problems: which panel is the source of truth for open/closed state? Who manages multi-panel exclusivity? What happens when your backend data arrives after the panel is already rendered? These aren't edge cases. They're the default conditions in any production app.

By the end of this, you'll be able to build an accordion that handles dynamic panel lists, controls open/closed state programmatically from outside the component, implements true lazy content loading so panels don't fetch data until opened, and wires correctly into reactive forms and route-based state. You'll also know exactly when to rip the whole thing out and reach for something simpler.

The mat-accordion Trap: Who Actually Owns the State?

The first thing most developers miss is that mat-accordion and mat-expansion-panel have a surprisingly loose relationship. mat-accordion is a coordinator, not a container. It doesn't hold state β€” it enforces a policy (single open panel) by listening to its panel children. The moment you break that parent-child DOM relationship β€” say, you render panels dynamically inside an *ngFor with a structural directive in between β€” the accordion loses awareness of some panels entirely and the single-open guarantee silently breaks.

This is why you shouldn't think of accordion state as belonging to the template. The open/closed state of each panel is business logic. It answers questions like: 'Which section was the user reviewing when they navigated away?' 'Should the error section auto-expand when validation fails?' That logic belongs in your component class or your state management layer β€” not scattered across [expanded] bindings you're praying stay in sync.

The correct production pattern: maintain an explicit activePanel identifier in your component. Drive [expanded] from that. Handle (opened) and (closed) events to update the identifier. Now your accordion state is testable, serialisable, and can survive route changes.

CheckoutSectionsAccordion.component.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
// io.thecodeforge β€” JavaScript tutorial

import {
  Component,
  OnInit,
  ChangeDetectionStrategy
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';

// Represents a discrete step in a multi-stage checkout flow.
// Each section maps to a route fragment so the browser back button
// restores the correct open panel β€” critical for mobile UX.
export interface CheckoutSection {
  id: string;
  label: string;
  isComplete: boolean;
  isDisabled: boolean;
}

@Component({
  selector: 'tcf-checkout-accordion',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <!--
      multi="false" is the default but we set it explicitly.
      Implicit defaults are silent bugs waiting for someone to
      refactor the template without knowing the invariant.
    -->
    <mat-accordion multi="false">
      <mat-expansion-panel
        *ngFor="let section of checkoutSections"
        [expanded]="activeSectionId === section.id"
        [disabled]="section.isDisabled"
        (opened)="onSectionOpened(section.id)"
        (closed)="onSectionClosed(section.id)"
        hideToggle="false"
      >
        <mat-expansion-panel-header>
          <mat-panel-title>
            <!-- Visual indicator so users know which steps are done -->
            <mat-icon *ngIf="section.isComplete" color="primary">check_circle</mat-icon>
            {{ section.label }}
          </mat-panel-title>
          <mat-panel-description *ngIf="section.isDisabled">
            Complete previous steps first
          </mat-panel-description>
        </mat-expansion-panel-header>

        <!--
          ngIf on the content body β€” NOT on the panel itself.
          Destroying the panel would lose its position in the accordion.
          Destroying just the body prevents content rendering when collapsed
          without breaking accordion coordination.
        -->
        <ng-container *ngIf="activeSectionId === section.id">
          <ng-content></ng-content>
          <!-- In a real app, each section id maps to a dynamic component -->
          <div class="section-placeholder">{{ section.id }} content loads here</div>
        </ng-container>

      </mat-expansion-panel>
    </mat-accordion>
  `
})
export class CheckoutSectionsAccordionComponent implements OnInit {

  checkoutSections: CheckoutSection[] = [
    { id: 'contact',  label: 'Contact Info',    isComplete: false, isDisabled: false },
    { id: 'shipping', label: 'Shipping Address', isComplete: false, isDisabled: true  },
    { id: 'payment',  label: 'Payment Method',  isComplete: false, isDisabled: true  },
    { id: 'review',   label: 'Order Review',    isComplete: false, isDisabled: true  }
  ];

  // Single source of truth. One string. No boolean array.
  // A boolean array across N panels is N sources of truth waiting to desync.
  activeSectionId: string = 'contact';

  constructor(
    private readonly route: ActivatedRoute,
    private readonly router: Router
  ) {}

  ngOnInit(): void {
    // Restore accordion state from the URL fragment.
    // Users who bookmark mid-checkout or hit back land in the right section.
    const fragment = this.route.snapshot.fragment;
    if (fragment && this.checkoutSections.some(s => s.id === fragment)) {
      this.activeSectionId = fragment;
    }
  }

  onSectionOpened(sectionId: string): void {
    this.activeSectionId = sectionId;

    // Write state to URL fragment β€” zero query param pollution.
    // replaceUrl: true prevents every panel open from creating a history entry.
    this.router.navigate([], {
      fragment: sectionId,
      replaceUrl: true
    });
  }

  onSectionClosed(sectionId: string): void {
    // Only clear if this panel closing wasn't caused by another panel opening.
    // mat-accordion fires (closed) on the old panel BEFORE (opened) on the new one.
    // Using setTimeout(0) here is the classic workaround β€” but it's a lie.
    // Instead, we let [expanded]="activeSectionId === section.id" handle it.
    // If a new panel opened, activeSectionId already changed β€” this is a no-op.
    if (this.activeSectionId === sectionId) {
      this.activeSectionId = '';
    }
  }
}
β–Ά Output
Accordion renders with 'Contact Info' expanded by default.
Navigating to /checkout#shipping expands the Shipping panel if it's not disabled.
Opening a new panel fires (closed) on the previous one, then (opened) on the new one.
URL fragment updates on every panel open without adding browser history entries.
Disabled panels show 'Complete previous steps first' in the panel description.
⚠️
Production Trap: The Desync That Breaks CheckoutNever manage expansion state as a boolean[] mapped by index. The moment you add, remove, or reorder panels dynamically, index 2 no longer means 'payment' β€” it means 'whatever is at position 2 right now.' Use a stable string identifier keyed to the panel's data, not its DOM position.

Lazy Panel Content: Stop Loading What Users Never Open

Here's the 11-second page I mentioned. The team was loading full transaction history, chart data, and analytics summaries inside panels that 90% of users never opened. All of it fetched eagerly. All of it blocking the thread with change detection cycles across thousands of DOM nodes that were visually hidden.

Angular Material solves this with &lt;ng-template matExpansionPanelContent&gt;. Content inside this directive is only rendered to the DOM when the panel first opens β€” not before. This isn't CSS display:none. The component is never instantiated. The HTTP calls inside it are never made. The change detection tree for that subtree simply doesn't exist until the user opens the panel.

The catch people miss: 'first opens' means exactly that. Once opened, the content stays in the DOM even if the panel closes again. If you need it destroyed on close β€” to reset a form, for example β€” you need the *ngIf approach on the panel body instead. These are different trade-offs. Lazy-with-persistence fits data display. Destroy-on-close fits forms that should reset between opens.

UserProfileSections.component.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
// io.thecodeforge β€” JavaScript tutorial

import {
  Component,
  Input,
  OnInit,
  ChangeDetectionStrategy,
  ChangeDetectorRef
} from '@angular/core';
import { Observable, of, EMPTY } from 'rxjs';
import { catchError, finalize } from 'rxjs/operators';

export interface TransactionSummary {
  id: string;
  amount: number;
  currency: string;
  timestamp: string;
}

// Simulates a service. In production this is your HttpClient call.
function fetchUserTransactions(userId: string): Observable<TransactionSummary[]> {
  console.log(`[HTTP] Fetching transactions for ${userId} β€” this fires only on first panel open`);
  return of([
    { id: 'txn_001', amount: 149.99, currency: 'USD', timestamp: '2024-03-15T10:22:00Z' },
    { id: 'txn_002', amount:  49.00, currency: 'USD', timestamp: '2024-03-14T08:01:00Z' },
  ]);
}

@Component({
  selector: 'tcf-user-profile-sections',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <mat-accordion multi="true">

      <!-- Panel 1: Static content. Always cheap. No lazy needed. -->
      <mat-expansion-panel>
        <mat-expansion-panel-header>
          <mat-panel-title>Account Details</mat-panel-title>
        </mat-expansion-panel-header>
        <p>Email: {{ userEmail }}</p>
        <p>Member since: 2022</p>
      </mat-expansion-panel>

      <!--
        Panel 2: Expensive content. matExpansionPanelContent defers
        instantiation until first open. The inner component's ngOnInit
        β€” and any HTTP calls inside it β€” don't fire until that moment.
      -->
      <mat-expansion-panel (opened)="onTransactionPanelOpened()">
        <mat-expansion-panel-header>
          <mat-panel-title>Transaction History</mat-panel-title>
          <mat-panel-description *ngIf="isLoadingTransactions">
            Loading...
          </mat-panel-description>
        </mat-expansion-panel-header>

        <!--
          matExpansionPanelContent: Angular Material's built-in lazy portal.
          This template is not rendered until the panel opens for the first time.
          Closing and reopening does NOT re-render β€” content persists.
          Use this for: data tables, charts, read-only content.
          Avoid for: forms that must reset on close.
        -->
        <ng-template matExpansionPanelContent>
          <div *ngIf="isLoadingTransactions" class="loading-state">
            <mat-spinner diameter="32"></mat-spinner>
          </div>

          <mat-list *ngIf="!isLoadingTransactions">
            <mat-list-item *ngFor="let txn of transactions">
              <span matListItemTitle>{{ txn.id }}</span>
              <span matListItemLine>{{ txn.amount | currency:txn.currency }} β€” {{ txn.timestamp | date:'shortDate' }}</span>
            </mat-list-item>
          </mat-list>

          <p *ngIf="!isLoadingTransactions && transactions.length === 0">
            No transactions found.
          </p>
        </ng-template>
      </mat-expansion-panel>

    </mat-accordion>
  `
})
export class UserProfileSectionsComponent {
  @Input() userId!: string;
  @Input() userEmail!: string;

  transactions: TransactionSummary[] = [];
  isLoadingTransactions = false;

  // Track whether we've already loaded β€” don't re-fetch on every open.
  // matExpansionPanelContent persists the DOM, but (opened) fires every time.
  private transactionsLoaded = false;

  constructor(private readonly cdr: ChangeDetectorRef) {}

  onTransactionPanelOpened(): void {
    // Guard: only fetch once. The panel stays mounted after first open,
    // so this event fires on every subsequent open too.
    if (this.transactionsLoaded) {
      return;
    }

    this.isLoadingTransactions = true;

    fetchUserTransactions(this.userId)
      .pipe(
        catchError(err => {
          console.error('[TransactionPanel] Failed to load transactions:', err);
          // Don't rethrow β€” a broken transaction panel shouldn't
          // crash the whole profile page.
          return EMPTY;
        }),
        finalize(() => {
          this.isLoadingTransactions = false;
          this.transactionsLoaded = true;
          // OnPush: manually trigger change detection because
          // the observable resolves outside Angular's zone in some setups.
          this.cdr.markForCheck();
        })
      )
      .subscribe(txns => {
        this.transactions = txns;
      });
  }
}
β–Ά Output
On page load: NO HTTP request fires. Only 'Account Details' content renders.
User opens 'Transaction History' panel:
[HTTP] Fetching transactions for [userId] β€” this fires only on first panel open
Panel shows 'Loading...' spinner in header description.
Transactions render in a list after data arrives.
User closes panel and reopens it:
No second HTTP call. No re-render. Content persists.
transactionsLoaded guard prevents duplicate fetch.
⚠️
Senior Shortcut: matExpansionPanelContent vs *ngIf β€” Pick the Right WeaponUse &lt;ng-template matExpansionPanelContent&gt; for read-only data display β€” it defers render and then keeps content alive. Use *ngIf=&quot;activePanelId === section.id&quot; on the panel body for forms or anything that must reset on close. Mixing them up means your form resets never work or your data refetches on every open.

Programmatic Control and Reactive Form Integration That Doesn't Leak

At some point your product manager will say: 'When the user hits Submit and there's a validation error, automatically open the section with the error.' This is where most accordion implementations crack. The component was designed to be user-driven. Now you need to drive it from code, and there are exactly two ways to do that β€” one of which creates a memory leak.

The leak-prone way: grab a @ViewChild(MatExpansionPanel) reference and call .open() directly. Fine for one panel. Falls apart the moment you have a *ngFor list of panels, because @ViewChildren gives you a QueryList β€” and QueryList.changes is a cold observable that you need to unsubscribe from yourself. I've seen this exact pattern create zombie subscriptions in a settings page that reloaded its panel list on every navigation. Memory climbed 40MB per minute in production until someone noticed Chrome's task manager.

The correct way: drive state through [expanded] bindings. Your validation logic sets activeSectionId to the first section with errors. The template responds. No ViewChild. No imperative .open(). No subscription to manage.

SettingsFormAccordion.component.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
// io.thecodeforge β€” JavaScript tutorial

import {
  Component,
  OnInit,
  OnDestroy,
  ChangeDetectionStrategy,
  ChangeDetectorRef
} from '@angular/core';
import {
  FormBuilder,
  FormGroup,
  Validators,
  AbstractControl
} from '@angular/forms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

// Each form section maps to a panel. Keeping this as a typed
// constant prevents magic strings scattered through the template.
export const SETTINGS_SECTIONS = [
  'profile',
  'notifications',
  'security'
] as const;

export type SettingsSectionId = typeof SETTINGS_SECTIONS[number];

@Component({
  selector: 'tcf-settings-form-accordion',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <form [formGroup]="settingsForm" (ngSubmit)="onSubmit()">
      <mat-accordion multi="false">

        <!-- Profile Section -->
        <mat-expansion-panel
          [expanded]="activeSectionId === 'profile'"
          (opened)="activeSectionId = 'profile'"
        >
          <mat-expansion-panel-header>
            <mat-panel-title>Profile</mat-panel-title>
            <!--
              Surface validation state in the header so users know
              *which* closed panel has errors without opening it.
            -->
            <mat-panel-description
              *ngIf="isSectionInvalid('profile')"
              class="error-description"
            >
              Fix errors before saving
            </mat-panel-description>
          </mat-expansion-panel-header>

          <ng-template matExpansionPanelContent>
            <mat-form-field appearance="outline" class="full-width">
              <mat-label>Display Name</mat-label>
              <input matInput formControlName="displayName" />
              <mat-error *ngIf="settingsForm.get('displayName')?.hasError('required')">
                Display name is required
              </mat-error>
              <mat-error *ngIf="settingsForm.get('displayName')?.hasError('minlength')">
                Minimum 2 characters
              </mat-error>
            </mat-form-field>
          </ng-template>
        </mat-expansion-panel>

        <!-- Notifications Section -->
        <mat-expansion-panel
          [expanded]="activeSectionId === 'notifications'"
          (opened)="activeSectionId = 'notifications'"
        >
          <mat-expansion-panel-header>
            <mat-panel-title>Notifications</mat-panel-title>
            <mat-panel-description *ngIf="isSectionInvalid('notifications')" class="error-description">
              Fix errors before saving
            </mat-panel-description>
          </mat-expansion-panel-header>

          <ng-template matExpansionPanelContent>
            <mat-form-field appearance="outline" class="full-width">
              <mat-label>Notification Email</mat-label>
              <input matInput formControlName="notificationEmail" />
              <mat-error *ngIf="settingsForm.get('notificationEmail')?.hasError('email')">
                Enter a valid email address
              </mat-error>
            </mat-form-field>
          </ng-template>
        </mat-expansion-panel>

        <!-- Security Section -->
        <mat-expansion-panel
          [expanded]="activeSectionId === 'security'"
          (opened)="activeSectionId = 'security'"
        >
          <mat-expansion-panel-header>
            <mat-panel-title>Security</mat-panel-title>
            <mat-panel-description *ngIf="isSectionInvalid('security')" class="error-description">
              Fix errors before saving
            </mat-panel-description>
          </mat-expansion-panel-header>

          <ng-template matExpansionPanelContent>
            <mat-form-field appearance="outline" class="full-width">
              <mat-label>Current Password</mat-label>
              <input matInput type="password" formControlName="currentPassword" />
              <mat-error *ngIf="settingsForm.get('currentPassword')?.hasError('required')">
                Password is required to save changes
              </mat-error>
            </mat-form-field>
          </ng-template>
        </mat-expansion-panel>

      </mat-accordion>

      <button mat-raised-button color="primary" type="submit" class="submit-btn">
        Save Settings
      </button>
    </form>
  `
})
export class SettingsFormAccordionComponent implements OnInit, OnDestroy {

  settingsForm!: FormGroup;
  activeSectionId: SettingsSectionId = 'profile';

  // Standard Angular teardown pattern. One subject, one takeUntil.
  // Never manage multiple Subscription objects β€” they get orphaned.
  private readonly destroy$ = new Subject<void>();

  // Maps form control names to which accordion section owns them.
  // Keep this explicit β€” inferring it from control name prefixes is too clever.
  private readonly controlSectionMap: Record<string, SettingsSectionId> = {
    displayName:       'profile',
    notificationEmail: 'notifications',
    currentPassword:   'security'
  };

  constructor(
    private readonly fb: FormBuilder,
    private readonly cdr: ChangeDetectorRef
  ) {}

  ngOnInit(): void {
    this.settingsForm = this.fb.group({
      displayName:       ['', [Validators.required, Validators.minLength(2)]],
      notificationEmail: ['', [Validators.email]],
      currentPassword:   ['', [Validators.required]]
    });
  }

  // Returns true if any control in this section has been touched and is invalid.
  // Touched check prevents showing errors before the user has interacted.
  isSectionInvalid(sectionId: SettingsSectionId): boolean {
    return Object.entries(this.controlSectionMap)
      .filter(([, section]) => section === sectionId)
      .some(([controlName]) => {
        const control = this.settingsForm.get(controlName);
        return control ? control.invalid && control.touched : false;
      });
  }

  onSubmit(): void {
    // Touch all controls so errors surface on untouched fields.
    this.settingsForm.markAllAsTouched();

    if (this.settingsForm.invalid) {
      // Find the first section that has an invalid, touched control
      // and navigate the accordion to it automatically.
      const firstInvalidSection = SETTINGS_SECTIONS.find(section =>
        this.isSectionInvalid(section)
      );

      if (firstInvalidSection) {
        // This is the entire "open panel programmatically" solution:
        // one assignment. No ViewChild. No .open(). No subscription.
        this.activeSectionId = firstInvalidSection;
        // OnPush: state changed outside a template event β€” nudge the detector.
        this.cdr.markForCheck();
      }
      return;
    }

    console.log('Settings saved:', this.settingsForm.value);
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}
β–Ά Output
Initial render: 'Profile' panel is open. Other panels collapsed.
User clicks 'Save Settings' without filling any fields:
markAllAsTouched() fires β€” all controls become touched.
isSectionInvalid('profile') returns true (displayName: required error).
activeSectionId = 'profile' β€” panel remains open.
'Fix errors before saving' appears in the Profile panel header description.
Error message 'Display name is required' appears in the form field.
User fills displayName, then clicks Save again:
Profile section now valid. Accordion jumps to 'security' section.
'Current Password is required' error surfaces.
'Fix errors before saving' appears in Security panel header.
⚠️
The Classic Bug: @ViewChild + QueryList = Silent Memory LeakIf you use @ViewChildren(MatExpansionPanel) and call .open() imperatively, you must subscribe to QueryList.changes to handle dynamic lists β€” and that subscription must be cleaned up in ngOnDestroy. Skip either step and you get a zombie subscription that accumulates on every component mount. The symptom: memory usage that climbs without bound and never GCs. The fix: don't do it. Drive state through [expanded] bindings and never touch the ViewChild API for this.

When to Ditch mat-expansion-panel and Just Use a div

Angular Material's expansion panel ships with the full Material theming system, ripple effects, ARIA roles (role=&quot;button&quot;, aria-expanded, aria-controls), keyboard navigation, and animation. That's roughly 15KB of JavaScript in your bundle before tree-shaking does its work. For most dashboard and settings UIs, that trade-off is completely fine β€” you'd build all of that accessibility scaffolding yourself anyway.

But there are scenarios where it's wrong. If you're building a landing page accordion for SEO-critical content, you don't want JavaScript controlling visible text. Server-rendered pure CSS accordions with &lt;details&gt; and &lt;summary&gt; load faster, work without JavaScript, and are indexed more reliably by crawlers. mat-expansion-panel is Angular β€” it requires hydration, it requires the Angular runtime, and it cannot render its content on the server without Angular Universal plus careful lazy hydration setup.

There's also the situation I saw on a fintech mobile app: 60 expansion panels on a single page, all part of a compliance document viewer. Material's animation system was creating 60 separate animation players simultaneously. On mid-range Android devices, opening a panel triggered a 400ms jank. The fix was ditching mat-expansion-panel entirely and using a CSS max-height transition with a [class.expanded] binding. Same visual result. Zero Material overhead. Sometimes the right tool is the boring one.

ComplianceDocumentViewer.component.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
// io.thecodeforge β€” JavaScript tutorial
// Use case: 50+ accordion items where Material animation overhead
// causes measurable jank on mid-range devices.
// This is the 'reach for a div' pattern β€” not a cop-out, a trade-off.

import {
  Component,
  Input,
  TrackByFunction,
  ChangeDetectionStrategy
} from '@angular/core';

export interface ComplianceClause {
  id: string;
  title: string;
  content: string;
  requiresAcknowledgement: boolean;
}

@Component({
  selector: 'tcf-compliance-document-viewer',
  changeDetection: ChangeDetectionStrategy.OnPush,
  styles: [`
    .clause-panel {
      border: 1px solid #e0e0e0;
      border-radius: 4px;
      margin-bottom: 8px;
      overflow: hidden;
    }

    .clause-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 16px;
      cursor: pointer;
      background: #fafafa;
      /* No ripple. No Material animation. Just a fast CSS transition. */
      transition: background 150ms ease;
      border: none;
      width: 100%;
      text-align: left;
    }

    .clause-header:hover { background: #f0f0f0; }

    .clause-body {
      /* max-height transition: cheap on the GPU, no JS animation players. */
      max-height: 0;
      overflow: hidden;
      transition: max-height 200ms ease-out, padding 200ms ease-out;
      padding: 0 16px;
    }

    /* Single class toggle drives the entire open/close animation. */
    .clause-body.expanded {
      max-height: 800px; /* Must exceed tallest possible content. */
      padding: 16px;
    }

    .clause-chevron {
      transition: transform 200ms ease;
      display: inline-block;
    }

    .clause-chevron.expanded {
      transform: rotate(180deg);
    }
  `],
  template: `
    <div class="compliance-document">
      <div
        *ngFor="let clause of clauses; trackBy: trackByClauseId"
        class="clause-panel"
      >
        <!--
          Full ARIA accordion pattern without Material:
          - role="button" on the trigger
          - aria-expanded reflects state
          - aria-controls points to the content id
          You own the accessibility now. Don't skip this.
        -->
        <button
          class="clause-header"
          [attr.aria-expanded]="openClauseIds.has(clause.id)"
          [attr.aria-controls]="'clause-body-' + clause.id"
          (click)="toggleClause(clause.id)"
          (keydown.enter)="toggleClause(clause.id)"
          (keydown.space)="toggleClause(clause.id); $event.preventDefault()"
        >
          <span>{{ clause.title }}</span>
          <span
            class="clause-chevron"
            [class.expanded]="openClauseIds.has(clause.id)"
            aria-hidden="true"
          >β–Ό</span>
        </button>

        <div
          [id]="'clause-body-' + clause.id"
          class="clause-body"
          [class.expanded]="openClauseIds.has(clause.id)"
          role="region"
          [attr.aria-labelledby]="'clause-header-' + clause.id"
        >
          <p>{{ clause.content }}</p>

          <mat-checkbox
            *ngIf="clause.requiresAcknowledgement"
            (change)="onAcknowledgement(clause.id, $event.checked)"
          >
            I have read and understood this clause
          </mat-checkbox>
        </div>
      </div>
    </div>
  `
})
export class ComplianceDocumentViewerComponent {
  @Input() clauses: ComplianceClause[] = [];

  // Set gives O(1) lookup β€” better than array.includes() in a trackBy+ngFor loop.
  // Also supports multi-open naturally β€” no accordion coordinator needed.
  openClauseIds = new Set<string>();

  // trackBy prevents the entire list from re-rendering when one clause opens.
  // Without this, *ngFor tears down and rebuilds 50+ DOM nodes on every toggle.
  trackByClauseId: TrackByFunction<ComplianceClause> = (_, clause) => clause.id;

  toggleClause(clauseId: string): void {
    if (this.openClauseIds.has(clauseId)) {
      this.openClauseIds.delete(clauseId);
    } else {
      this.openClauseIds.add(clauseId);
    }
    // OnPush + Set mutation: Angular doesn't detect Set mutations.
    // Re-assign to trigger change detection.
    this.openClauseIds = new Set(this.openClauseIds);
  }

  onAcknowledgement(clauseId: string, acknowledged: boolean): void {
    console.log(`Clause ${clauseId} acknowledged: ${acknowledged}`);
    // In production: update a form control or dispatch to state management.
  }
}
β–Ά Output
50 clauses render with zero Material animation overhead.
Clicking any clause header toggles its body open/closed in ~200ms via CSS transition.
Multiple clauses can be open simultaneously β€” no single-open enforcement.
Aria attributes correctly announce expanded state to screen readers.
trackBy prevents full list re-render on every toggle β€” only the changed DOM node updates.
Set re-assignment triggers OnPush change detection correctly.
πŸ”₯
Interview Gold: When Does mat-expansion-panel Become the Wrong Choice?Three signals: (1) You have 40+ panels and profiling shows animation player overhead in the flame graph. (2) You need SSR with SEO β€” use &lt;details&gt;/&lt;summary&gt; instead. (3) You're in a micro-frontend that can't import the full Angular Material bundle. In all three cases, a CSS max-height transition with a Set-backed open-state tracker outperforms Material and takes 20 lines to implement.
Aspectmat-expansion-panel (Material)CSS max-height + div
Bundle cost~15KB (with tree-shaking)0KB β€” zero dependency
Animation performanceJS animation player per panel β€” measurable on 40+ panelsGPU-composited CSS transition β€” flat cost regardless of panel count
ARIA accessibilityBuilt-in: aria-expanded, aria-controls, role=buttonManual β€” you own every attribute, but full control
Keyboard navigationBuilt-in: Enter, Space, TabManual β€” requires keydown bindings on each trigger
SSR / SEO compatibilityRequires Angular Universal + hydration setupFull SSR with no JS β€” or use native <details>/<summary>
Single-open enforcementmat-accordion multi=false handles it automaticallyManual β€” needs a coordinator in your component class
Theming integrationFull Material theme + dark mode support out of the boxManual CSS β€” complete freedom, zero guardrails
Form integrationClean β€” plays well with reactive forms inside panelsClean β€” no difference, it's just a div
Panel state in URLManual β€” you wire (opened)/(closed) to routerManual β€” same effort
When to chooseStandard dashboard/settings UI β€” team uses Material50+ panels, SSR-first pages, bundle-critical micro-frontends

🎯 Key Takeaways

  • Never manage accordion open/closed state as a boolean[]. Use a single stable string ID as source of truth β€” it's serialisable, testable, and survives dynamic list reordering without desyncing.
  • matExpansionPanelContent is not the same as ngIf. It defers first render but persists after close. If your panel content must reset on close (forms especially), ngIf on the panel body is the right call β€” not matExpansionPanelContent.
  • Reach for mat-expansion-panel when your team is already on Material and you need accessibility + theming for free. Reach for a CSS max-height div when you have 40+ panels, are building SSR-first, or are in a bundle-sensitive micro-frontend.
  • mat-accordion's single-open guarantee silently breaks the moment a structural directive sits between mat-accordion and its mat-expansion-panel children. Angular Material tracks panels by querying direct content children β€” break that DOM relationship and you own the coordination problem yourself.

⚠ Common Mistakes to Avoid

  • βœ•Mistake 1: Placing *ngIf on the mat-expansion-panel itself to hide/show panels conditionally β€” the panel disappears from the DOM, breaking mat-accordion's panel count and single-open coordination silently. You see panels that no longer enforce mutual exclusion. Fix: use [disabled]="condition" to exclude a panel from user interaction, or drive visibility with CSS and keep the panel in the DOM.
  • βœ•Mistake 2: Using (opened) to trigger an HTTP call without a 'loaded' guard β€” every time the user opens the panel the call fires again, hammering your API and causing a race condition where stale data replaces fresh data. The symptom is flickering content on second open. Fix: a private boolean flag checked at the top of the handler, set to true after the first successful load.
  • βœ•Mistake 3: Calling cdr.detectChanges() instead of cdr.markForCheck() inside an Observable subscription in a ChangeDetectionStrategy.OnPush component β€” detectChanges() runs synchronously top-down from that component, potentially triggering change detection on the entire subtree mid-async-operation. The symptom is ExpressionChangedAfterItHasBeenCheckedError on the console. Fix: always use markForCheck() in OnPush components β€” it schedules a check at the next detection cycle, not immediately.
  • βœ•Mistake 4: Forgetting to set trackBy on *ngFor when rendering a dynamic panel list β€” Angular tears down and rebuilds every panel DOM node whenever the array reference changes (e.g., after a sort or filter). mat-expansion-panel re-instantiates, open/closed state resets, animations replay on every change. Fix: provide a TrackByFunction that returns a stable unique identifier from your data object.

Interview Questions on This Topic

  • Qmat-accordion enforces single-open mode with multi=false. What exactly breaks if you wrap mat-expansion-panel elements in a structural directive like *ngIf inside the accordion, and how would you diagnose it?
  • QYou have a settings page with a Material accordion. Submitting the form should auto-expand the first panel containing invalid fields. A junior dev reaches for @ViewChildren(MatExpansionPanel) and calls .open() imperatively. What are the two production problems with that approach, and what's your preferred alternative?
  • QA profile page renders transaction history inside a mat-expansion-panel using matExpansionPanelContent for lazy loading. QA reports that after the user opens the panel, closes it, and opens it again, the data shown is 20 minutes stale. What caused this and how do you fix it without removing lazy loading?
  • QYou're migrating a compliance document viewer with 80 accordion sections to Angular Material. Performance profiling on a mid-range Android device shows 380ms of scripting work every time a panel opens. Walk me through how you'd diagnose whether Material's animation system is the culprit and what you'd do if it is.

Frequently Asked Questions

How do I open a specific mat-expansion-panel programmatically in Angular?

Set an activePanelId string in your component and bind [expanded]=&quot;activePanelId === panel.id&quot; on each panel. Changing activePanelId in code opens the target panel immediately β€” no ViewChild, no .open() call required. This approach works with OnPush change detection and survives dynamic panel lists because state lives in your component, not in a DOM reference.

What's the difference between matExpansionPanelContent and using *ngIf inside a mat-expansion-panel?

matExpansionPanelContent defers rendering until first open, then keeps the content alive in the DOM permanently. ngIf destroys and recreates content on every toggle. Use matExpansionPanelContent for data display that's expensive to fetch but should persist β€” use ngIf for forms or any content that must reset to a clean state each time the panel closes.

How do I stop multiple mat-expansion-panels from being open at the same time?

Add multi=&quot;false&quot; to the parent mat-accordion element β€” that's the default behaviour, but set it explicitly so future developers know it's intentional. If panels aren't closing correctly, check that all your mat-expansion-panel elements are direct content children of mat-accordion with no structural directives wrapping them in between, or the accordion loses awareness of those panels entirely.

We have 60+ expansion panels on a single page and Material animations are causing 400ms jank on Android. What's the actual fix?

Ditch mat-expansion-panel for that specific page and replace it with a CSS max-height transition approach. Each panel gets a class.expanded binding toggled by a Set in your component β€” O(1) lookup, GPU-composited transition, zero JS animation players. You lose Material theming and automatic ARIA management, which means you add aria-expanded, aria-controls, and keyboard handlers manually. For 60 panels on a compliance or document-heavy page, that trade-off is worth it every time β€” I've seen it drop jank from 400ms to under 16ms on the same device.

πŸ”₯
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousIntroduction to Angular: Components, Modules and ServicesNext β†’Frontend Frameworks Compared: React vs Angular vs Vue in 2026
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged