Angular のアニメーションは、要素が画面に出るときに animate.enter、画面から消えるときに animate.leave で CSS のクラスを付け、動き自体は CSS の animation や transition で書くのが現在の基本です。 従来の trigger / state / transition を使う書き方(@angular/animations パッケージ)は、Angular 20.2 から非推奨になっています。どちらの書き方でも同じ動きを作れるので、順に置き換えていけます。

この記事では、フェード・スライド・リストの追加削除・開閉の4パターンを、従来の API と新しい API の両方で書き、Angular 22.2.1 でビルドしてブラウザで動かしました。opacity や高さが時間とともにどう変わったかの記録、書き換え対応表、leave が効かないときの原因を1つずつ再現した結果、テストでアニメーションを有効にする設定と実行結果を載せています。

確認環境:Linux(x64)、Node.js 24.21.0、Angular CLI / Angular / @angular/animations 22.2.1、Vitest 5.0.3、Playwright 1.56.0 の Chromium。ng new の既定構成で作成し、ng build の本番ビルドを Chromium で開いて計測しています。

従来 API から animate.enter / animate.leave への書き換え早見表

やりたいこと 従来 API(@angular/animations) 新 API(animate.enter / animate.leave+CSS)
準備 provideAnimations() と、コンポーネントの animations: [...] 準備なし(@angular/core の機能)
出るときの動き transition(':enter', [style(...), animate(...)]) 要素に animate.enter="クラス名"、CSS で animation
消えるときの動き transition(':leave', [animate(...)]) 要素に animate.leave="クラス名"、CSS で animation
動きの中身 style({ opacity: 0 }) と animate('300ms ease-out', style(...)) @keyframes と animation: 名前 300ms ease-out
要素への指定 @fade(トリガー名) animate.enter="fade-in" animate.leave="fade-out"
状態の切り替え(開閉など) state('open', ...) と [@openClose]="..." クラスの付け外し [class.open]="..." と CSS の transition
高さを中身に合わせる height にワイルドカード(アスタリスク)を指定 display: grid と grid-template-rows: 0fr から 1fr
動きの完了を知る 完了のコールバック(done) animationend / transitionend イベント
再生を自分のコードで制御する AnimationPlayer Element.getAnimations() と Web Animations API、または (animate.leave)="関数($event)" と event.animationComplete()
テスト provideNoopAnimations() 既定で無効。有効にするときは animationsEnabled: true

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angularアニメーションの使い方|animate.enter移行表」

2つの書き方の位置づけ

項目 従来 API 新 API
パッケージ @angular/animations(別途インストール) @angular/core に含まれる
状況 20.2 から非推奨 20.2 で追加
動きを書く場所 TypeScript(animations 配列) CSS
本番ビルドのサイズ(今回の4パターン) 176.38 kB(転送時 50.51 kB) 128.13 kB(転送時 38.69 kB)

@angular/animations 22.2.1 の型定義には、trigger などの API に「@deprecated 20.2 Use animate.enter or animate.leave instead. Intent to remove in v23」というコメントが付いています。Angular の公式ドキュメント(移行ガイド)にも、v20.2 から @angular/animations が非推奨になったことと、ネイティブ CSS への置き換え方が載っています。

サイズは、同じ4パターンを片方の書き方だけで作ってビルドした結果です。新 API だけにすると Initial total が 48.25 kB(転送時 11.82 kB)小さくなりました。

従来 API の完成コード(4パターン)

まず、置き換え前の姿です。@angular/animations を入れ、app.config.ts に provideAnimations() を追加します。

npm install @angular/animations@22.2.1
// src/app/app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideAnimations } from '@angular/platform-browser/animations';

export const appConfig: ApplicationConfig = {
  providers: [provideBrowserGlobalErrorListeners(), provideAnimations()],
};
// src/app/legacy-demo.ts
import { Component, signal } from '@angular/core';
import { animate, state, style, transition, trigger } from '@angular/animations';

@Component({
  selector: 'app-legacy-demo',
  animations: [
    trigger('fade', [
      transition(':enter', [style({ opacity: 0 }), animate('300ms ease-out', style({ opacity: 1 }))]),
      transition(':leave', [animate('300ms ease-in', style({ opacity: 0 }))]),
    ]),
    trigger('slide', [
      transition(':enter', [
        style({ transform: 'translateX(-40px)', opacity: 0 }),
        animate('300ms ease-out', style({ transform: 'translateX(0)', opacity: 1 })),
      ]),
      transition(':leave', [animate('300ms ease-in', style({ transform: 'translateX(40px)', opacity: 0 }))]),
    ]),
    trigger('item', [
      transition(':enter', [
        style({ transform: 'translateY(-8px)', opacity: 0 }),
        animate('300ms ease-out', style({ transform: 'translateY(0)', opacity: 1 })),
      ]),
      transition(':leave', [animate('300ms ease-in', style({ transform: 'translateX(40px)', opacity: 0 }))]),
    ]),
    trigger('openClose', [
      state('open', style({ height: '*', opacity: 1 })),
      state('closed', style({ height: '0px', opacity: 0 })),
      transition('open <=> closed', [animate('300ms ease-in-out')]),
    ]),
  ],
  template: `
    <button id="l-fade-btn" (click)="fadeShown.set(!fadeShown())">フェード</button>
    @if (fadeShown()) {
      <p id="l-fade" @fade>フェードで出入りします</p>
    }

    <button id="l-slide-btn" (click)="slideShown.set(!slideShown())">スライド</button>
    @if (slideShown()) {
      <p id="l-slide" @slide>スライドで出入りします</p>
    }

    <button id="l-add-btn" (click)="add()">追加</button>
    <ul id="l-list">
      @for (task of tasks(); track task.id) {
        <li @item>
          {{ task.title }}
          <button class="remove" (click)="remove(task.id)">削除</button>
        </li>
      }
    </ul>

    <button id="l-panel-btn" (click)="open.set(!open())">開閉</button>
    <div id="l-panel" class="panel" [@openClose]="open() ? 'open' : 'closed'">
      <p>開閉する内容です。高さは中身に合わせて変わります。</p>
    </div>
  `,
  styles: `
    .panel {
      overflow: hidden;
    }
  `,
})
export class LegacyDemo {
  readonly fadeShown = signal(false);
  readonly slideShown = signal(false);
  readonly open = signal(false);
  readonly tasks = signal([
    { id: 1, title: '備品の発注' },
    { id: 2, title: '会議室の予約' },
  ]);
  private nextId = 3;

  add(): void {
    this.tasks.update((list) => [...list, { id: this.nextId, title: `タスク ${this.nextId++}` }]);
  }

  remove(id: number): void {
    this.tasks.update((list) => list.filter((task) => task.id !== id));
  }
}

provideAnimations() を書かずに開くと、開発ビルドではコンソールに次のエラーが出ます。

ERROR RuntimeError: NG05105: Unexpected synthetic property @openClose found. Please make sure that:
  - Make sure `provideAnimationsAsync()`, `provideAnimations()` or `provideNoopAnimations()` call was added to a list of providers used to bootstrap an application.

新 API の完成コード(4パターン)

同じ画面を animate.enter / animate.leave と CSS で書き直したものです。provider の追加も import も要りません。

// src/app/native-demo.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-native-demo',
  template: `
    <button id="n-fade-btn" (click)="fadeShown.set(!fadeShown())">フェード</button>
    @if (fadeShown()) {
      <p id="n-fade" animate.enter="fade-in" animate.leave="fade-out">フェードで出入りします</p>
    }

    <button id="n-slide-btn" (click)="slideShown.set(!slideShown())">スライド</button>
    @if (slideShown()) {
      <p id="n-slide" animate.enter="slide-in" animate.leave="slide-out">スライドで出入りします</p>
    }

    <button id="n-add-btn" (click)="add()">追加</button>
    <ul id="n-list">
      @for (task of tasks(); track task.id) {
        <li animate.enter="item-in" animate.leave="item-out">
          {{ task.title }}
          <button class="remove" (click)="remove(task.id)">削除</button>
        </li>
      }
    </ul>

    <button id="n-panel-btn" (click)="open.set(!open())">開閉</button>
    <div id="n-panel" class="panel" [class.open]="open()">
      <div class="panel-inner">
        <p>開閉する内容です。高さは中身に合わせて変わります。</p>
      </div>
    </div>
  `,
  styles: `
    /* フェード */
    .fade-in {
      animation: fade-in 300ms ease-out;
    }
    .fade-out {
      animation: fade-out 300ms ease-in;
    }
    @keyframes fade-in {
      from { opacity: 0; }
    }
    @keyframes fade-out {
      to { opacity: 0; }
    }

    /* スライド */
    .slide-in {
      animation: slide-in 300ms ease-out;
    }
    .slide-out {
      animation: slide-out 300ms ease-in;
    }
    @keyframes slide-in {
      from { transform: translateX(-40px); opacity: 0; }
    }
    @keyframes slide-out {
      to { transform: translateX(40px); opacity: 0; }
    }

    /* リストの追加・削除 */
    .item-in {
      animation: item-in 300ms ease-out;
    }
    .item-out {
      animation: slide-out 300ms ease-in;
    }
    @keyframes item-in {
      from { transform: translateY(-8px); opacity: 0; }
    }

    /* 開閉(要素は残したまま、クラスの付け外しで動かす) */
    .panel {
      display: grid;
      grid-template-rows: 0fr;
      opacity: 0;
      transition: grid-template-rows 300ms ease-in-out, opacity 300ms ease-in-out;
    }
    .panel.open {
      grid-template-rows: 1fr;
      opacity: 1;
    }
    .panel-inner {
      overflow: hidden;
    }
  `,
})
export class NativeDemo {
  readonly fadeShown = signal(false);
  readonly slideShown = signal(false);
  readonly open = signal(false);
  readonly tasks = signal([
    { id: 1, title: '備品の発注' },
    { id: 2, title: '会議室の予約' },
  ]);
  private nextId = 3;

  add(): void {
    this.tasks.update((list) => [...list, { id: this.nextId, title: `タスク ${this.nextId++}` }]);
  }

  remove(id: number): void {
    this.tasks.update((list) => list.filter((task) => task.id !== id));
  }
}

読み方のポイントは3つです。

  • animate.enter に書いたクラスは、要素が DOM に入るときに付き、アニメーションが終わると外れる
  • animate.leave に書いたクラスは、要素が消えるときに付き、アニメーションが終わってから要素が DOM から削除される
  • @keyframes は「変化する側」だけを書けば足りる。出るときは from(始まりの姿)だけ、消えるときは to(終わりの姿)だけを書くと、もう片方は要素の普段の見た目になる

パターン別の書き換えと計測結果

ボタンを押してからの経過時間ごとに、要素があるか、opacity がいくつか、class に何が付いているかを記録しました。アニメーションの長さはどれも 300 ミリ秒です。

パターン1:フェード

従来 API 新 API
要素 @fade animate.enter="fade-in" animate.leave="fade-out"
出るとき transition(':enter', [style({ opacity: 0 }), animate('300ms ease-out', style({ opacity: 1 }))]) .fade-in { animation: fade-in 300ms ease-out; } と @keyframes fade-in { from { opacity: 0; } }
消えるとき transition(':leave', [animate('300ms ease-in', style({ opacity: 0 }))]) .fade-out { animation: fade-out 300ms ease-in; } と @keyframes fade-out { to { opacity: 0; } }

class の「…」は、Angular が自動で付けるコンポーネント識別用のクラス名を省いた部分です。

--- 従来 API フェード enter
  直後: あり opacity=0.09 class="… ng-trigger ng-trigger-fade ng-star-inserted ng-animating"
  150ms: あり opacity=0.68 class="… ng-trigger ng-trigger-fade ng-star-inserted ng-animating"
  450ms: あり opacity=1.00 class="… ng-trigger ng-trigger-fade ng-star-inserted"
--- 従来 API フェード leave
  直後: あり opacity=0.99
  150ms: あり opacity=0.68
  450ms: なし
--- 新 API フェード enter
  直後: あり opacity=0.00 class="ng-star-inserted fade-in"
  150ms: あり opacity=0.62 class="ng-star-inserted fade-in"
  450ms: あり opacity=1.00 class="ng-star-inserted"
--- 新 API フェード leave
  直後: あり opacity=0.99 class="ng-star-inserted fade-out"
  150ms: あり opacity=0.74 class="ng-star-inserted fade-out"
  450ms: なし

どちらも、出るときは opacity が 0 付近から 1 へ、消えるときは 1 から下がっていき、300 ミリ秒を過ぎると要素がなくなりました。新 API では、450 ミリ秒の時点で fade-in クラスが外れていることも確認できます。

パターン2:スライド

従来 API 新 API
要素 @slide animate.enter="slide-in" animate.leave="slide-out"
出るとき style({ transform: 'translateX(-40px)', opacity: 0 }) から animate(...) @keyframes slide-in { from { transform: translateX(-40px); opacity: 0; } }
消えるとき animate('300ms ease-in', style({ transform: 'translateX(40px)', opacity: 0 })) @keyframes slide-out { to { transform: translateX(40px); opacity: 0; } }
--- 新 API スライド enter
  直後: あり opacity=0.09 class="ng-star-inserted slide-in"
  150ms: あり opacity=0.68 class="ng-star-inserted slide-in"
  450ms: あり opacity=1.00 class="ng-star-inserted"
--- 新 API スライド leave
  直後: あり opacity=0.99 class="ng-star-inserted slide-out"
  150ms: あり opacity=0.68 class="ng-star-inserted slide-out"
  450ms: なし

従来 API の計測値も、直後 0.09、150 ミリ秒 0.68、450 ミリ秒 1.00 と同じ経過でした。transform と opacity のように複数のプロパティを同時に動かすときは、@keyframes の中に並べて書きます。

パターン3:リストの追加・削除(@for)

@for の中の要素に animate.enter と animate.leave を書くと、追加された行だけ、削除された行だけが動きます。

従来 API 新 API
要素 li 要素に @item li 要素に animate.enter="item-in" animate.leave="item-out"
定義 trigger('item', [transition(':enter', ...), transition(':leave', ...)]) .item-in、.item-out と @keyframes
--- 新 API リスト 初期表示: 2件
--- 新 API リスト 追加(3件目の行)
  直後: あり opacity=0.09 class="ng-star-inserted item-in"
  150ms: あり opacity=0.68 class="ng-star-inserted item-in"
  450ms: あり opacity=1.00 class="ng-star-inserted"
--- 新 API リスト 先頭を削除
  前: 備品の発注(opacity=1.00) / 会議室の予約(opacity=1.00)
  150ms: 備品の発注(opacity=0.74) / 会議室の予約(opacity=1.00)
  450ms: 会議室の予約(opacity=1.00)
--- 従来 API リスト 先頭を削除
  前: 備品の発注(opacity=1.00) / 会議室の予約(opacity=1.00)
  150ms: 備品の発注(opacity=0.68) / 会議室の予約(opacity=1.00)
  450ms: 会議室の予約(opacity=1.00)

削除ボタンを押した行は、150 ミリ秒の時点ではまだ DOM に残って薄くなっている途中で、450 ミリ秒では消えています。残った行の opacity は 1.00 のままです。@for の track に id のような一意の値を指定しておくと、どの行が増減したかを Angular が正しく見分けられます。

パターン4:開閉(要素を残したまま高さを変える)

開閉は、要素を出し入れするのではなく「同じ要素の状態を切り替える」動きです。新しい書き方では animate.enter / animate.leave を使わず、クラスの付け外しと CSS の transition で書きます。

従来 API 新 API
要素 [@openClose]="open() ? 'open' : 'closed'" [class.open]="open()"
状態の定義 state('open', ...) と state('closed', ...) .panel と .panel.open
動き transition('open <=> closed', [animate('300ms ease-in-out')]) transition: grid-template-rows 300ms ease-in-out, opacity 300ms ease-in-out
高さを中身に合わせる height にワイルドカードを指定 grid-template-rows: 0fr から 1fr、内側の要素に overflow: hidden
--- 従来 API 開閉 開く
  前: opacity=0.00 height=0px
  150ms: opacity=0.50 height=27px
  450ms: opacity=1.00 height=53px
--- 新 API 開閉 開く
  前: あり opacity=0.00 class="panel" height=0px
  150ms: あり opacity=0.50 class="panel open" height=27px
  450ms: あり opacity=1.00 class="panel open" height=53px
--- 新 API 開閉 閉じる
  前: あり opacity=1.00 class="panel open" height=53px
  150ms: あり opacity=0.50 class="panel" height=26px
  450ms: あり opacity=0.00 class="panel" height=0px

どちらも高さが 0px から中身の高さ(53px)まで、途中の 27px を通ってなめらかに変わりました。中身に合わせた高さへ動かすために、grid の行の大きさ(0fr から 1fr)を動かす形にしています。この方法は Angular の公式ドキュメント(移行ガイド)でも紹介されています。

関数で動きを書く:(animate.leave) と animationComplete

クラス名の代わりに、イベントバインディングの形で関数を渡すこともできます。Web Animations API やアニメーション用のライブラリで動きを作りたいときに使います。次のコンポーネントは、この節と次の「原因一覧」で使う確認用のものです。

// src/app/trouble-demo.ts
import { AnimationCallbackEvent, Component, signal } from '@angular/core';

@Component({
  selector: 'app-trouble-demo',
  template: `
    <button id="t1-btn" (click)="t1.set(!t1())">t1</button>
    @if (t1()) {
      <p id="t1" (animate.leave)="leaveWithoutComplete($event)">animationComplete を呼ばない</p>
    }

    <button id="t2-btn" (click)="t2.set(!t2())">t2</button>
    @if (t2()) {
      <p id="t2" (animate.leave)="leaveWithComplete($event)">animationComplete を呼ぶ</p>
    }

    <button id="t3-btn" (click)="t3.set(!t3())">t3</button>
    @if (t3()) {
      <p id="t3" animate.leave="fade-uot">クラス名のつづりが違う</p>
    }

    <button id="t4-btn" (click)="t4.set(!t4())">t4</button>
    @if (t4()) {
      <p id="t4a" class="tr-plain">transition だけ</p>
      <p id="t4b" class="tr-start">transition と starting-style</p>
    }

    <button id="t5-btn" (click)="t5.set(!t5())">t5</button>
    @if (t5()) {
      <section id="t5-wrap">
        <p id="t5" animate.leave="fade-out">親ごと消える子</p>
      </section>
    }

    <button id="t7-btn" (click)="t7.set(!t7())">t7</button>
    @if (t7()) {
      <p id="t7" animate.leave="fade-out-safe">動きを減らす設定に対応</p>
    }
  `,
  styles: `
    .fade-out {
      animation: fade-out 300ms ease-in;
    }
    @keyframes fade-out {
      to { opacity: 0; }
    }
    .tr-plain {
      opacity: 1;
      transition: opacity 300ms;
    }
    .tr-start {
      opacity: 1;
      transition: opacity 300ms;
      @starting-style {
        opacity: 0;
      }
    }
    .fade-out-safe {
      animation: fade-out 300ms ease-in;
    }
    @media (prefers-reduced-motion: reduce) {
      .fade-out-safe {
        animation: none;
      }
    }
  `,
})
export class TroubleDemo {
  readonly t1 = signal(true);
  readonly t2 = signal(true);
  readonly t3 = signal(true);
  readonly t4 = signal(false);
  readonly t5 = signal(true);
  readonly t7 = signal(true);

  leaveWithoutComplete(event: AnimationCallbackEvent): void {
    event.target.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 300, fill: 'forwards' });
  }

  leaveWithComplete(event: AnimationCallbackEvent): void {
    const animation = event.target.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 300 });
    animation.finished.then(() => event.animationComplete());
  }
}

関数には AnimationCallbackEvent が渡されます。型定義は次の2つのプロパティです。

プロパティ 内容
target 動かす対象の要素
animationComplete 動きが終わったことを Angular に知らせる関数。呼ぶと要素が DOM から削除される

(animate.leave) に関数を渡したときは、動きが終わったところで animationComplete() を呼びます。 呼んだ場合(t2)と呼ばない場合(t1)の結果です。

--- t2 animationComplete あり: leave
  直後: あり opacity=0.94
  150ms: あり opacity=0.50
  450ms: なし
--- t1 animationComplete なし: leave
  直後: あり opacity=0.94
  150ms: あり opacity=0.50
  450ms: あり opacity=0.00
  1000ms: あり opacity=0.00
  約4000ms で DOM から削除

呼ばない場合、透明になった要素が DOM に残り続け、約 4 秒後に削除されました。Angular の公式ドキュメントには、animationComplete() が呼ばれないときは 4 秒後に自動で削除され、この時間は MAX_ANIMATION_TIMEOUT というトークンで調整できると書かれています。

leave が効かないときの原因一覧

上のコンポーネントで1つずつ再現した結果をまとめます。

症状 原因 今回の結果 直し方
要素が透明のまま数秒残る (animate.leave) の関数で animationComplete() を呼んでいない(t1) 約 4000 ミリ秒後に削除 動きの完了時に event.animationComplete() を呼ぶ
動かずにすぐ消える クラス名のつづりが CSS と合っていない(t3) 直後に削除 テンプレートと CSS のクラス名をそろえる
動かずにすぐ消える そのクラスに animation が付いていない(動きを減らす設定で animation: none にした場合を含む、t7) 直後に削除 意図どおりならそのままでよい
ビルドが止まる 同じコンポーネントに従来 API と新 API を両方書いている NG2027 のエラー コンポーネント単位でどちらかにそろえる
出るときに動かない transition で書いていて、始まりの姿がない(t4a) 直後から opacity=1.00 @starting-style を足す(t4b)、または animation と @keyframes で書く

同じコンポーネントに両方を書くとビルドエラーになる

animations 配列を持つコンポーネントのテンプレートに animate.enter や animate.leave を書くと、ng build がエラーで止まりました。trouble-demo.ts の末尾に次のコンポーネントを足した場合の結果です(先頭の import に import { animate, style, transition, trigger } from '@angular/animations'; も足しています)。

// エラーになる例:animations 配列と animate.enter / animate.leave を同じコンポーネントに書く
@Component({
  selector: 'app-mixed-demo',
  animations: [
    trigger('fade', [
      transition(':enter', [style({ opacity: 0 }), animate('300ms', style({ opacity: 1 }))]),
      transition(':leave', [animate('300ms', style({ opacity: 0 }))]),
    ]),
  ],
  template: `
    <button id="m-btn" (click)="shown.set(!shown())">m</button>
    @if (shown()) {
      <p id="m-legacy" @fade>従来 API の要素</p>
      <p id="m-native" animate.enter="fade-in" animate.leave="fade-out">新 API の要素</p>
    }
  `,
  styles: `
    .fade-in {
      animation: fade-in 300ms ease-out;
    }
    .fade-out {
      animation: fade-out 300ms ease-in;
    }
    @keyframes fade-in {
      from { opacity: 0; }
    }
    @keyframes fade-out {
      to { opacity: 0; }
    }
  `,
})
export class MixedDemo {
  readonly shown = signal(false);
}
✘ [ERROR] NG2027: A component cannot have both the '@Component.animations' property (legacy animations) and use 'animate.enter' or 'animate.leave' in the template. [plugin angular-compiler]

    src/app/trouble-demo.ts:88:14:
      88 │   animations: [
         ╵               ^

置き換えは「コンポーネント単位」で進めます。 今回のアプリのように、従来 API のコンポーネントと新 API のコンポーネントが同じ画面に並んでいる構成は、ビルドも動作も問題ありませんでした。

transition で出るときの動きを書くなら @starting-style

CSS の transition は「値が変わったとき」に動くため、DOM に入ったばかりの要素には変化前の値がありません。@starting-style で始まりの姿を書くと動きます。

--- t4a transition だけ: enter
  直後: あり opacity=1.00
  150ms: あり opacity=1.00
--- t4b transition と starting-style: enter
  直後: あり opacity=0.04
  150ms: あり opacity=0.80
  450ms: あり opacity=1.00

t4b は animate.enter を書いていませんが、CSS だけで出るときの動きが付いています。消えるときの動きは Angular が要素の削除を待つ必要があるので、animate.leave が要ります。

親ごと消える要素の animate.leave は動く

@if の直下ではなく、その内側の子要素に animate.leave を書いた場合(t5)も、子の動きが終わってから削除されました。

--- t5 親ごと消える子: leave
  直後: あり opacity=0.99 class="fade-out"
  150ms: あり opacity=0.74 class="fade-out"
  450ms: なし

動きを減らす設定(prefers-reduced-motion)への対応

OS やブラウザの「動きを減らす」設定をしている人には、アニメーションを止めて見せるのが親切です。CSS のメディアクエリで animation を none にすると、animate.leave の要素は待たずに削除されます。

--- t7 通常: leave
  直後: あり opacity=0.99 class="ng-star-inserted fade-out-safe"
  150ms: あり opacity=0.68 class="ng-star-inserted fade-out-safe"
  450ms: なし
--- t7 動きを減らす設定: leave
  直後: なし

テストでアニメーションを有効にする

TestBed は、既定でアニメーションを無効にします。animate.leave の要素は待たずに消えるので、普段のテストは待ち時間なしで書けます。クラスが付くことや、終わってから消えることを確かめたいテストだけ、animationsEnabled: true を指定します。

// src/app/native-demo.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NativeDemo } from './native-demo';

describe('NativeDemo のアニメーション', () => {
  let fixture: ComponentFixture<NativeDemo>;
  const el = (selector: string) => fixture.nativeElement.querySelector(selector) as HTMLElement | null;
  const click = async (selector: string) => {
    el(selector)!.click();
    await fixture.whenStable();
  };
  const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

  it('既定ではアニメーションが無効で、leave の要素はすぐに消える', async () => {
    fixture = TestBed.createComponent(NativeDemo);
    await fixture.whenStable();

    await click('#n-fade-btn');
    expect(el('#n-fade')!.classList.contains('fade-in')).toBe(false);

    await click('#n-fade-btn');
    expect(el('#n-fade')).toBeNull();
  });

  it('animationsEnabled: true にすると、クラスが付き、終わってから要素が消える', async () => {
    TestBed.configureTestingModule({ animationsEnabled: true });
    fixture = TestBed.createComponent(NativeDemo);
    await fixture.whenStable();

    await click('#n-fade-btn');
    expect(el('#n-fade')!.classList.contains('fade-in')).toBe(true);
    await wait(400);
    expect(el('#n-fade')!.classList.contains('fade-in')).toBe(false);

    await click('#n-fade-btn');
    expect(el('#n-fade')!.classList.contains('fade-out')).toBe(true); // まだ DOM にある
    await wait(400);
    expect(el('#n-fade')).toBeNull(); // 300ms のアニメーションが終わって削除された
  });
});

CSS のアニメーションは実際のブラウザで動くものなので、このテストは Vitest のブラウザモード(Chromium)で実行します。

npm install -D @vitest/browser-playwright playwright@1.56.0
ng test --watch=false --browsers=chromiumHeadless
 RUN  v5.0.3

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Duration  2.55s

同じテストを、ng new の既定のテスト環境(jsdom)で実行した結果も載せておきます。

$ ng test --watch=false

 ❯  demo  src/app/native-demo.spec.ts (2 tests | 1 failed) 74ms
     × animationsEnabled: true にすると、クラスが付き、終わってから要素が消える 15ms

AssertionError: expected false to be true // Object.is equality
 ❯ src/app/native-demo.spec.ts:30:58
     30|     expect(el('#n-fade')!.classList.contains('fade-in')).toBe(true);
テスト環境 animationsEnabled enter のクラス leave の要素
jsdom(既定) 指定なし 付かない すぐ消える
jsdom(既定) true 付かない すぐ消える
Chromium(ブラウザモード) 指定なし 付かない すぐ消える
Chromium(ブラウザモード) true 付く(終わると外れる) 動きが終わってから消える

jsdom では animationsEnabled: true を指定しても、クラスは付かず要素はすぐ消えました。 表示の有無や文言を確かめるテストは jsdom のままで書け、アニメーションそのものを確かめるテストだけブラウザモードで動かす、という分け方が合っています。

置き換えの進め方

順番 すること
1 animations 配列を持つコンポーネントを一覧にする
2 1つのコンポーネントを選び、:enter / :leave の transition を animate.enter / animate.leave と @keyframes に書き換える
3 state を使った切り替えは、クラスの付け外しと CSS の transition に書き換える
4 そのコンポーネントの animations 配列と @angular/animations の import を消す(両方が残っていると NG2027)
5 すべてのコンポーネントで終わったら、provideAnimations() を外し、@angular/animations をアンインストールする

今回の画面も、従来 API のコンポーネントと新 API のコンポーネントを同じアプリに並べて動かしています。コンポーネントごとに少しずつ進められるので、動作を見ながら置き換えられます。

よくある質問

Q. BrowserAnimationsModule や provideAnimations() は、新しい書き方でも必要ですか? A. 必要ありません。animate.enter / animate.leave は @angular/core の機能で、今回の新 API だけのアプリは provider を何も足さずに動きました。

Q. animate.enter のクラスは付いたままになりますか? A. アニメーションが終わると外れます。今回の計測でも、直後と 150 ミリ秒では class に fade-in があり、450 ミリ秒ではなくなっていました。

Q. 最初から表示されている要素にも animate.enter は動きますか? A. 動きます。今回のリストは最初から2件を表示していますが、画面を開いた直後の1件目は opacity=0.00 で class に item-in が付き、200 ミリ秒で 0.68、700 ミリ秒で 1.00(item-in は外れる)になりました。従来 API の :enter でも同じ経過でした。公式ドキュメントには animate.enter にシグナルなどの式でクラスを渡せると書かれているので、最初の表示では動かしたくない場合はクラス名を式で切り替える形にできます。

Q. 従来 API はいつまで使えますか? A. @angular/animations 22.2.1 の型定義には「Intent to remove in v23」と書かれています。新しく書くコンポーネントは animate.enter / animate.leave と CSS で書き、既存のものはコンポーネント単位で置き換えていくのがおすすめです。

まとめ

  • 出るときは animate.enter、消えるときは animate.leave にクラス名を書き、動きは CSS の @keyframes で書く
  • :enter / :leave の transition は、そのまま animate.enter / animate.leave に置き換えられる(フェード・スライド・リストで同じ動きを確認)
  • 開閉のような状態の切り替えは、クラスの付け外しと CSS の transition で書く。高さは grid-template-rows の 0fr と 1fr で動かす
  • (animate.leave) に関数を渡したら animationComplete() を呼ぶ。呼ばないと約 4 秒残る
  • 同じコンポーネントに従来 API と新 API を両方書くと NG2027 のビルドエラー。置き換えはコンポーネント単位で進める
  • テストは既定でアニメーション無効。動きを確かめるテストは animationsEnabled: true とブラウザモードで実行する
  • 新 API だけにすると、今回の例では本番ビルドが 48.25 kB 小さくなった

参考・出典

  • Angular 公式ドキュメント(animate.enter と animate.leave のガイド) https://angular.jp/guide/animations
  • Angular 公式ドキュメント「ネイティブCSSアニメーションへの移行」 https://angular.jp/guide/animations/migration

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angularアニメーションの使い方|animate.enter移行表」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?