Skip to content

Retain Angular animations in v23 until coordinated enter/leave has a supported migration path #71703

Open

Description

@EthanSK

Which @angular/* package(s) are relevant/related to the feature request?

animations, core, platform-browser

Description

Please reconsider removing @angular/animations in v23 until there is a supported, behaviorally equivalent path for coordinated parent/child enter/leave animations, or a maintained compatibility package.

I am asking sincerely as someone who wants to keep upgrading Angular, rather than freeze an application or maintain a private framework fork. Simple CSS fades are not the difficulty. The value for my application is Angular coordinating view lifetime, child queries, forward/reverse stagger, delayed entry and changing state through one reusable declaration.

Our actual setup is a standalone, zoneless Angular media-editor application (currently testing Angular 22.1.8). A parent wizard reacts to its step signal and coordinates a child trigger inside each @switch branch:

<div [@fadeItems]="step()" class="relative">
  @switch (step()) {
    @case ('first') {
      <section @fadeItemsParent class="panel">
        <h2 class="fade-item">First step</h2>
        <app-first-control class="fade-item" />
      </section>
    }
    @case ('second') {
      <section @fadeItemsParent class="panel">
        <h2 class="fade-item">Second step</h2>
        <app-second-control class="fade-item" />
      </section>
    }
  }
</div>

The names/content above are shortened for this report. The animation structure and these timings come from our working shared triggers:

const fadeItems = trigger('fadeItems', [
  transition('* => *', [
    query('@fadeItemsParent', animateChild(), { optional: true }),
  ]),
]);

const fadeItemsParent = trigger('fadeItemsParent', [
  transition(':enter', [
    query('.fade-item', [
      style({ opacity: 0 }),
      stagger(100, animate('250ms 469ms ease-in', style({ opacity: 1 }))),
    ], { optional: true }),
  ]),
  transition(':leave', [
    query('.fade-item', [
      stagger(-100, animate('250ms ease-out', style({ opacity: 0 }))),
    ], { optional: true }),
  ]),
]);

The real wizard uses overlapping/positioned step containers, so outgoing controls can finish fading while incoming controls appear after the entry delay. Callers mark their controls with .fade-item; they do not each manage animation parameters or lifecycle callbacks. Another current caller retains both form views and switches HIDDEN/VISIBLE states with the same shared trigger, preserving entered form data instead of destroying it. We also have an ordered variant for numbered child groups.

Evidence from our migration attempts: I went back through the actual development records rather than relying on memory.

  • On 7 December 2025, we attempted a broad replacement with CSS/JavaScript/Web Animations, including a shared animation runner, structural enter/leave directive and fade-list directive, across the wizard, upload widgets, layout and video page.
  • The first result was only partly functional: I reported, “the fade out animation doesnt happen, it exits instantly.” The implementation had replaced parent-managed animations with enter-only list behavior, allowing outgoing DOM to disappear before the exit completed.
  • A subsequent pass replaced those list usages with a structural directive intended to retain the outgoing view. I still reported that the fade-out did not happen.
  • A further correction had to stop updating the old view's binding context before fading it out, then swap in a new view and enter it. This is the kind of subtle view-lifecycle/state bookkeeping the existing animation engine was already handling for us.

These are documented unsuccessful attempts, not a claim that CSS/WAAPI is mathematically incapable of the effect. They also predate improvements since then; I am not presenting them as a newly verified bug in Angular 22's native APIs. My concern is losing supported orchestration and being required to rebuild it as application infrastructure, with recurring edge cases and a different call-site API.

Proposed solution

Please either:

  1. Keep the legacy engine available and supported through v23 while advanced migration cases are resolved; or
  2. Provide an officially supported compatibility package/adapter preserving the trigger API and its integration; or
  3. Publish a complete, tested equivalent for the coordinated example above before removal, including nested controls, old/new view-context separation, reverse stagger, rapid step reversal, and retained forms whose visibility changes without destruction.

A parity example should show the same old and new behavior, rather than a simpler single-element fade. If there is already a supported solution that preserves this setup without a large custom runner or per-caller rewrites, I would genuinely welcome being pointed to it. I would much rather adopt that than argue against progress.

Please do not remove a working capability before applications with this orchestration pattern have a dependable migration path. A longer deprecation period or a maintained compatibility package would make a real difference.

Alternatives considered

  • animate.enter/animate.leave plus CSS: valuable for basic effects, but our attempted replacement lost parent/child and view-lifetime coordination. A current, complete parity example is the missing piece for this case.
  • A custom WAAPI runner and structural wrappers: attempted, but introduced context-swap/lifecycle bugs and substantial application-owned machinery.
  • Pinning Angular 22: a temporary option, not a sustainable upgrade strategy.
  • Carrying the old package forward with dependency overrides or a private fork: this does not itself preserve removed renderer/compiler integration and transfers maintenance to application developers.

Related: the completed animation RFC and #66359, where a contributor also expected one-to-one migration examples. I recognize that #66359 was a resolved documentation discussion; I am linking it for context, not claiming it remains an open bug.

Activity

  1. added this to the needsTriage milestone on Oct 28, 2026
  2. added this to the needsTriage milestone on Oct 28, 2026
  3. JeanMeche commented on Oct 28, 2026

    @JeanMeche
    Member

    It's unlikely that we'll remove legacy animations in v23. I'll update the deprecation message in the docs.

  4. JeanMeche commented on Oct 28, 2026

    @JeanMeche
    Member

    Is this what you're looking for ?

    import { Component, signal } from '@angular/core';
    import {
      animate,
      animateChild,
      query,
      stagger,
      style,
      transition,
      trigger,
    } from '@angular/animations';
    
    @Component({
      selector: 'app-first-control',
      template: `<input placeholder="First control" />`,
    })
    export class FirstControl {}
    
    @Component({
      selector: 'app-second-control',
      template: `<button type="button">Second control</button>`,
    })
    export class SecondControl {}
    
    @Component({
      selector: 'app-native-demo',
      imports: [FirstControl, SecondControl],
      template: `
        <h1>animate.enter / animate.leave</h1>
        <button type="button" (click)="toggle()">Toggle step (current: {{ step() }})</button>
    
        <div class="relative">
          @switch (step()) {
            @case ('first') {
              <section class="panel" animate.leave="panel-leave">
                <h2 class="fade-item" style="--i: 0; --n: 2">First step</h2>
                <app-first-control class="fade-item" style="--i: 1; --n: 2" />
              </section>
            }
            @case ('second') {
              <section class="panel" animate.leave="panel-leave">
                <h2 class="fade-item" style="--i: 0; --n: 2">Second step</h2>
                <app-second-control class="fade-item" style="--i: 1; --n: 2" />
              </section>
            }
          }
        </div>
      `,
      styles: `
        .relative { position: relative; }
        .panel { position: absolute; padding: 1rem; }
        /* enter: 250ms, 469ms delay, staggered by 100ms. @starting-style applies on first style resolution, so no flash */
        .fade-item {
          display: block;
          opacity: 1;
          transition: opacity 250ms ease-in calc(469ms + var(--i) * 100ms);
          @starting-style { opacity: 0; }
        }
        /* leave runs on the section so Angular keeps it whole until the staggered children finish (250ms + 100ms) */
        .panel-leave { animation: hold 350ms; }
        .panel-leave .fade-item {
          transition: none;
          animation: fade-out 250ms ease-out calc((var(--n) - 1 - var(--i)) * 100ms) both;
        }
        @keyframes hold { from { opacity: 1; } to { opacity: 1; } }
        @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } }
      `,
    })
    export class NativeDemo {
      protected readonly step = signal<'first' | 'second'>('first');
    
      protected toggle() {
        this.step.update((s) => (s === 'first' ? 'second' : 'first'));
      }
    }
    
    const fadeItems = trigger('fadeItems', [
      transition('* => *', [query('@fadeItemsParent', animateChild(), { optional: true })]),
    ]);
    
    const fadeItemsParent = trigger('fadeItemsParent', [
      transition(':enter', [
        query(
          '.fade-item',
          [
            style({ opacity: 0 }),
            stagger(100, animate('250ms 469ms ease-in', style({ opacity: 1 }))),
          ],
          { optional: true },
        ),
      ]),
      transition(':leave', [
        query('.fade-item', [stagger(-100, animate('250ms ease-out', style({ opacity: 0 })))], {
          optional: true,
        }),
      ]),
    ]);
    
    @Component({
      selector: 'app-legacy-demo',
      imports: [FirstControl, SecondControl],
      animations: [fadeItems, fadeItemsParent],
      template: `
        <h1>Legacy @angular/animations</h1>
        <button type="button" (click)="toggle()">Toggle step (current: {{ step() }})</button>
    
        <div [@fadeItems]="step()" class="relative">
          @switch (step()) {
            @case ('first') {
              <section @fadeItemsParent class="panel">
                <h2 class="fade-item">First step</h2>
                <app-first-control class="fade-item" />
              </section>
            }
            @case ('second') {
              <section @fadeItemsParent class="panel">
                <h2 class="fade-item">Second step</h2>
                <app-second-control class="fade-item" />
              </section>
            }
          }
        </div>
      `,
      styles: `
        .relative { position: relative; }
        .panel { position: absolute; padding: 1rem; }
        .fade-item { display: block; }
      `,
    })
    export class LegacyDemo {
      protected readonly step = signal<'first' | 'second'>('first');
    
      protected toggle() {
        this.step.update((s) => (s === 'first' ? 'second' : 'first'));
      }
    }
    
    @Component({
      selector: 'app-root',
      imports: [NativeDemo, LegacyDemo],
      template: `
        <div class="side-by-side">
          <app-native-demo />
          <app-legacy-demo />
        </div>
      `,
      styles: `
        .side-by-side { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; padding: 1rem; }
      `,
    })
    export class App {}
  5. added
    needs: clarificationThis issue needs additional clarification from the reporter before the team can investigate.
    on Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: animationslegacy animations package only. Otherwise use area: core.needs: clarificationThis issue needs additional clarification from the reporter before the team can investigate.

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions