In Angular 22, an @if removes a panel whose child component's template uses animate.leave, yet only the panel's own leave plays. Why, and how do you fix it?
answer
- descendants are gathered per template
- component boundary stops the search
- host element sits in the parent
- v22 changed the scope
basics
~20 sSince v22, removing an element also runs animate.leave on its descendants, but only those in the same component template and its embedded views. A child component's own template is outside that scope. Put animate.leave on the child's host element instead.
solid answer
~50 sIn Angular 22, when a node is removed, Angular collects `animate.leave` bindings on that node and on its descendants within the same component template, including nested `@if` and `@for` views, runs them, and detaches the node only when all of them have finished. The search stops at component boundaries: bindings inside a child component's own template are not collected, so that child is removed along with the panel without animating. The fix is to put the leave on the child's **host element**, either in the parent template (`<app-toast animate.leave="toast-out">`) or as a host binding in the child's decorator, because the host element belongs to the view being removed. Otherwise the child has to animate itself programmatically and the parent must delay the removal. In v21, `animate.leave` only ran on the element actually being removed; v22 re-introduced nested leaves with this component-boundary scope.
code
ts · 29 linesimport {Component, input, signal} from '@angular/core';
@Component({
selector: 'app-toast',
// runs when any ancestor view removes this host element
host: {'animate.leave': 'toast-out'},
template: `<div class="body">{{ text() }}</div>`,
})
export class Toast {
readonly text = input.required<string>();
}
@Component({
selector: 'app-notification-panel',
imports: [Toast],
template: `
@if (open()) {
<section class="panel" animate.leave="panel-out">
@for (t of toasts(); track t) {
<app-toast [text]="t" />
}
</section>
}
`,
})
export class NotificationPanel {
protected readonly open = signal(true);
protected readonly toasts = signal(['Saved', 'Synced']);
}go deeper
Know that an animate.leave on the element being removed, or on elements inside it in the same template, plays before removal.
Explain that nested leaves are collected per component template and nested control-flow views, and that a child component's own template is outside that scope.
Diagnose skipped or newly slow exits after a v22 upgrade, move leaves to host elements or host bindings, and design components so their exit is part of their public behaviour.
Decide how a design system exposes enter and exit motion across component boundaries, given that the native API offers no cross-component orchestration like the old DSL did.
## The symptom A notification panel is rendered inside `@if (panelOpen())`. It contains a list of toast components, and each toast component's template has `animate.leave="toast-out"` on its root `<div>`. When `panelOpen` becomes false, the panel's own exit plays, but the toasts vanish without sliding out. Nothing is broken: this is the documented **scope** of nested leave animations. ## What Angular does when a node is removed When control flow or a `ViewContainerRef` removes a node that is being tracked for leave animations, Angular: 1. collects the node's own `animate.leave` binding, if any; 2. collects `animate.leave` bindings on the node's **descendants in the same component template**, by walking the template's node tree; 3. collects leave animations in **embedded views** nested inside the node, such as `@if` and `@for` blocks in the same template; 4. runs all of them together and waits until every one has settled; 5. only then detaches the node, and with it the whole subtree. So a child element inside the removed panel can animate away first and the panel does not disappear from under it. ## Where the search stops The collection **does not cross component boundaries**. A child component's template is compiled into its own view, and the leave bindings declared there are not considered when an ancestor in another component is removed. The documentation is explicit: child animations fire only for elements within the same component template. | Where `animate.leave` is written | Runs when an ancestor in the parent template is removed? | |---|---| | On the removed element itself | Yes | | On a descendant element in the same template | Yes (since v22) | | Inside a nested `@if` or `@for` in the same template | Yes (since v22) | | On a child component's **host element**, in the parent template | Yes | | As a **host binding** in the child component's decorator | Yes, it applies to the host element | | Inside the child component's own template | **No** | ## The fixes - **Move the leave to the host element.** Write `<app-toast animate.leave="toast-out" />` in the panel's template. The host element belongs to the parent's view, so it is collected. - **Declare it as a host binding.** `host: {'animate.leave': 'toast-out'}` in the toast component makes every usage animate its host element, without each parent repeating the class. - **Orchestrate it yourself.** If the motion must live deep inside the child's template, trigger it programmatically in the child and have the parent keep the panel alive, for example by flipping the `@if` only after the child reports that it has finished. ## What changed in v22 - **Angular 21:** `animate.leave` ran only on the element actually being removed. A descendant's leave animation did not delay the ancestor's removal. - **Angular 22:** leave animations are no longer limited to the element being removed. The release **re-introduced** nested leaves, now **scoped to component boundaries**. The release notes list this as a breaking change, so apps upgrading from v21 may see exits that used to be skipped start playing, and removals that used to be instant now wait for them. - **Angular 22.2:** a fix cancels leave animations in nested views when a list is reordered, so moved items do not play an exit. ## Contrast with the legacy DSL The deprecated `@angular/animations` package let a parent reach into children with `query()` and `animateChild()`. The native API has no equivalent: no animation takes priority over another, and sequencing across components is your own code's job, using CSS delays or end events. ## Why the boundary exists Scoping the search to one component template keeps removal cheap and predictable. Angular only has to look at the leave bindings registered in the view being removed and in its embedded views, not walk into every component nested below it. It also keeps each component's template an implementation detail: a parent cannot be slowed down by exit animations it cannot see in its own template. The price is that a component which wants its exit to play when an ancestor goes away must expose that exit on its **host element**, which is exactly the part of the component its parent's view owns. ## How to reason about it in a review When an exit "does not play", ask three questions: which element is actually being removed, which template declares the `animate.leave`, and is there a component boundary between them? If there is, move the binding to the host element or make the parent wait.
- After upgrading from v21 to v22, closing a dialog now takes noticeably longer. What is the likely cause?Since v22, `animate.leave` bindings on descendants in the same template, and in nested `@if`/`@for` views, run before the ancestor is removed. A slow exit on some inner element that used to be skipped now plays, and the dialog waits for the longest of them. Shorten or remove that inner leave, or accept the new timing.
- Why does a host binding work when the same binding in the child's template does not?A component's host element is created by, and belongs to, the parent's view, even though the host binding is declared in the child's decorator. The nested-leave search walks the view being removed, so it finds the host element's binding but never looks inside the child component's own view.
saying these in an interview costs you the question
- Every animate.leave in the removed subtree runs, whatever component declares it.
- Nested leave animations cannot be made to run at all.
- A host binding in the child is ignored because it lives in the child's decorator.
- In v21, descendants' animate.leave bindings already delayed ancestor removal.
- query() and animateChild() work with animate.leave to reach into children.