Angular Expansion Panel — Collapsed Panels Block Main Thread
Collapsed mat-expansion-panels still fire ngOnInit and HTTP calls.
- 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
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.
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.
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.
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.
| 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
- 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.
- 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.
- 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.
- 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.
- 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
- 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 thefinalize()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 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 in a production bug report?SeniorReveal
- QA 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?Mid-levelReveal
- QA 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?Mid-levelReveal
- QYou'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.SeniorReveal
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]="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().
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.'
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.
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.
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.
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.
That's Advanced JS. Mark it forged?
7 min read · try the examples if you haven't