In Angular's legacy animations DSL, what do the :enter and :leave aliases mean, and why does a :leave transition sometimes never run?
answer
- void means not in the DOM
- aliases for void transitions
- removed with its parent
- parent blocks child animations
basics
~20 s:enter is an alias for 'void => ' and :leave for ' => void', where void means the element is not attached. A :leave only runs when the element itself is removed; removed along with its parent, it disappears without animating unless the parent queries it.
solid answer
~40 sIn the legacy DSL, `void` is the state of an element that is not in the DOM and `*` matches any state, so `:enter` is shorthand for `void => *` and `:leave` for `* => void`. They fire when control flow or a `ViewContainerRef` inserts or removes the element, and during a `:leave` Angular keeps the element in the DOM until the animation finishes. The catch is that `:enter` always runs for an inserted element with a trigger, but `:leave` only runs when that element is removed on its own. If its parent block is removed, the child is taken out with it before its transition can run. In addition, a parent animation takes priority and blocks child triggers, so a parent that wants its children to animate must `query()` them and call `animateChild()`.
code
ts · 24 linesimport {Component, signal} from '@angular/core';
import {animate, style, transition, trigger} from '@angular/animations';
@Component({
selector: 'app-promo-banner',
template: `
@if (visible()) {
<aside class="banner" @fadeSlide>Free shipping this week</aside>
}
<button type="button" (click)="visible.set(!visible())">Toggle</button>
`,
animations: [
trigger('fadeSlide', [
transition(':enter', [
style({opacity: 0, transform: 'translateY(8px)'}),
animate('200ms ease-out', style({opacity: 1, transform: 'none'})),
]),
transition(':leave', [animate('150ms ease-in', style({opacity: 0}))]),
]),
],
})
export class PromoBanner {
protected readonly visible = signal(true);
}go deeper
Know that :enter and :leave are the aliases for elements being added and removed, and that the leaving element stays until its animation ends.
Explain void and the wildcard, why :leave is skipped for elements removed with their parent, and how parent priority blocks child triggers.
Debug missing exit animations by checking which element is actually removed, and use query() with animateChild() and optional queries deliberately.
Recognise which legacy orchestration patterns have no native equivalent and budget the redesign they need during a migration.
## The two special states The legacy `@angular/animations` DSL models an element that is not in the DOM as being in a special state called **`void`**. The **wildcard** `*` matches any state, including `void`. From those two you can express entry and exit: | Expression | Alias | Fires when | |---|---|---| | `void => *` | `:enter` | an element carrying the trigger is inserted | | `* => void` | `:leave` | an element carrying the trigger is removed | | `* => *` | (none) | any change, including enter and leave | The aliases are written `transition(':enter', [...])` and `transition(':leave', [...])`. A trigger used only for entry and exit needs no `state()` at all: the element is put on the page with `<div @fadeInOut>` and the transitions do the work. ## A typical enter and leave pair 1. On `:enter`, the first step is usually a `style()` that sets the starting look, for example `style({opacity: 0, transform: 'translateY(8px)'})`, because the element has no previous state to animate from. 2. `animate('200ms ease-out', style({opacity: 1, transform: 'none'}))` then brings it to its normal look. 3. On `:leave`, a single `animate(...)` to the exit style is enough; the engine **keeps the element in the DOM** until it finishes, then removes it. ## Which elements count as entering and leaving The documentation's rule of thumb is: every element Angular **adds** passes through `:enter`, but only elements Angular removes **directly** pass through `:leave`. An element whose own `@if` becomes false is removed directly. An element that sits inside a parent block that is removed is not: it goes away "without warning" together with its parent, so its own `:leave` transition never gets a chance to run. The same applies to queries. When a parent animation calls `query(':enter')` or `query(':leave')`, only elements that Angular considers entering or leaving on their own can be found: those inserted through a `ViewContainerRef` or by control flow and structural directives. Elements that simply come and go with their parent should be queried with a normal selector instead. There is one exception: an element with its own animation trigger can always be queried with `:leave` when its parent is leaving. ## Parent priority and animateChild() When an animation runs on an element, it **takes priority**, and animations on its descendants are **blocked**. This is why a list item's own `@item` trigger may stay still while the list's container animates. To let the children run, the parent's transition queries them and hands control over: - `query('@item', animateChild())` runs the child triggers' own transitions; - `query(':leave', animateChild(), {optional: true})` does the same for leaving children, without throwing when there are none. ## Other entry-and-exit gotchas - **Wildcard styles.** In `animate('300ms', style({height: '*'}))` the `*` is computed at runtime, so an element can grow from `0` to its natural height. - **Reordering.** Tracking list items by a stable identity (`track item.id` in `@for`, or a `TrackByFunction` with `*ngFor`) lets the engine keep track of which element is which when items move; without it, reordered lists animate incorrectly. - **Disabled regions.** `[@.disabled]` on an ancestor stops the transitions, but callbacks still fire, instantly, with `event.disabled` set to `true`. ## A checklist when an exit does not play 1. **Which element is actually removed?** If it is an ancestor, the element's own `:leave` is skipped. 2. **Is the trigger on the removed element?** A `:leave` on a sibling or a child does nothing for the element being removed. 3. **Is a parent animation running at the same moment?** It blocks the child unless it queries the child and calls `animateChild()`. 4. **Is the region disabled?** `[@.disabled]` anywhere above turns the transition into an instant one. 5. **Is the provider a noop one?** `provideNoopAnimations()` or `provideAnimationsAsync('noop')`, common in test setups, keeps bindings valid but plays nothing. ## How this maps to current Angular In Angular 22 the same needs are met by `animate.enter` and `animate.leave`, with CSS doing the motion, and the old package is deprecated. The concepts carry over, but the rules do not: the native API has no `animateChild()`, and its nested leave behaviour follows its own component-boundary rules. When you maintain a legacy trigger, reason with the `void` model above.
- How do you make a child's :leave run when its parent container is the element being removed?Give the container its own trigger with a `:leave` transition that queries the children, for example `query('@item', animateChild(), {optional: true})`, or `query('.row', ...)` to animate them directly. Because the parent's animation now drives the children before the parent is removed, their exit plays.
- Why does an :enter transition usually start with a style() step?An entering element comes from `void`, which has no styles to animate from. A leading `style()` sets the starting values, such as opacity 0, and the following `animate()` moves to the destination style. Without it, the element appears already in its final look.
saying these in an interview costs you the question
- Every element in a removed subtree plays its own :leave transition.
- :enter is an alias for '* => *'.
- The element is removed first and the :leave animation plays afterwards.
- Child triggers always animate at the same time as their parent's.
- query(':leave') finds every descendant of a leaving element.