skip to content

In Angular's legacy animations DSL, what do the :enter and :leave aliases mean, and why does a :leave transition sometimes never run?

level: middleimportance: should knowfreq 38%

answer

  1. void means not in the DOM
  2. aliases for void transitions
  3. removed with its parent
  4. 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 s

In 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 lines
ts
import {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

for a junior

Know that :enter and :leave are the aliases for elements being added and removed, and that the leaving element stays until its animation ends.

for a middle

Explain void and the wildcard, why :leave is skipped for elements removed with their parent, and how parent priority blocks child triggers.

for a senior

Debug missing exit animations by checking which element is actually removed, and use query() with animateChild() and optional queries deliberately.

for a principal

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.