Angular Enter, Leave and State Animations with CSS and Signals [2026]

Link copied
Angular Enter, Leave and State Animations with CSS and Signals [2026]

Angular Enter, Leave and State Animations with CSS and Signals [2026]

Knowing that animate.enter exists is the easy part. The real work is translating the animations a design asks for, such as an accordion that eases open, a list that cascades in, a form field that shakes on error or a carousel that slides in the direction you navigated, into CSS that Angular can drive with a signal. Each of those used to be a trigger() with a handful of states and transitions. In a modern Angular app, each is a class, a few lines of CSS, and at most one Angular binding.

This is lesson 13.2 of the Angular Tutorial. It builds on lesson 13.1, which introduced animate.enter, animate.leave and the deprecation of @angular/animations. You'll learn how to animate state changes with classes bound to signals, how to handle multi-state components, how to re-trigger keyframe animations, how to choose enter and leave animations dynamically, how to stagger lists with CSS custom properties, and how each legacy trigger/state/transition pattern maps to the modern equivalent.

Four kinds of animation #

Kind Example Element in DOM? Modern tool
State change Accordion open/closed, active tab underline Stays Class or attribute bound to a signal + CSS transition
One-off effect Shake on invalid submit, pulse on new message Stays Keyframe class added, then removed on animationend
Enter / leave Toast appears and disappears, modal opens Added / removed animate.enter / animate.leave
List choreography Search results cascade in Added / removed animate.enter + --index custom property for delay

Working out which kind you have first saves a lot of fiddling: state changes don't need animate.enter, and enter animations don't need a state signal.

State changes: bind a class, transition in CSS #

The pattern is always the same. A signal holds the state, a class or attribute binding reflects it, and CSS describes the transition between the styles:

@Component({
  selector: 'app-accordion-item',
  template: `
    <button type="button" (click)="open.set(!open())" [attr.aria-expanded]="open()">
      {{ heading() }}
    </button>
    <div class="panel" [class.open]="open()">
      <div class="panel-inner"><ng-content /></div>
    </div>
  `,
  styleUrl: './accordion-item.css',
})
export class AccordionItem {
  heading = input.required<string>();
  open = signal(false);
}
/* accordion-item.css */
.panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 250ms ease-out;
}
.panel.open {
  grid-template-rows: 1fr;
}
.panel-inner {
  overflow: hidden;
}

Animating height from 0 to auto has never worked with plain transitions, which is why the old API had height: '*'. The grid technique above animates the row between 0fr and 1fr instead, so the panel reaches its natural height without you measuring anything. Newer CSS offers calc-size() and interpolate-size: allow-keywords for animating to auto directly; use them where your browser support allows, with the grid version as the fallback.

The binding only marks the component for refresh when open changes, which fits OnPush and zoneless change detection: the browser runs the transition itself, with no further change detection.

More than two states #

When a component has several states, a data attribute keeps the template simple and the CSS readable:

<div class="upload" [attr.data-state]="status()">…</div>
.upload {
  transition: background-color 200ms, border-color 200ms;
}
.upload[data-state='uploading'] { border-color: var(--accent); }
.upload[data-state='done']      { background-color: var(--success-bg); }
.upload[data-state='error']     { border-color: var(--danger); animation: shake 300ms; }

Here status is a signal<'idle' | 'uploading' | 'done' | 'error'> or a computed() from a resource's status. Each named state of an old trigger() becomes one attribute selector.

One-off effects with keyframes #

Some animations should play once in response to an event: a field shakes when submission fails, a badge pulses when a count changes. A keyframe animation on a class does that, but CSS only replays an animation when the class is removed and added again. Listen for animationend and reset:

@Component({
  selector: 'app-login-form',
  template: `
    <form [class.shake]="shaking()" (animationend)="shaking.set(false)" (submit)="submit($event)">
      …
    </form>
  `,
  styles: `
    .shake { animation: shake 300ms ease-in-out; }
    @keyframes shake {
      0%, 100% { transform: translateX(0); }
      25% { transform: translateX(-6px); }
      75% { transform: translateX(6px); }
    }
  `,
})
export class LoginForm {
  shaking = signal(false);

  async submit(event: Event) {
    event.preventDefault();
    const ok = await this.tryLogin();
    if (!ok) this.shaking.set(true);
  }
  // …
}

Note that animationend bubbles. If child elements run their own animations, check event.target === event.currentTarget in the handler, or the parent resets early.

Enter and leave, chosen at runtime #

animate.enter and animate.leave can be bound to expressions, so the animation can depend on state:

<aside [animate.enter]="compact() ? 'fade-in' : 'slide-in'">…</aside>

The entering element reads the expression when it's created, so this is reliable for entry. For direction-dependent pairs, such as a carousel where the next slide comes in from the right going forward and from the left going back, it's more robust to put the direction on a parent that stays in the DOM and let CSS pick the keyframes. The leaving element is being removed in the same update that changes the direction, so the parent is the element whose state you can count on being current:

@Component({
  selector: 'app-carousel',
  template: `
    <div class="viewport" [attr.data-direction]="direction()">
      @for (slide of current(); track slide.id) {
        <figure class="slide" animate.enter="slide-in" animate.leave="slide-out">
          <img [src]="slide.src" [alt]="slide.alt" />
        </figure>
      }
    </div>
    <button type="button" (click)="go(-1)">Previous</button>
    <button type="button" (click)="go(1)">Next</button>
  `,
  styleUrl: './carousel.css',
})
export class Carousel {
  slides = input.required<Slide[]>();
  index = signal(0);
  direction = signal<'next' | 'prev'>('next');
  current = computed(() => [this.slides()[this.index()]]);

  go(step: 1 | -1) {
    this.direction.set(step === 1 ? 'next' : 'prev');
    this.index.update(i => (i + step + this.slides().length) % this.slides().length);
  }
}
.viewport { display: grid; overflow: hidden; }
.slide { grid-area: 1 / 1; }          /* entering and leaving slides overlap */

.slide-in  { animation: in-from-right 300ms ease-out; }
.slide-out { animation: out-to-left 300ms ease-in forwards; }
[data-direction='prev'] .slide-in  { animation-name: in-from-left; }
[data-direction='prev'] .slide-out { animation-name: out-to-right; }

@keyframes in-from-right { from { transform: translateX(100%); } }
@keyframes in-from-left  { from { transform: translateX(-100%); } }
@keyframes out-to-left   { to { transform: translateX(-100%); } }
@keyframes out-to-right  { to { transform: translateX(100%); } }

Three details make this work. The @for with track slide.id over a one-item array means a new slide is a new element, so one enters while the old one leaves. grid-area: 1 / 1 stacks both in the same cell so they slide past each other instead of stacking vertically. And the forwards fill mode keeps the leaving slide at its end position until Angular removes it.

Staggered lists with custom properties #

The legacy stagger() function delayed each item's animation by a fixed step. In CSS, give each item its index as a custom property and compute the delay from it:

<ul class="results">
  @for (result of results(); track result.id; let i = $index) {
    <li animate.enter="rise" [style.--index]="i">{{ result.title }}</li>
  }
</ul>
.rise {
  animation: rise 300ms ease-out both;
  animation-delay: calc(min(var(--index), 10) * 40ms);
}

@keyframes rise {
  from { opacity: 0; transform: translateY(12px); }
}

The min(..., 10) cap matters for long lists: without it, item 80 would wait more than three seconds. The both fill mode keeps items invisible during their delay, so they don't flash at full opacity before animating. Angular removes the rise class once each item's animation has finished, including the delay.

A caveat for lists that refresh: the stagger plays every time items enter. With a good track expression (lesson 8.3), items that stay in the list keep their elements and don't animate again; only genuinely new ones do. With track $index or re-created objects, every row re-enters on every refresh.

Sequencing and parallel animations #

The old group() and sequence() helpers have direct CSS equivalents:

/* parallel: two animations at once, different durations */
.badge-in {
  animation: fade-in 200ms ease-out, pop 400ms cubic-bezier(.2, 1.4, .4, 1);
}

/* sequence: the second starts when the first ends */
.dialog-in {
  animation: backdrop-in 150ms ease-out, panel-in 250ms ease-out 150ms both;
}

For animations that must coordinate with logic, such as waiting for an animation to finish before focusing an element, use the Web Animations API: element.getAnimations() returns the running animations, and each has a finished promise.

await Promise.all(this.panel().nativeElement.getAnimations().map(a => a.finished));
this.firstField().nativeElement.focus();

Legacy to modern: a translation table #

Legacy @angular/animations Modern equivalent
trigger('openClose', [...]) + [@openClose]="state" [class.open]="open()" or [attr.data-state]="state()"
state('open', style({...})) A CSS rule for .open or [data-state='open']
transition('open <=> closed', animate('200ms')) transition: <props> 200ms on the element
transition(':enter', [style({opacity: 0}), animate(...)]) animate.enter="fade-in" + keyframes, or @starting-style
transition(':leave', animate(..., style({opacity: 0}))) animate.leave="fade-out" + transition or keyframes
height: '*' Grid 0fr/1fr, or calc-size() where supported
stagger(40, ...) animation-delay: calc(var(--index) * 40ms)
query('.item', ...) A CSS selector for .item in the component's styles
group([...]) Several comma-separated animations on one element
sequence([...]) animation-delay on later animations
keyframes([...]) @keyframes
(@openClose.done)="..." (transitionend) / (animationend), or getAnimations() + finished

Gotchas #

Symptom Cause Fix
Accordion jumps instead of animating Transitioning height to auto Grid 0fr → 1fr, or calc-size() where supported
Shake animation plays only the first time Class never removed, so CSS doesn't replay Reset the signal on animationend
Parent's animationend handler fires early Child animation events bubble Check event.target === event.currentTarget
Staggered items flash before animating No backwards fill during the delay Use animation-fill-mode: both
Whole list re-animates on every refresh Items re-created because of weak track track item.id so existing rows keep their elements
Leaving and entering slides stack vertically Both elements in normal flow during the overlap Put them in one grid cell (grid-area: 1 / 1) or position absolutely

What's next #

Lesson 13.3 animates between pages: Angular Router's withViewTransitions(), the ::view-transition-old and ::view-transition-new pseudo-elements, view-transition-name for shared-element transitions, customising or skipping transitions with onViewTransitionCreated, and what happens in browsers without support.

Try it yourself #

Find a component in your app that shows or hides a panel with @if and no animation. Decide which of the four kinds it is. If the element can stay in the DOM, convert it to a class bound to a signal with the grid height technique; if it must be removed, add animate.enter and animate.leave. Check it with the browser's Animations panel in DevTools to see the exact timing.

YouI’m migrating a notifications list that used query(‘:enter’, stagger(50, animate(…))). How do I get the same cascade without @angular/animations?
Claude · used get_best_practicesMove the animation onto the items themselves. In NotificationList, add let i = $index to the @for, then put animate.enter="notify-in" and [style.--index]="i" on each <li>. In the component CSS, give .notify-in your keyframe animation with animation-fill-mode: both and animation-delay: calc(min(var(--index), 10) * 50ms); the cap keeps long lists from waiting seconds for the last item. Remove the trigger() and the [@list] binding in the same change, since a component can’t mix legacy and new animations. Finally, check the @for uses track notification.id: with a stable track, only newly arrived notifications cascade, instead of the whole list replaying every time it refreshes.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

2 comments

2 thoughts on “Angular Enter, Leave and State Animations with CSS and Signals [2026]”

  1. Pingback: Angular Route Animations with View Transitions [2026]

  2. Pingback: Angular Animations: animate.enter and animate.leave [2026]

Leave a Comment

Your email stays private. Required fields are marked *

2 thoughts on “Angular Enter, Leave and State Animations with CSS and Signals [2026]”

  1. Pingback: Angular Route Animations with View Transitions [2026]

  2. Pingback: Angular Animations: animate.enter and animate.leave [2026]

Leave a Comment

Your email stays private. Required fields are marked *