mat-expansion-panel is Angular Material's accordion component — mat-accordion coordinates open/close policy across its panel children
State ownership belongs in your component class, not the template — drive [expanded] from a single activePanelId string, not a boolean array
matExpansionPanelContent defers first render until open but persists after close — use *ngIf on the body if content must reset on close
mat-accordion's single-open guarantee silently breaks when a structural directive (ngIf, ngFor) sits between accordion and panel — panels become invisible to the coordinator
40+ panels with Material animations cause measurable jank on mid-range devices — swap to CSS max-height transitions for high-count scenarios
The biggest trap: using @ViewChildren(MatExpansionPanel) with .open() imperatively — it creates zombie subscriptions that leak 40MB/minute in production
Angular 17+ signals change the calculus — a signal-backed activePanelId integrates naturally with OnPush and eliminates most manual markForCheck() calls
Plain-English First
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 who holds the key. The cabinet itself has no memory — it doesn't know which drawer the user cared about last session, it doesn't know which drawer should pop open when a validation error fires, and it doesn't know which drawer to show when someone lands on the page via a deep link. Your component holds all of that. The cabinet just enforces the rule that one drawer is open at a time.
Get that mental separation right — the cabinet is a policy enforcer, your component is the source of truth — and the rest follows.
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? What happens when Angular's change detection is running across 40 hidden panel subtrees on every keystroke? 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 — and what 'simpler' actually looks like in Angular 17 and 18 with signals in play.
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 via a ContentChildren query. The moment you break that parent-child DOM relationship — say, you render panels dynamically inside an *ngFor with a structural directive in between, or you wrap conditional panels in a div — the accordion loses awareness of some panels entirely and the single-open guarantee silently breaks. Both panels stay open. No warning. No error. The bug is invisible until a user reports it.
This is why accordion state doesn't belong in 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?' 'Which panel should be open when the user lands on this page from an email deep link?' None of those questions can be answered by template-level boolean bindings that exist only in memory.
The correct production pattern: maintain an explicit activePanelId identifier in your component — a plain string in Angular 14-16, or a signal in Angular 17+. Drive [expanded] from that identifier. Handle (opened) and (closed) events to update the identifier. Your accordion state is now testable, serialisable to the URL, restorable on page refresh, and accessible to any service or store that needs to read or set it from outside the component.
Angular 17 introduced signals as a stable API, and they change the ergonomics here noticeably. A signal<string>('') as your activePanelId integrates naturally with OnPush — reading the signal inside the template creates a reactive dependency automatically, and updating the signal schedules a re-render without calling markForCheck() manually. For new projects on Angular 17+, prefer signals for accordion state. For existing projects on Angular 14-16, the plain string pattern below is fully correct and requires no migration.
CheckoutSectionsAccordion.component.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
// io.thecodeforge — Angular Material Accordion// Pattern: single activeSectionId string as source of truth.// Works on Angular 14-18. For Angular 17+ signal variant, see comment at bottom.import {
Component,
OnInit,
ChangeDetectionStrategy
} from'@angular/core';
import { ActivatedRoute, Router } from'@angular/router';
import { CommonModule } from'@angular/common';
import { MatExpansionModule } from'@angular/material/expansion';
import { MatIconModule } from'@angular/material/icon';
exportinterfaceCheckoutSection {
id: string;
label: string;
isComplete: boolean;
isDisabled: boolean;
}
@Component({
selector: 'tcf-checkout-accordion',
standalone: true,
imports: [CommonModule, MatExpansionModule, MatIconModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- multi="false" is the default but declare it explicitly:
future developers need to know this is intentional, not an oversight. -->
<mat-accordion multi="false">
<!--
CRITICAL: mat-expansion-panel elements must be DIRECT content children
of mat-accordion. No *ngIf, no wrapping div, no ng-container between them.
Any structural directive here breaks ContentChildren query and the
single-open guarantee silently fails.
Safe pattern: *ngFor directly on mat-expansion-panel is fine.
Unsafe: <ng-container *ngIf><mat-expansion-panel> — doNOTdothis.
-->
<mat-expansion-panel
*ngFor="let section of checkoutSections; trackBy: trackBySectionId"
[expanded]="activeSectionId === section.id"
[disabled]="section.isDisabled"
(opened)="onSectionOpened(section.id)"
(closed)="onSectionClosed(section.id)"
>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon
*ngIf="section.isComplete"
color="primary"
aria-hidden="true"
>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 here (not matExpansionPanelContent) because checkout sections
contain forms that must reset when closed.
matExpansionPanelContent would persist form state across closes.
-->
<ng-container *ngIf="activeSectionId === section.id">
<div class="section-placeholder">{{ section.id }} content loads here</div>
</ng-container>
</mat-expansion-panel>
</mat-accordion>
`
})
exportclassCheckoutSectionsAccordionComponentimplementsOnInit {
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 string — one source of truth, serialisable to URL, testable.// Never use boolean[] mapped by array index.
activeSectionId: string = 'contact';
constructor(
privatereadonly route: ActivatedRoute,
privatereadonly router: Router
) {}
ngOnInit(): void {
// Restore active panel from URL fragment on page load or back-navigation.// Enables deep-linking: /checkout#shipping opens the Shipping panel directly.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;
// replaceUrl:true — updates the URL without adding a browser history entry.// Without replaceUrl, every panel open adds to history and back-button// navigates through panel states instead of away from the page.this.router.navigate([], {
fragment: sectionId,
replaceUrl: true
});
}
onSectionClosed(sectionId: string): void {
// Only clear activeSectionId if the closing panel was actually the active one.// mat-accordion fires (closed) on the previously-open panel when a new one opens —// at that moment activeSectionId already holds the new panel's ID.if (this.activeSectionId === sectionId) {
this.activeSectionId = '';
}
}
// TrackBy prevents Angular from destroying and rebuilding all panel DOM nodes// when the checkoutSections array reference changes (e.g., after an API update).trackBySectionId(_index: number, section: CheckoutSection): string {
return section.id;
}
// — Angular 17+ Signal Variant (alternative to the string field above) —// activeSectionId = signal<string>('contact');//// In the template: [expanded]="activeSectionId() === section.id"// In handlers: this.activeSectionId.set(sectionId);//// Benefits: integrates with OnPush automatically, no markForCheck() needed,// and the signal dependency is tracked per-template-expression rather than// per-component. Correct choice for Angular 17+ standalone components.
}
Output
Accordion renders with 'Contact Info' expanded by default.
Navigating to /checkout#shipping expands the Shipping panel if it's not disabled.
Opening a panel fires (closed) on the previously-open panel first, then (opened) on the new one —
onSectionClosed fires with the old ID, but activeSectionId already holds the new ID so the guard skips the clear.
URL fragment updates on every panel open without adding browser history entries (replaceUrl:true).
Disabled panels show 'Complete previous steps first' in the panel description.
trackBy prevents full panel list re-render when the checkoutSections array is replaced.
Production Trap: Index-Based State Desyncs on Dynamic Lists
Never manage expansion state as a boolean[] mapped by array index. The moment you add, remove, or reorder panels dynamically — which happens constantly in real apps that load panel lists from APIs — index 2 no longer means 'payment.' It means 'whatever happens to be at position 2 right now.' One async array update and you're debugging the wrong panel opening. Use a stable string identifier keyed to the panel's data, not its DOM position. The string is serialisable to the URL, testable in unit tests without rendering, and survives array mutations without any special handling.
Production Insight
mat-accordion uses ContentChildren to discover its panels — any DOM node between accordion and panel breaks that query silently, with no error and no warning in the console. The single-open guarantee simply stops working for affected panels.
The (closed) event on mat-accordion fires on the previously-open panel before (opened) fires on the new one. If your onSectionClosed handler naively clears activeSectionId, it clears it one frame before the new panel sets it — causing a one-frame flicker where no panel is expanded. The guard (if (this.activeSectionId === sectionId)) prevents this.
Rule: always drive [expanded] from a single activePanelId in your component class. Never manage per-panel booleans. Never place structural directives directly on mat-expansion-panel inside a mat-accordion.
Key Takeaway
mat-accordion is a policy enforcer, not a state container — it coordinates panels but owns none of the state. The moment you need to answer 'which panel is open?' from outside the template, the state needs to live in your component or store.
A single activePanelId string is serialisable, testable, restorable from the URL, and works correctly when the panel list changes dynamically. A boolean array is none of those things and desyncs silently on the first array mutation.
Punchline: if your accordion state is a boolean[], you have N sources of truth. The string has one. Pick the one.
UseUse activeSectionId string with [expanded] binding — one string, one source of truth, zero desync
IfStatic or dynamic panel list, Angular 17+ on signals
→
UseUse signal<string>('') as activeSectionId — reactive integration with OnPush without markForCheck()
IfDynamic panel list from API data — panels added, removed, or reordered at runtime
→
UseUse activeSectionId keyed to panel's stable data ID — never use array index as state key
IfNeed panel state to survive browser back/forward or page refresh
→
UseStore activeSectionId in URL fragment via router.navigate with replaceUrl:true — restore in ngOnInit from route.snapshot.fragment
IfMultiple panels can be open simultaneously (multi=true accordion)
→
UseUse a Set<string> of open panel IDs — O(1) has() check for [expanded] binding, supports multi-open naturally, serialisable to URL as comma-separated fragments
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 on page load. All of it blocking the thread with change detection cycles across thousands of DOM nodes that were visually hidden behind Material's CSS.
Angular Material solves this with <ng-template matExpansionPanelContent>. Content inside this directive is not rendered to the DOM until the panel first opens — not before. This isn't CSS display:none with the component hidden behind it. The component is never instantiated. Its ngOnInit hook never fires. The HTTP calls inside it are never made. The change detection subtree for that component simply doesn't exist until the user opens the panel.
The catch that consistently surprises developers: 'first opens' is exact. Once opened, the content stays in the DOM even after the panel closes again. The component is alive, subscribed, and visible to change detection — it's just visually hidden by Material's panel close animation. This is the persistence guarantee, and it's a feature for data display. But for a form inside a panel that should start fresh each time, it's a bug.
If you need content destroyed on close — to reset a form, clear a wizard state, or force a re-fetch of live data — you need *ngIf="activePanelId === section.id" on the panel body instead. This destroys the component on close and creates a new instance on reopen. The form state resets because the component is new. The HTTP call re-fires because ngOnInit runs again on the fresh component.
Angular 17 introduces @defer as a template syntax alternative for lazy loading. For panels that load heavy components — charting libraries, rich text editors, map components — @defer (on interaction) paired with the panel header interaction can defer loading the heavy dependency entirely until the user opens the panel, not just defer instantiation. This is a step beyond matExpansionPanelContent, which defers instantiation but still requires the component's module to be bundled eagerly. @defer can split the bundle entirely.
UserProfileSections.component.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
// io.thecodeforge — Lazy Panel Content with Loaded Guard// Demonstrates matExpansionPanelContent for data panels// and the loaded guard pattern that prevents duplicate HTTP calls.import {
Component,
Input,
ChangeDetectionStrategy,
ChangeDetectorRef
} from'@angular/core';
import { CommonModule } from'@angular/common';
import { MatExpansionModule } from'@angular/material/expansion';
import { MatListModule } from'@angular/material/list';
import { MatProgressSpinnerModule } from'@angular/material/progress-spinner';
import { Observable, of, EMPTY } from'rxjs';
import { catchError, finalize } from'rxjs/operators';
exportinterfaceTransactionSummary {
id: string;
amount: number;
currency: string;
timestamp: string;
}
// Simulated service call — replace with your injected HttpClient service.functionfetchUserTransactions(userId: string): Observable<TransactionSummary[]> {
console.log(
`[HTTP] Fetching transactions for user ${userId}`,
'— this line should appear ONCE regardless of how many times the panel opens'
);
returnof([
{ id: 'txn_001', amount: 149.99, currency: 'USD', timestamp: '2026-03-15T10:22:00Z' },
{ id: 'txn_002', amount: 49.00, currency: 'USD', timestamp: '2026-03-14T08:01:00Z' },
{ id: 'txn_003', amount: 299.00, currency: 'USD', timestamp: '2026-03-10T14:55:00Z' },
]);
}
@Component({
selector: 'tcf-user-profile-sections',
standalone: true,
imports: [
CommonModule,
MatExpansionModule,
MatListModule,
MatProgressSpinnerModule
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<mat-accordion multi="true">
<!-- Static content panel — no lazy loading needed, renders immediately -->
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>AccountDetails</mat-panel-title>
</mat-expansion-panel-header>
<p>Email: {{ userEmail }}</p>
<p>Member since: 2022</p>
</mat-expansion-panel>
<!--
Data panel — lazy loads on first open, persists after close.
(opened) fires every time the panel opens.
The transactionsLoaded guard prevents re-fetching on subsequent opens.
-->
<mat-expansion-panel (opened)="onTransactionPanelOpened()">
<mat-expansion-panel-header>
<mat-panel-title>TransactionHistory</mat-panel-title>
<mat-panel-description *ngIf="isLoadingTransactions">
<mat-spinner diameter="16"></mat-spinner>
Loading...
</mat-panel-description>
<mat-panel-description *ngIf="transactionError">
Failed to load — click to retry
</mat-panel-description>
</mat-expansion-panel-header>
<!--
matExpansionPanelContent: content is NOTin the DOM until first open.
After first open, content persists — the component survives close/reopen.
Correctfor: data tables, charts, read-only displays.
Wrongfor: forms that should reset on close — use *ngIf instead.
-->
<ng-template matExpansionPanelContent>
<div *ngIf="isLoadingTransactions"class="loading-state">
<mat-spinner diameter="32"></mat-spinner>
</div>
<button
*ngIf="transactionError && !isLoadingTransactions"
mat-stroked-button
(click)="retryTransactionLoad()"
>
Retry
</button>
<mat-list *ngIf="!isLoadingTransactions && !transactionError">
<mat-list-item
*ngFor="let txn of transactions; trackBy: trackByTxnId"
>
<span matListItemTitle>{{ txn.id }}</span>
<span matListItemLine>
{{ txn.amount | currency:txn.currency }}
—
{{ txn.timestamp | date:'mediumDate' }}
</span>
</mat-list-item>
</mat-list>
<p *ngIf="!isLoadingTransactions && !transactionError && transactions.length === 0">
No transactions found.
</p>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
`
})
exportclassUserProfileSectionsComponent {
@Input() userId!: string;
@Input() userEmail!: string;
transactions: TransactionSummary[] = [];
isLoadingTransactions = false;
transactionError = false;
// Guard: ensures the HTTP call fires exactly once per component lifetime.// matExpansionPanelContent keeps the DOM alive — without this guard,// (opened) would re-fetch on every panel open.private transactionsLoaded = false;
constructor(privatereadonly cdr: ChangeDetectorRef) {}
onTransactionPanelOpened(): void {
// Guard check first — this is the pattern that prevents duplicate fetches.if (this.transactionsLoaded) {
return;
}
this.loadTransactions();
}
retryTransactionLoad(): void {
// Explicit retry resets the guard and re-fetches.// Only called by the user clicking Retry after an error.this.transactionsLoaded = false;
this.transactionError = false;
this.loadTransactions();
}
privateloadTransactions(): void {
this.isLoadingTransactions = true;
this.transactionError = false;
fetchUserTransactions(this.userId)
.pipe(
catchError(err => {
console.error('[TransactionPanel] Failed to load transactions:', err);
this.transactionError = true;
returnEMPTY;
}),
finalize(() => {
this.isLoadingTransactions = false;
this.transactionsLoaded = true;
// markForCheck() — not detectChanges() — schedules re-render// at the next CD cycle. Never call detectChanges() from async// callbacks in OnPush components.this.cdr.markForCheck();
})
)
.subscribe(txns => {
this.transactions = txns;
});
}
trackByTxnId(_index: number, txn: TransactionSummary): string {
return txn.id;
}
}
Output
Page load: ZERO HTTP requests fire. Transaction History panel content does not exist in the DOM.
User opens 'Transaction History' panel for the first time:
[HTTP] Fetching transactions for user [userId] — fires exactly once
Panel header shows spinner in description field while loading
Transactions render in mat-list after data arrives
cdr.markForCheck() schedules re-render — no ExpressionChangedAfterItHasBeenCheckedError
User closes the panel and reopens it:
onTransactionPanelOpened() runs — transactionsLoaded is true, returns immediately
No HTTP request. No loading state. Content renders instantly from persisted DOM.
'Failed to load — click to retry' appears in panel header description
Retry button appears inside panel body
User clicks Retry — retryTransactionLoad() resets guard, re-fetches
matExpansionPanelContent vs *ngIf — The Promise Each Makes
matExpansionPanelContent: defer until first open, then persist forever — the component stays alive in the change detection tree, data survives close and reopen, HTTP call fires once
*ngIf on panel body: destroy on close, create fresh on reopen — component lifecycle starts over, form state resets, HTTP call fires on every open
Use matExpansionPanelContent for: data tables, charts, lists, read-only displays — anything where load-once-display-always is the correct behavior
Use *ngIf for: forms, wizards, multi-step flows, anything that must present a clean state each time the panel opens
Angular 17+ @defer goes one step further: it can split the component's module into a separate bundle chunk, loaded only when the panel first opens — correct choice for panels containing heavy dependencies like charting libraries or rich text editors
Production Insight
matExpansionPanelContent persists content after close — (opened) fires on every open but the DOM is only created once. Without the loaded guard, every panel open re-fires the HTTP call. On slow connections, this creates a race condition: the second request resolves after the first render, replacing displayed data mid-view with a visible content flicker.
The finalize() operator is the correct place for loading state cleanup — it runs regardless of success or error, which means isLoadingTransactions always returns to false even when the HTTP call fails. Placing cleanup only in the subscribe callback leaves the loading state stuck on error.
Rule: always add a private loaded guard in the (opened) handler. Always handle errors with catchError that returns EMPTY. Always use finalize() for cleanup. This three-part pattern makes every lazy panel robust to error conditions and duplicate opens.
Key Takeaway
matExpansionPanelContent defers first render and then persists — *ngIf destroys and recreates on every toggle. These are not interchangeable. Choosing the wrong one means either your form never resets or your data refetches on every open.
The loaded guard pattern — a private boolean checked at the top of the (opened) handler — is what separates a working lazy panel from a panel that makes a duplicate HTTP call on every open and occasionally shows stale data flickering.
Punchline: if your form inside a panel shows the previous user's input when opened fresh, you're using matExpansionPanelContent. Switch to *ngIf. If your panel makes an HTTP call every time it opens, you're missing the loaded guard. Add it.
Lazy Loading Strategy Decisions
IfPanel contains read-only data display — tables, charts, transaction lists
→
UseUse matExpansionPanelContent with a loaded guard — defer first render, persist after close, HTTP call fires exactly once
IfPanel contains a form that must start fresh each open
→
UseUse *ngIf="activePanelId === section.id" on the panel body — destroys form component on close, creates a clean instance on reopen
IfPanel contains a heavy dependency — charting library, rich text editor, map component (Angular 17+)
→
UseUse @defer (on interaction) on the panel content — splits the dependency into a separate bundle chunk, loaded only when the panel first opens
IfPanel data must be fresh on every open — live dashboard, real-time metrics
→
UseUse matExpansionPanelContent but skip the loaded guard — let (opened) re-fetch every time. Consider adding a loading spinner in the header description during the refresh
IfPanel load fails — need user-triggered retry without destroying the panel
→
UseKeep matExpansionPanelContent, add an error state flag, show Retry button inside the panel. Retry handler resets the loaded guard and calls the fetch again
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 static 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 crawling.
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. No ngOnDestroy cleanup required.
The form-to-accordion wiring pattern below uses a controlSectionMap — a plain object that maps each form control name to the section it lives in. When submit fires, markAllAsTouched() makes all error states visible, then isSectionInvalid() scans the map to find which section has the first invalid control. Setting activeSectionId to that section ID opens it. The error message is already visible inside the panel because the control is touched and invalid. This is the entire flow — no DOM manipulation, no ViewChild, no subscription.
SettingsFormAccordion.component.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
// io.thecodeforge — Settings Form with Validation-Driven Accordion// Auto-expands the first section containing invalid controls on submit.// No @ViewChild, no .open(), no subscription to manage.import {
Component,
OnInit,
OnDestroy,
ChangeDetectionStrategy,
ChangeDetectorRef
} from'@angular/core';
import {
FormBuilder,
FormGroup,
Validators,
ReactiveFormsModule
} from'@angular/forms';
import { CommonModule } from'@angular/common';
import { MatExpansionModule } from'@angular/material/expansion';
import { MatFormFieldModule } from'@angular/material/form-field';
import { MatInputModule } from'@angular/material/input';
import { MatButtonModule } from'@angular/material/button';
import { Subject } from'rxjs';
import { takeUntil } from'rxjs/operators';
// Const assertion gives us a typed union for free.exportconst SETTINGS_SECTIONS = [
'profile',
'notifications',
'security'
] asconst;
exporttypeSettingsSectionId = typeof SETTINGS_SECTIONS[number];
@Component({
selector: 'tcf-settings-form-accordion',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatExpansionModule,
MatFormFieldModule,
MatInputModule,
MatButtonModule
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form [formGroup]="settingsForm" (ngSubmit)="onSubmit()">
<mat-accordion multi="false">
<!--
Profile section.
[expanded] driven by activeSectionId — no ViewChild, no .open().
isSectionInvalid() reads the form state to show the error indicator
in the header without needing to touch the panel DOM.
-->
<mat-expansion-panel
[expanded]="activeSectionId === 'profile'"
(opened)="activeSectionId = 'profile'"
>
<mat-expansion-panel-header>
<mat-panel-title>Profile</mat-panel-title>
<mat-panel-description
*ngIf="isSectionInvalid('profile')"class="error-description"
aria-live="polite"
>
Fix errors before saving
</mat-panel-description>
</mat-expansion-panel-header>
<!--
matExpansionPanelContent here is intentional — this profile section
shows display data alongside the input, and the form field
should persist state between opens (user shouldn't lose typed input
just because they open another panel briefly).
TheSecurity section uses the same pattern for consistency.
-->
<ng-template matExpansionPanelContent>
<mat-form-field appearance="outline"class="full-width">
<mat-label>DisplayName</mat-label>
<input matInput formControlName="displayName" autocomplete="name" />
<mat-error *ngIf="settingsForm.get('displayName')?.hasError('required')">
Display name is required
</mat-error>
<mat-error *ngIf="settingsForm.get('displayName')?.hasError('minlength')">
Minimum2 characters required
</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"
aria-live="polite"
>
Fix errors before saving
</mat-panel-description>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<mat-form-field appearance="outline"class="full-width">
<mat-label>NotificationEmail</mat-label>
<input matInput formControlName="notificationEmail"type="email" autocomplete="email" />
<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"
aria-live="polite"
>
Fix errors before saving
</mat-panel-description>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<mat-form-field appearance="outline"class="full-width">
<mat-label>CurrentPassword</mat-label>
<input matInput type="password" formControlName="currentPassword" autocomplete="current-password" />
<mat-error *ngIf="settingsForm.get('currentPassword')?.hasError('required')">
Current password is required to save changes
</mat-error>
</mat-form-field>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
<div class="form-actions">
<button mat-raised-button color="primary"type="submit">
SaveSettings
</button>
</div>
</form>
`
})
exportclassSettingsFormAccordionComponentimplementsOnInit, OnDestroy {
settingsForm!: FormGroup;
activeSectionId: SettingsSectionId = 'profile';
privatereadonly destroy$ = newSubject<void>();
// Maps each form control name to the section panel it lives in.// onSubmit() uses this to find the first section containing an invalid control.// Add new controls here when extending the form — the validation-jump logic// works automatically without touching onSubmit().privatereadonly controlSectionMap: Record<string, SettingsSectionId> = {
displayName: 'profile',
notificationEmail: 'notifications',
currentPassword: 'security'
};
constructor(
privatereadonly fb: FormBuilder,
privatereadonly 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 the given section is invalid AND touched.// Touched check prevents showing errors in sections the user hasn't visited yet.// After markAllAsTouched() on submit, all controls become touched.isSectionInvalid(sectionId: SettingsSectionId): boolean {
returnObject.entries(this.controlSectionMap)
.filter(([, section]) => section === sectionId)
.some(([controlName]) => {
const control = this.settingsForm.get(controlName);
return control ? control.invalid && control.touched : false;
});
}
onSubmit(): void {
// markAllAsTouched() makes all validation errors visible simultaneously.// Without this, only controls the user has interacted with show errors.this.settingsForm.markAllAsTouched();
if (this.settingsForm.invalid) {
// Find the first section (in SETTINGS_SECTIONS order) that has an error.// SETTINGS_SECTIONS order defines the visual priority — profile errors// surface before notification errors, which surface before security errors.const firstInvalidSection = SETTINGS_SECTIONS.find(section =>
this.isSectionInvalid(section)
);
if (firstInvalidSection) {
// One assignment. No ViewChild. No .open(). No subscription.// The template's [expanded] binding opens the correct panel immediately.this.activeSectionId = firstInvalidSection;
// markForCheck() — not detectChanges() — schedules the re-render// at the next CD cycle. Required in OnPush when state changes// outside Angular's zone or from a synchronous event handler.this.cdr.markForCheck();
}
return;
}
// Form is valid — proceed with save.
console.log('Saving settings:', this.settingsForm.value);
}
ngOnDestroy(): void {
// destroy$ is here as the standard cleanup subject pattern.// In this specific component there are no subscriptions to clean up// because we drive everything through [expanded] bindings.// If you add takeUntil(this.destroy$) pipes later, this is already here.this.destroy$.next();
this.destroy$.complete();
}
}
Output
Initial render: 'Profile' panel is open. Notifications and Security panels collapsed.
User clicks 'Save Settings' without filling any fields:
markAllAsTouched() fires — all form controls become touched
isSectionInvalid('profile') returns true — displayName is required and now touched
activeSectionId = 'profile' — Profile panel remains open
'Fix errors before saving' appears in Profile panel header description (aria-live announces it)
'Display name is required' mat-error appears inside the Profile panel
cdr.markForCheck() schedules the OnPush re-render
User types a valid display name (2+ chars), clicks Save again:
Profile section now valid — isSectionInvalid('profile') returns false
'notifications' isSectionInvalid check: notificationEmail is empty string, Validators.email
passes for empty string (email validator only fails on non-empty invalid emails)
'security' isSectionInvalid check: currentPassword is required, touched, and empty — INVALID
activeSectionId = 'security' — accordion jumps to Security panel
'Fix errors before saving' appears in Security header description
'Current password is required' error surfaces inside Security panel
User fills password, clicks Save:
Form valid — console.log('Saving settings:', ...) fires
No section jump — all sections valid
The Classic Leak: @ViewChild + QueryList = Zombie Subscriptions
If you use @ViewChildren(MatExpansionPanel) and call .open() imperatively on a dynamic panel list, you must subscribe to QueryList.changes to handle panels being added or removed — and that subscription must be cleaned up in ngOnDestroy. Skip either the subscription or the cleanup and you get a zombie subscription that accumulates on every component mount and never GCs. The symptom: memory usage that climbs without bound over a navigation session. The fix is not 'add the unsubscribe' — the fix is don't do it. Drive state through [expanded] bindings. Zero subscriptions means zero cleanup means zero leaks.
Production Insight
@ViewChildren(MatExpansionPanel) with imperative .open() calls is a pattern I've seen in at least a dozen Angular codebases. It works in development. It leaks in production. The 40MB/minute figure came from a real settings page that re-rendered its panel list on every navigation and skipped the QueryList.changes cleanup.
The controlSectionMap pattern scales cleanly. Adding a new settings section means: add the section ID to SETTINGS_SECTIONS, add a new panel in the template, add the new control names to controlSectionMap. The onSubmit() logic and isSectionInvalid() work automatically — they scan the map dynamically, they don't need to know how many sections exist.
Rule: if your accordion needs to be driven from code, one string assignment beats twenty lines of ViewChild DOM manipulation. The string is testable without a rendered component. The ViewChild reference is not.
Key Takeaway
Programmatic accordion control through a single state variable is one assignment — no ViewChild, no .open(), no subscription, no ngOnDestroy cleanup required.
@ViewChildren with QueryList.changes subscriptions is a memory leak in a trench coat. It looks like a solution to a real problem. The real solution is to not need the DOM reference at all.
Punchline: every accordion that needs to be driven from code can be driven by changing one string. If you're reaching for ViewChild to open a panel, you're solving the wrong problem.
Programmatic Panel Control Decisions
IfNeed to open a specific panel programmatically — validation error, deep link, tutorial flow
→
UseSet activeSectionId to the target panel's ID — one assignment, no ViewChild, no subscription
IfNeed to open a panel on route navigation — deep link to specific section via URL fragment
→
UseRead route.snapshot.fragment in ngOnInit, validate it against the panel ID list, set activeSectionId if it matches
IfNeed to auto-expand the first section containing validation errors on form submit
→
UseCall markAllAsTouched(), iterate SETTINGS_SECTIONS in order using isSectionInvalid() with a controlSectionMap, set activeSectionId to the first match
IfAlready using @ViewChildren(MatExpansionPanel) with .open() in production
→
UseRefactor to [expanded] binding pattern — the leak accumulates on every navigation and gets worse over time, not better
IfAngular 17+ project using signals
→
UseReplace activeSectionId string field with signal<SettingsSectionId>('profile') — reactive integration with OnPush, no markForCheck() needed in onSubmit()
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="button", aria-expanded, aria-controls), keyboard navigation (Enter, Space, Tab, Arrow keys), and a JavaScript animation player. That's roughly 15KB of JavaScript in your bundle before tree-shaking, plus one JS animation player registered with Angular's animation engine per panel instance — even collapsed panels contribute to the engine's bookkeeping.
For most dashboard and settings UIs with under 20 panels, that trade-off is completely reasonable. You'd build the ARIA scaffolding and keyboard navigation yourself anyway, and Material does it correctly. The bundle cost is amortized across the rest of your Material component usage.
But there are specific scenarios where it's the wrong choice, and they come up more often than you'd expect.
The high-panel-count case: I watched a fintech mobile app try to render 60 expansion panels on a single compliance document viewer page using mat-expansion-panel. Material's animation system created 60 JS animation players simultaneously. On mid-range Android devices (the demographic that matters most for fintech in many markets), opening any panel triggered 380ms of scripting work — far above the 16ms frame budget. The fix was replacing mat-expansion-panel with CSS max-height transitions on a div, backed by a Set<string> for state. Same visual result. Zero Material animation overhead. Jank dropped from 380ms to under 16ms on the same device.
The SSR/SEO case: if you're building a landing page FAQ accordion where the content needs to be indexed by search engines, mat-expansion-panel is the wrong tool. It requires Angular's runtime to render, which means Angular Universal or the new Angular 17 SSR with hydration. Native <details> and <summary> elements render on the server with zero JavaScript, are indexed correctly by Googlebot, and are accessible by default. For marketing content, there's no argument for a JavaScript accordion.
The bundle-sensitive micro-frontend case: if your app is composed of independently deployed Angular micro-frontends with strict bundle size limits, importing MatExpansionModule for a simple FAQ list in one shell consumes budget that should go to application code. CSS max-height with manual ARIA attributes costs literally 0KB of JavaScript.
ComplianceDocumentViewer.component.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
// io.thecodeforge — High-Count Accordion Without Material// Use this pattern when:// - 40+ panels on a single page (Material animation players are measurable overhead)// - SSR-first pages where SEO requires server-rendered content// - Bundle-sensitive micro-frontends that can't import MatExpansionModule//// What you get for free with mat-expansion-panel that you must add manually here:// - ARIA: aria-expanded, aria-controls, role=region (added below)// - Keyboard: Enter and Space on trigger (added below via keydown bindings)// - Animation: CSS max-height transition replaces Material's JS animation playerimport {
Component,
Input,
TrackByFunction,
ChangeDetectionStrategy
} from'@angular/core';
import { CommonModule } from'@angular/common';
import { MatCheckboxModule } from'@angular/material/checkbox';
exportinterfaceComplianceClause {
id: string;
title: string;
content: string;
requiresAcknowledgement: boolean;
}
@Component({
selector: 'tcf-compliance-document-viewer',
standalone: true,
imports: [CommonModule, MatCheckboxModule],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [`
.clause-panel {
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-bottom: 8px;
overflow: hidden;
}
/*
Button reset + full-width layout.
Using a <button> (not a div) for the trigger gives us keyboard focus,
Enter/Space activation, and correct button role for free at the HTML level.
*/
.clause-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
cursor: pointer;
background: #fafafa;
border: none;
width: 100%;
text-align: left;
font-size: 0.875rem;
font-weight: 500;
transition: background 150ms ease;
}
.clause-header:hover,
.clause-header:focus-visible {
background: #f0f0f0;
outline: 2px solid #1976d2;
outline-offset: -2px;
}
/*
max-height transition: GPU-composited on most browsers.
Set max-height high enough for your tallest panel.
Too low = content clipped. Too high = sluggish close animation.
For variable-height panels, consider max-height:none on .expanded
with a fixed height on the non-expanded state.
*/
.clause-body {
max-height: 0;
overflow: hidden;
transition: max-height 220ms ease-out, padding 220ms ease-out;
padding: 0 16px;
}
.clause-body.expanded {
max-height: 1000px;
padding: 16px;
}
.clause-chevron {
display: inline-block;
transition: transform 220ms ease;
font-size: 12px;
color: #666;
}
.clause-chevron.expanded {
transform: rotate(180deg);
}
`],
template: `
<div class="compliance-document" role="list">
<div
*ngFor="let clause of clauses; trackBy: trackByClauseId"class="clause-panel"
role="listitem"
>
<!--
Use <button> — not <div role="button"> — for the trigger.
Native button gives keyboard focus, Enter/Space, and button role at zero cost.
aria-expanded is the ARIA attribute screen readers use to announce state.
aria-controls links the button to the region it controls.
-->
<button
class="clause-header"
[id]="'clause-header-' + clause.id"
[attr.aria-expanded]="openClauseIds.has(clause.id)"
[attr.aria-controls]="'clause-body-' + clause.id"
(click)="toggleClause(clause.id)"
>
<span>{{ clause.title }}</span>
<span
class="clause-chevron"
[class.expanded]="openClauseIds.has(clause.id)"
aria-hidden="true"
>▼</span>
</button>
<!--
role="region" + aria-labelledby links this region to its header button.
Screen readers announce "[clause title] region" when the user navigates into it.
This replicates the ARIA structure mat-expansion-panel provides automatically.
-->
<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>
`
})
exportclassComplianceDocumentViewerComponent {
@Input() clauses: ComplianceClause[] = [];
// Set<string> gives O(1) lookup for [class.expanded] and [aria-expanded] bindings.// Multi-open by default — no single-open enforcement because compliance// documents often require users to read multiple clauses simultaneously.
openClauseIds = newSet<string>();
trackByClauseId: TrackByFunction<ComplianceClause> = (_, clause) => clause.id;
toggleClause(clauseId: string): void {
if (this.openClauseIds.has(clauseId)) {
this.openClauseIds.delete(clauseId);
} else {
this.openClauseIds.add(clauseId);
}
// Re-assign the Set reference to trigger OnPush change detection.// OnPush checks reference equality for objects — mutating a Set in place// does not trigger a re-render. New reference does.this.openClauseIds = newSet(this.openClauseIds);
}
onAcknowledgement(clauseId: string, acknowledged: boolean): void {
console.log(`Clause ${clauseId} acknowledged: ${acknowledged}`);
// In production: emit an output event or dispatch to a store.
}
}
Output
60 clauses render with ZERO Material animation overhead.
No JS animation players registered. No Angular animation engine involvement.
Initial render: all panels collapsed. DOM includes all clause bodies (max-height: 0).
User clicks any clause header:
toggleClause() runs — clause ID added to Set
Set re-assigned — OnPush CD triggered
.clause-body.expanded CSS class applies — max-height transitions from 0 to 1000px
Total scripting time on mid-range Android: <2ms
CSS transition time: 220ms (GPU-composited, no main thread involvement)
Aria behavior:
Button announces 'aria-expanded: true' to screen readers on open
Region is labelled by the header button via aria-labelledby
Screen reader announces '[clause title] region' on navigation into body
trackBy behavior:
When clause array is replaced (sort, filter), only changed DOM nodes update
Open/closed state (Set) survives array replacement — IDs are stable
Interview Gold: When Does mat-expansion-panel Become the Wrong Choice?
Three clear signals: (1) You have 40+ panels and Chrome DevTools Performance tab shows Animation tasks consuming >16ms per open — Material's JS animation players are the bottleneck. (2) You need SSR with SEO-critical content — use native <details>/<summary> instead, which render on the server with zero JavaScript and are indexed by Googlebot correctly. (3) You're in a micro-frontend with strict bundle size constraints that can't absorb MatExpansionModule. In all three cases, a CSS max-height transition with a Set-backed open-state tracker delivers the same UX in 30 lines of code with zero dependency cost.
Production Insight
The Set re-assignment pattern for OnPush components is a recurring source of confusion. Mutating a Set in place (openClauseIds.delete(id)) does not create a new reference — OnPush won't detect the change and the view won't update. Always re-assign: this.openClauseIds = new Set(this.openClauseIds). This is the same reason you assign a new array reference instead of push() when using OnPush with arrays.
The max-height transition has one known limitation: if your panel content height is variable and can exceed your max-height value, content gets clipped. Set max-height conservatively high for your tallest realistic panel. For genuinely unbounded content, use max-height:none on the expanded state and animate height using a ResizeObserver-based JavaScript approach — but that's a very specific trade-off that only makes sense when content height is truly unpredictable.
Rule: profile before you build. If you have 40 panels and are considering mat-expansion-panel, spend 10 minutes with Chrome DevTools Performance tab on a 4x CPU throttle before writing the first line of template. If Animation tasks are already appearing at 40 panels in your test data, you've found the answer before writing production code.
Key Takeaway
mat-expansion-panel gives you ARIA, theming, keyboard navigation, and animation for free — but registers a JS animation player per panel, which becomes measurable overhead at 40+ panels on mid-range mobile devices.
CSS max-height transitions are GPU-composited with flat cost regardless of panel count. Adding 60 more panels doesn't add 60 more animation players — the cost is literally the same as one.
Punchline: if your compliance document viewer has 60 panels and 380ms jank on the device your users actually use, the right tool is a div with a CSS transition, a Set, and 30 lines of TypeScript — not a tuned Material configuration.
mat-expansion-panel vs CSS div — When to Switch
IfStandard dashboard or settings UI with under 20 panels, team already using Angular Material
→
UseUse mat-expansion-panel — ARIA, theming, keyboard navigation, and animation are free. The 15KB bundle cost is justified.
If40+ panels on a single page — compliance documents, FAQ pages, large data-heavy views
→
UseProfile first with 4x CPU throttle. If Animation tasks exceed 16ms, switch to CSS max-height transitions with a Set-backed state tracker.
IfSSR-first landing page, FAQ section, or marketing accordion with SEO-critical content
→
UseUse native <details>/<summary> — zero JavaScript, full server rendering, correct Googlebot indexing, no Angular runtime required
IfMicro-frontend with strict bundle size budget that can't absorb MatExpansionModule
→
UseUse CSS max-height with manual ARIA attributes — 30 lines of code, zero dependency, identical visual result
IfMid-range mobile is in the primary device target and animation performance is a concern
→
UseTest on a real device or with 6x CPU throttle before committing. If mat-expansion-panel shows jank, CSS transitions are the answer.
● Production incidentPOST-MORTEMseverity: high
40 expansion panels load eagerly — page takes 11 seconds to become interactive
Symptom
Lighthouse Performance score drops to 12. Time to Interactive (TTI) is 11.2 seconds on desktop, 22 seconds on mobile. Chrome DevTools Network tab shows 40 concurrent XHR requests firing within 200ms of page load — all of them from chart components inside collapsed panels the user hasn't touched. The main thread is blocked for 8 seconds by change detection cycles across thousands of DOM nodes inside those collapsed panels. Memory usage on load: 340MB, where a comparable page without the accordion sits at 85MB.
Assumption
The team's first instinct was the backend. API response times averaged 200ms per request — 40 concurrent requests meant the last one resolved at ~800ms on a good day. They spent a week adding Redis caching and optimizing database queries. API response times dropped from 200ms to 50ms per request. TTI improved by exactly 1.5 seconds. The bottleneck was never the API — it was the 38 hidden panel subtrees Angular was running change detection across on every user interaction, every 16ms animation frame, every keystroke in any form field on the page.
Root cause
Every mat-expansion-panel rendered its content eagerly — the chart components inside collapsed panels were fully instantiated at page load. Their ngOnInit hooks fired. HTTP requests executed for data the user would statistically never view in that session. Angular's change detection tree included all 40 chart subtrees, meaning every interaction anywhere on the page triggered re-evaluation across thousands of component bindings inside panels the user couldn't see.
The panels used Material's built-in CSS visibility toggle, not DOM removal. Visually hidden does not mean computationally absent in Angular. The components were alive, subscribed to their observables, and consuming change detection budget on every cycle. The team had also skipped OnPush on the chart components, meaning they participated in every zone-triggered detection pass.
Fix
1. Wrap each panel's content body in <ng-template matExpansionPanelContent> — this defers component instantiation until first open. The ngOnInit hook doesn't fire. The HTTP call doesn't execute. The DOM subtree doesn't exist. 2. Add a private loaded boolean flag per panel to prevent re-fetching on subsequent opens — matExpansionPanelContent keeps the DOM alive after close, so the guard prevents duplicate requests. 3. For panels containing forms that must reset on close, use ngIf="activePanelId === section.id" on the panel body instead of matExpansionPanelContent — this destroys and recreates the content on every toggle. 4. Add trackBy to every ngFor rendering panel lists — prevents Angular from tearing down and rebuilding all panel DOM nodes on array reference changes. 5. Add ChangeDetectionStrategy.OnPush to all chart components inside panels — they should only update when their inputs change, not on every zone event. 6. Set up a Lighthouse CI check that fails the build if TTI exceeds 3 seconds on a simulated mid-tier mobile device.
Key lesson
mat-expansion-panel renders content eagerly by default — collapsed panels still instantiate components, fire ngOnInit, and make HTTP calls
matExpansionPanelContent defers instantiation until first open — the component doesn't exist in the change detection tree until the user opens the panel
CSS visibility and display:none do not remove components from Angular's change detection tree — only DOM removal via *ngIf or matExpansionPanelContent does
Always profile with Chrome DevTools Performance tab before blaming the backend — in this incident and most like it, the bottleneck was change detection overhead in the frontend, not API latency
OnPush change detection on panel content components is not optional in high-panel-count scenarios — default change detection on 40 chart components is 40 subtrees evaluated on every zone event
Production debug guideCommon mat-expansion-panel failures and how to diagnose them — symptoms first, then the command or inspection that cuts to the root cause6 entries
Symptom · 01
Multiple panels open simultaneously despite multi=false on mat-accordion
→
Fix
Inspect the rendered DOM in Chrome DevTools. Look for ng-container or ng-template elements sitting between mat-accordion and mat-expansion-panel. Angular Material's ContentChildren query only reaches direct content children — a structural directive (ngIf, ngFor with a wrapper div) breaks that relationship silently. The accordion stops tracking the wrapped panels and cannot enforce single-open mode on them. Fix: move the *ngIf condition inside the panel body or onto a child element, never on the mat-expansion-panel itself. If conditional panel inclusion is genuinely needed, use [disabled] to exclude the panel from interaction without removing it from the accordion's content children.
Symptom · 02
HTTP call fires every time a panel opens — data refetches on every open, sometimes causing visible content flicker
→
Fix
Check whether the (opened) handler has a loaded guard. matExpansionPanelContent persists content in the DOM after close, so the component survives — but (opened) fires on every open event regardless. Without a guard, the HTTP call re-fires every time. Add a private boolean flag (e.g., private transactionsLoaded = false) checked at the top of the handler, set to true after the first successful load. On flicker specifically: the second request resolves after the first render, replacing the displayed data mid-view. The guard eliminates both the duplicate request and the flicker.
Symptom · 03
ExpressionChangedAfterItHasBeenCheckedError in the console — intermittent, harder to reproduce in production than development
→
Fix
This error fires when Angular detects a binding value changed after it completed the current change detection pass. In OnPush components with accordion panels, the most common cause is an Observable subscription inside the component that resolves synchronously (or resolves and immediately calls cdr.detectChanges()) during the change detection cycle. Replace cdr.detectChanges() with cdr.markForCheck() — markForCheck() schedules a re-check at the next detection cycle without running synchronously mid-pass. In Angular 17+ with signals, this error class largely disappears because signal reads are tracked reactively rather than evaluated during CD passes.
Symptom · 04
Memory usage climbs steadily on a settings page with accordion panels — no apparent upper bound
→
Fix
Take a heap snapshot in Chrome DevTools Memory tab before and after navigating to the settings page five times. Switch to Comparison view and filter for 'Detached' DOM nodes. If mat-expansion-panel nodes are accumulating in the detached set, something is holding references to destroyed component instances. The most common culprit: @ViewChildren(MatExpansionPanel) with a QueryList.changes subscription that isn't cleaned up in ngOnDestroy. Each navigation creates a new subscription on the new QueryList while the old ones persist. Search the codebase for @ViewChildren(MatExpansionPanel) and verify every QueryList.changes pipe has takeUntil(destroy$) or an explicit unsubscribe in ngOnDestroy. Better: refactor to [expanded] bindings entirely and remove the ViewChildren reference.
Symptom · 05
Panel open/close animations stutter — 300-400ms jank on mid-range Android devices, smooth on desktop
→
Fix
Profile with Chrome DevTools Performance tab using CPU throttling set to 4x or 6x slowdown (simulating mid-range mobile). Look for 'Animation' tasks in the flame graph exceeding 16ms. If you have 40+ panels, Angular's animation engine registers a JS animation player for each mat-expansion-panel — even collapsed ones contribute to the animation engine's bookkeeping overhead. Confirm by temporarily adding [@.disabled]="true" to each panel element. If jank drops to under 16ms with animations disabled, the animation players are the bottleneck. The fix: replace mat-expansion-panel with CSS max-height transitions for this specific use case — GPU-composited transitions have flat cost regardless of panel count.
Symptom · 06
Form inside a panel doesn't reset when the panel closes and reopens — user sees previous input values on reopened panel
→
Fix
Check if the panel body is wrapped in <ng-template matExpansionPanelContent>. If it is, the form component persists in the DOM after close — its state, including input values, error states, and touched flags, all survive. matExpansionPanelContent is the wrong directive for form-containing panels that need a clean slate. Replace it with *ngIf="activePanelId === section.id" on the panel body. This destroys the form component on close and creates a fresh instance on reopen — form state resets automatically because the component is new. The trade-off: the HTTP call to populate the form will re-fire on every open, which is usually correct behavior for a form anyway.
★ Expansion Panel Performance Debug Cheat SheetWhen Angular Material expansion panels cause performance issues or state bugs in production, these browser-based diagnostics cut to the root cause in under 5 minutes. Follow the symptom that matches yours — don't run all of them.
Page takes 5+ seconds to become interactive with many panels — profiling shows main thread blocked at load−
Immediate action
Count how many HTTP requests fire on page load before the user opens any panel. If the count matches your panel count, you have eager loading. Verify this before touching any code.
Commands
Open Chrome DevTools > Network tab > filter by 'Fetch/XHR' > hard reload the page (Ctrl+Shift+R) > observe requests firing before any user interaction
Open Chrome DevTools > Performance tab > set CPU throttle to 4x slowdown > record 5 seconds from page load > look for 'Evaluate Script' and 'Animation' long tasks in the flame graph
Fix now
If 20+ XHR requests fire at load before any panel interaction, wrap panel content in <ng-template matExpansionPanelContent> immediately — that's the direct cause. If the flame graph shows Animation tasks exceeding 16ms with 40+ panels, the Material animation system is the bottleneck — evaluate switching to CSS max-height transitions. If Evaluate Script tasks dominate, change detection overhead from missing OnPush on panel content components is the issue.
Memory usage grows on every navigation to the accordion page — suspected subscription leak from ViewChildren usage+
Immediate action
Take heap snapshots before and after navigating to the page 5 times. You're looking for accumulating detached DOM nodes that should have been garbage collected when the component destroyed.
Commands
Chrome DevTools > Memory tab > Take heap snapshot > navigate to accordion page 5 times > Take second heap snapshot > switch to Comparison view > sort by 'Size Delta' descending
In the Comparison view, filter retained objects by 'Detached' — look specifically for MatExpansionPanel or your component class appearing in the detached set with growing counts across snapshots
Fix now
If MatExpansionPanel instances accumulate in the detached set, search the codebase for '@ViewChildren(MatExpansionPanel)' and 'QueryList.changes.subscribe'. Every subscribe call here needs a matching takeUntil(destroy$) in the pipe or an explicit unsubscribe in ngOnDestroy. The permanent fix: refactor accordion state to [expanded] bindings driven by activePanelId — eliminates the ViewChildren reference entirely and the leak cannot happen.
Panel state doesn't survive page refresh or browser back-button navigation — user loses context on every navigation+
Immediate action
Check whether the active panel ID is persisted anywhere beyond component memory — URL fragment, query param, sessionStorage, or a state management store.
Commands
In browser console: window.location.hash — check if the fragment updates when you open a panel. If it's always empty, the panel state exists only in component memory and is lost on any navigation.
Search component ngOnInit for any fragment restoration logic: this.route.snapshot.fragment. If absent, the component never attempts to restore state on init.
Fix now
Add router.navigate([], { fragment: sectionId, replaceUrl: true }) inside the (opened) event handler — this writes the active panel ID to the URL without adding a browser history entry. In ngOnInit, restore from the fragment: const fragment = this.route.snapshot.fragment; if (fragment && this.sections.some(s => s.id === fragment)) { this.activeSectionId = fragment; }. replaceUrl:true is important — without it, every panel open adds a browser history entry and back-button navigation becomes disorienting.
mat-expansion-panel (Material) vs CSS max-height + div
Aspect
mat-expansion-panel (Material)
CSS max-height + div
Bundle cost
~15KB after tree-shaking — justified if the rest of your app uses Material
0KB — zero JavaScript dependency
Animation performance
One JS animation player per panel registered with Angular's animation engine — measurably slow at 40+ panels on mid-range mobile
GPU-composited CSS transition — flat cost regardless of panel count, no main thread involvement
ARIA accessibility
Built-in and correct: aria-expanded, aria-controls, role=button on trigger, role=region on body
Manual — you own every ARIA attribute. Easy to get wrong. Use <button> not <div> for triggers or you lose keyboard focus for free.
Keyboard navigation
Built-in: Enter, Space, Tab, and arrow keys for panel-to-panel navigation
Enter and Space work automatically on <button> triggers. Arrow key navigation between panels requires additional keydown handlers.
SSR and SEO compatibility
Requires Angular Universal or Angular 17 SSR with hydration — content is not server-rendered by default
Full SSR with no JavaScript. For SEO-critical content, use native <details>/<summary> instead — zero JS, correct Googlebot indexing.
Single-open enforcement
mat-accordion multi=false handles it automatically via ContentChildren coordination
Manual — implement single-open in your component: clear the Set to one entry on toggle, or track a single string ID
Material theming and dark mode
Full Material theme integration out of the box — respects mat-theme variables, dark mode, density settings
Manual CSS — complete freedom, zero guardrails. Dark mode requires your own CSS custom properties or media query handling.
Reactive form integration
Clean — reactive forms inside mat-expansion-panel work identically to any other container
Clean — no difference. It's a div. Forms don't care.
State in URL / deep-linking
Manual — wire (opened)/(closed) events to router.navigate with replaceUrl:true
Manual — same implementation effort regardless of which accordion approach you use
When to choose
Standard dashboard or settings UI under 20 panels where team is already on Material
40+ panels, SSR-first landing pages, bundle-constrained micro-frontends, or any case where Material animation jank is measurable on target devices
Key takeaways
1
Never manage accordion open/closed state as a boolean[] mapped by array index. Use a single stable string identifier
it's serialisable to the URL fragment, testable without rendering, and correct regardless of how the panel list is sorted, filtered, or reordered. In Angular 17+, a signal<string> integrates with OnPush automatically and removes the need for manual markForCheck() calls.
2
matExpansionPanelContent and *ngIf on the panel body are not interchangeable. matExpansionPanelContent defers first render and persists after close
correct for data display. *ngIf destroys on close and creates fresh on reopen — correct for forms. Mixing them up means either your form never resets or your data refetches on every open. Pick based on whether the content should remember its state.
3
mat-accordion's single-open guarantee silently breaks the moment a structural directive sits between the accordion and its panel children. No warning. No error. Two panels open at the same time. ContentChildren queries direct children only
break that DOM relationship and you own the coordination problem yourself. Use [disabled] for conditional panel availability, not *ngIf on the panel element.
4
Reach for mat-expansion-panel when your team is on Material and you need accessibility scaffolding, theming, and keyboard navigation for free
it's worth the 15KB bundle cost for under 20 panels. Reach for CSS max-height transitions when you have 40+ panels and profiling shows Material animation players consuming more than 16ms per open event on your target device.
5
The @ViewChildren(MatExpansionPanel) + .open() pattern is a memory leak in a trench coat. It looks like a clean imperative solution. It creates zombie subscriptions that accumulate on every component mount and never garbage collect. The correct fix is not 'add the unsubscribe.' The correct fix is to not need the DOM reference at all
drive state through [expanded] bindings and one string assignment does what twenty lines of ViewChild manipulation attempted to do.
Common mistakes to avoid
5 patterns
×
Placing *ngIf directly on mat-expansion-panel to conditionally include or exclude panels
Symptom
The accordion appears to work correctly most of the time. But after a conditional panel is hidden and re-shown, two panels are open simultaneously despite multi=false on the accordion. The timing of the bug is inconsistent — sometimes it reproduces, sometimes it doesn't — which makes it hard to track down. In production, users report that clicking one panel header doesn't close the other.
Fix
Angular Material's mat-accordion uses ContentChildren to track its child panels. ContentChildren only sees direct content children — a structural directive (ngIf) creates an embedded view between the accordion and the panel, breaking that parent-child relationship. The accordion stops tracking the conditionally-included panel and can't enforce single-open mode on it.
Use [disabled]="!shouldShow" to hide a panel from user interaction while keeping it in the DOM and visible to the accordion's ContentChildren query. If the panel must be completely absent (because it contains content that shouldn't render at all), place the ngIf inside the panel body, not on the panel element itself.
×
Using (opened) to trigger an HTTP call without a loaded guard
Symptom
Every time the user opens the panel, the HTTP call fires again. API logs show duplicate requests for the same resource within seconds of each other. On slow connections, the visible content flickers — data from the first load is partially replaced by the second load mid-render. Users on fast connections may not notice, but the duplicate API calls show up in server metrics as unexpected load.
Fix
matExpansionPanelContent persists content in the DOM after close, so the component survives — but (opened) fires on every open event regardless. Without a guard, every open triggers a new fetch.
Add a private boolean flag (e.g., private dataLoaded = false) at the top of the (opened) handler. Set it to true inside the finalize() operator after the first load completes. Subsequent opens hit the guard and return immediately. Also add an error state and a Retry button — if the first load fails, the guard prevents a retry unless the user explicitly requests one.
×
Calling cdr.detectChanges() instead of cdr.markForCheck() in OnPush components
Symptom
ExpressionChangedAfterItHasBeenCheckedError appears intermittently in the browser console. The error surfaces on some page loads but not others, which makes it appear like a race condition. In production, some users see a blank panel body where content should appear — the component rendered but Angular aborted the change detection cycle due to the error.
Fix
detectChanges() runs change detection synchronously top-down from the component immediately. When called from within an async callback that resolves during an existing CD pass, Angular detects that a binding value changed after it already evaluated it — hence the error. markForCheck() schedules a re-check at the next CD cycle without running synchronously — it's always safe to call from async contexts, event handlers, and Observable callbacks in OnPush components. Replace every cdr.detectChanges() call in expansion panel event handlers with cdr.markForCheck().
×
Forgetting trackBy on *ngFor when rendering a dynamic panel list
Symptom
When the panel array reference changes — after a sort, filter, or API refresh — Angular tears down and rebuilds every panel DOM node from scratch. mat-expansion-panel components re-instantiate, their open/closed state resets to default, any lazy content loaded by matExpansionPanelContent is destroyed and must be re-loaded, and Material's open/close animations replay on all panels simultaneously. On a 30-panel page, this causes a visible 200-300ms flash that users describe as 'the page flickering.'
Fix
Provide a TrackByFunction that returns the panel's stable unique identifier from its data object. Angular uses this to determine which existing DOM nodes to reuse and which to create new. Panels whose ID hasn't changed keep their DOM node, their component instance, their lazy-loaded content, and their open/closed state — only genuinely new or removed panels trigger DOM changes. TrackBy is not a performance optimization for small lists — for dynamic accordion panels, it's a correctness requirement.
×
Managing accordion state as a boolean[] mapped by array index
Symptom
After adding a panel at the top of the list (index 0), every panel's state is now one position off. What was previously the open panel at index 1 ('shipping') now appears closed, while index 0 ('contact') appears open — but the user was on shipping. The bug is subtle because the panel list looks correct, but the wrong panel is highlighted. Sorting the panel list by completion status triggers the same desync. Debugging takes hours because the state looks correct from the template's perspective.
Fix
Use a single activeSectionId string keyed to the panel's stable data ID ('payment', 'shipping', 'contact'). The string maps to a meaningful identity in your data, not a position in an array. Reordering, adding, or removing panels doesn't affect the string — it still refers to 'payment', which is still the active panel. The string is serialisable to the URL fragment, testable in isolation without rendering the component, and correct regardless of how the panel array is sorted or filtered.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
mat-accordion enforces single-open mode with multi=false. What exactly b...
Q02SENIOR
A settings page has a Material accordion. Submitting the form should aut...
Q03SENIOR
A profile page renders transaction history inside a mat-expansion-panel ...
Q04SENIOR
You're reviewing a PR where a compliance document viewer uses mat-expans...
Q01 of 04SENIOR
mat-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 in a production bug report?
ANSWER
Angular Material's mat-accordion uses @ContentChildren(MatExpansionPanel) to discover its child panels at component initialization. ContentChildren only queries direct content children in the view — it does not traverse into embedded views created by structural directives. When you place ngIf on a mat-expansion-panel, Angular creates an ng-template and an ng-container between the accordion and the panel. The panel is no longer a direct content child of the accordion — it's a child of the ng-container's embedded view. The accordion's ContentChildren query misses it entirely. Result: the accordion has no reference to that panel, so it cannot call .close() on it when another panel opens. Two panels stay open simultaneously with no warning or error.
Diagnosing this from a production bug report: the symptom is 'two panels open at the same time despite multi=false.' Open Chrome DevTools Elements panel and inspect the rendered DOM around the accordion. Look for ng-container elements between mat-accordion and mat-expansion-panel — if present, that's the structural directive creating the embedded view. Confirm by temporarily removing the ngIf and verifying the single-open behavior restores.
Fix: move the *ngIf inside the panel body, or use [disabled]="condition" to control panel availability without removing it from the DOM. If the panel must be conditionally absent from the accordion entirely, the accordion component needs a refresh — destroy and recreate it when the panel list changes, which forces ContentChildren to re-query.
Q02 of 04SENIOR
A settings page has 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?
ANSWER
Problem 1: Memory leak from QueryList.changes subscription. @ViewChildren returns a QueryList, which updates as the DOM changes. For a static panel list this is manageable, but for dynamic lists (panels added or removed via *ngFor), you must subscribe to QueryList.changes to detect updates. That subscription must be explicitly cleaned up in ngOnDestroy. In a settings page that rebuilds its panel list on every navigation — which is common when settings sections come from an API — every navigation creates a new subscription on the new QueryList without cleaning up the previous one. Memory climbs 40MB per minute in production. The Angular change detector adds overhead for each zombie subscription still holding references to destroyed view instances.
Problem 2: Untestable validation logic. Calling .open() on a ViewChild reference couples your validation logic to the DOM. You cannot unit test 'open the first panel with errors' without TestBed rendering the full component template. You cannot serialize the open state to the URL. You cannot restore it on page refresh. The logic that determines which panel to open is in a method that returns void and produces a DOM side effect — it's inherently opaque.
Preferred alternative: maintain an activeSectionId string in the component. Build a controlSectionMap that maps each form control name to the section ID it belongs to. On submit, call settingsForm.markAllAsTouched(), iterate SETTINGS_SECTIONS in display order, find the first section where isSectionInvalid() returns true, and set activeSectionId to that section. The template's [expanded]="activeSectionId === 'profile'" binding opens the correct panel. One assignment. No ViewChild. No subscription. Fully testable: mock the form state, call onSubmit(), assert activeSectionId equals 'profile'.
Q03 of 04SENIOR
A profile page renders transaction history inside a mat-expansion-panel using matExpansionPanelContent for lazy loading. QA reports that data shown is 20-30 minutes stale when the user returns to the tab after working elsewhere. What caused this and how do you fix it without removing the lazy loading behavior?
ANSWER
matExpansionPanelContent renders content on first open and then persists it in the DOM indefinitely — the component stays alive and its data properties hold the values from the initial fetch. When the user returns to the tab after 20-30 minutes, the panel still displays the snapshot from the first open. No refresh has occurred because there's no trigger for one — the component's (opened) handler has a loaded guard that correctly prevents re-fetching.
The fix depends on the staleness tolerance: Option 1 — timestamp-based invalidation. In the (opened) handler, compare Date.now() against a lastFetchedAt timestamp. If the delta exceeds an acceptable threshold (say, 5 minutes for transaction data), reset the loaded guard and re-fetch. This keeps lazy loading on first open while automatically refreshing stale data. Option 2 — user-initiated refresh. Add a Refresh button inside the panel body that resets the loaded guard and calls the fetch function. Users who care about freshness can refresh explicitly. Option 3 — route change invalidation. Listen to the router's NavigationEnd events with takeUntil(destroy$). On each navigation that matches the profile route, reset the loaded guard. The next panel open will re-fetch. All three keep matExpansionPanelContent in place — the choice depends on how stale is 'too stale' for the specific data domain.
Q04 of 04SENIOR
You're reviewing a PR where a compliance document viewer uses mat-expansion-panel for 80 accordion sections. Performance testing on a mid-range Android device shows 380ms of scripting work every time a panel opens. Walk through your diagnosis and the recommendation you'd give in the code review.
ANSWER
Before writing a single comment in the PR, I'd profile the specific bottleneck. Open Chrome DevTools Performance tab, enable CPU throttling at 4-6x, and record a session opening and closing two panels. Look at the flame graph.
If Animation tasks dominate at >16ms each, and there are 80 entries corresponding to the 80 panel animation players, the diagnosis is confirmed: Angular's animation engine is registering one JS animation player per mat-expansion-panel regardless of whether the panel is collapsed. 80 panels means 80 players tracked simultaneously. On mid-range Android, that bookkeeping overhead exceeds the frame budget on every open event.
To confirm it's specifically the animation system and not something else: add [@.disabled]="true" to each mat-expansion-panel temporarily. If scripting drops from 380ms to under 20ms with animations disabled, the animation engine is definitively the bottleneck.
Recommendation in the code review: replace mat-expansion-panel with a CSS max-height transition approach. Each clause gets a <button> trigger (not a div — native button gives keyboard focus and Enter/Space for free), a body div with max-height:0 transitioning to max-height:1000px via CSS, and [class.expanded] bound to a Set<string>. The Set gives O(1) lookup for each panel's expanded state. OnPush triggers on Set re-assignment. Add aria-expanded on the button, aria-controls linking to the body, and role=region on the body — this replicates the ARIA structure mat-expansion-panel provides automatically. Total: 30 lines of TypeScript, 20 lines of CSS, zero Material animation overhead. Expected result: scripting time under 2ms per panel open, CSS transition completing in 220ms on the GPU with no main thread involvement.
01
mat-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 in a production bug report?
SENIOR
02
A settings page has 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?
SENIOR
03
A profile page renders transaction history inside a mat-expansion-panel using matExpansionPanelContent for lazy loading. QA reports that data shown is 20-30 minutes stale when the user returns to the tab after working elsewhere. What caused this and how do you fix it without removing the lazy loading behavior?
SENIOR
04
You're reviewing a PR where a compliance document viewer uses mat-expansion-panel for 80 accordion sections. Performance testing on a mid-range Android device shows 380ms of scripting work every time a panel opens. Walk through your diagnosis and the recommendation you'd give in the code review.
SENIOR
FAQ · 6 QUESTIONS
Frequently Asked Questions
01
How do I open a specific mat-expansion-panel programmatically in Angular?
Set an activePanelId string in your component and bind [expanded]="activePanelId === panel.id" on each panel. Changing activePanelId in code — from a validation handler, a service event, or a router navigation — opens the target panel immediately. No ViewChild. No .open() call. No subscription to manage.
In Angular 17+, use activePanelId = signal<string>('') instead. The signal read in the template [expanded]="activePanelId() === panel.id" creates a reactive dependency automatically — updating the signal with activePanelId.set(sectionId) triggers a re-render without markForCheck().
Was this helpful?
02
What's the difference between matExpansionPanelContent and using *ngIf inside a mat-expansion-panel?
They defer rendering differently and make opposite promises about what happens after the panel closes.
matExpansionPanelContent defers rendering until first open, then keeps the content alive in the DOM permanently. The component survives close and reopen — its state, HTTP-fetched data, and form values all persist.
*ngIf on the panel body destroys the content component on close and creates a fresh instance on reopen. Form state resets. HTTP calls re-fire. ngOnInit runs on the new instance.
Use matExpansionPanelContent for data display panels where load-once-display-always is correct. Use *ngIf for forms or multi-step flows where a clean state on every open is required. Using matExpansionPanelContent on a form panel is the most common cause of 'my form doesn't reset when I close and reopen the panel.'
Was this helpful?
03
How do I stop multiple mat-expansion-panels from being open at the same time?
Add multi="false" to the parent mat-accordion element. This is technically the default, but declare it explicitly — future developers need to know it's intentional.
If panels still open simultaneously despite multi=false, the structural directive problem is almost certainly the cause. Inspect the DOM in Chrome DevTools. If you see ng-container elements between mat-accordion and mat-expansion-panel, a structural directive is breaking the ContentChildren query that lets the accordion track its panels. Move any *ngIf conditions inside the panel body, and use [disabled] on the panel element for conditional availability.
Was this helpful?
04
We have 60+ expansion panels on a single page and Material animations are causing 400ms jank on Android. What's the actual fix?
Profile first to confirm. Open Chrome DevTools Performance tab with 4-6x CPU throttle. If Animation tasks exceed 16ms per panel open and you see entries corresponding to your panel count, Material's JS animation players are confirmed as the bottleneck.
The fix: replace mat-expansion-panel with CSS max-height transitions. Each panel gets a <button> trigger (not a div — native button gives keyboard focus and Enter/Space for free), a body div with max-height:0 transitioning to an appropriate max-height value with CSS ease-out, and [class.expanded] binding driven by a Set<string> in your component.
You'll add ARIA manually: aria-expanded on the button, aria-controls linking button to body, role=region on the body, aria-labelledby linking region to its header. That's the ARIA structure mat-expansion-panel provides automatically — replicating it takes about 4 attributes per panel.
Result: jank typically drops from 400ms to under 16ms on the same device. The CSS transition runs on the GPU with no main thread involvement. Adding 60 more panels doesn't add 60 more animation players — CSS transition cost is flat.
Was this helpful?
05
Should I use mat-expansion-panel for an FAQ section on a marketing landing page?
No. Marketing landing pages are SEO-critical — search engines need to index the FAQ content. mat-expansion-panel hides content behind Angular's runtime. Without Angular Universal or Angular 17's SSR setup, Googlebot sees an empty page. Even with SSR configured, the setup complexity and hydration overhead are not justified for a static FAQ section.
Use native HTML <details> and <summary> elements instead. Zero JavaScript required. Content renders on the server with no configuration. Googlebot indexes it correctly. Screen readers understand details/summary natively. The close/open animation can be added with a CSS transition on the details element.
Reserve mat-expansion-panel for authenticated application UIs where SEO doesn't matter and the user is already running Angular — the settings page, the checkout flow, the user profile — not the marketing site.
Was this helpful?
06
How should I handle accordion state in Angular 17+ with signals?
Replace the activeSectionId string field with a signal: activeSectionId = signal<string>('contact'). In the template, read it as a function call: [expanded]="activeSectionId() === section.id". Update it with: this.activeSectionId.set(sectionId) in event handlers.
The benefit: signal reads inside templates create fine-grained reactive dependencies. When activeSectionId changes, only the [expanded] expressions that read it are re-evaluated — not the entire component template. In OnPush components, signal updates schedule a re-render automatically without markForCheck(). This removes the manual markForCheck() call from onSubmit() and simplifies async handler cleanup.
For multi-open scenarios, use a computed signal: openPanelIds = signal(new Set<string>()) and update with this.openPanelIds.update(ids => new Set([...ids, newId])). The Set re-creation on update is intentional — it creates a new reference that signals can track as a change.