If you were paying attention, the Angular team now advises against using the Angular Animations package. It comes as no surprise, since CSS animations are super powerful and Web Animations API is now supported by all browsers. A time when we might need this 60kB extra package is over and the official suggestion is to migrate according to this guide. Most of the time that would be a walk in the park, however one little case still prevents us from migrating easily. And that is the :leave state – performing an animation on element removal from DOM. Angular currently has an RFC for a way to remedy that. But what if we want to lighten our bundle right now? In Taiga UI, a components library I work on, we implemented a similar approach a few weeks before RFC was published. Let’s see how it works.

Angular Renderer

A place where this is done is the Renderer. Angular animations package provides its own renderer factory which creates a special renderer for animations, as well as the default one under the hood. I do not say this often, but this is where Angular has a weak spot in terms of customization. While usually we can augment any behavior with DI, in this case it falls short. If you are working on an app, it’s usually enough – you can just remove animations and provide your own renderer factory with the implementation we are about to make. But if you are working on a library you do not have control over this. The app using your library might still rely on animations somewhere or even have its own renderer. So while we wait for the Angular team to add official support for :leave state – we will have to hack our way into it and augment renderer behavior on the fly.

Specifying the task

We want the following:

  1. Apply app-enter class on the element when it is created
  2. Remove it after animations play out or if there were no animations
  3. Apply app-leave class on the element when it is removed and keep it in the DOM
  4. Remove it from DOM when animations play out or if there were no animations

This is a job for a directive that we will call appAnimated. We will also have some global styles to simplify setting it up. And if we need to apply animations to components, we can use it as a host directive. For both host directives overview and adding styles to directives I refer you to my previous article. Here are the styles we want to always be applied to our elements:

.app-enter,
.app-leave {
    animation-duration: var(--app-duration);
    pointer-events: none;
}


.app-leave {
    animation-direction: reverse;
}

All of those rules are easily overridden, but they give us a good baseline, allowing us to set speed and to just specify animation-name and it will be played in both directions properly. Disabling pointer-events on animations is optional, but I recommend doing so, otherwise you might get into some unwanted hover effects and unexpected clicks on fading drops-down lists etc. You can even disable animations by setting the duration variable to 0 based on prefers-reduced-motion.

Making a directive

Writing the actual directive would require 2 hacks. The first one is getting the renderer. You might think it’s as easy as injecting it, but unfortunately it does not always work. If your component is created via ngComponentOutlet, for example, or imperatively with createComponent the renderer is hardcoded in the private API.

That means if we want this to work all the time, instead of injecting the renderer we want to do this:

inject(ViewContainerRef)._hostLView[11]

Don’t worry, this hasn’t changed since the introduction of Ivy engine, and we are only doing this until official support from Angular ships. We will also need the host element and the ApplicationRef so we can trigger a tick once the animation finishes.

Our directive will apply app-enter class using the host property of its decorator, and we will add a listener for animationend / animationcancel events to remove the class. We will also use afterNextRender to check to remove the class if the host element getAnimations() list is empty (i.e. if there were no enter animations and, therefore, no event would fire).

Note: we do not want to react to bubbling animation events from nested elements. In callback you can check that $event.target === $event.currentTarget or, if you value DX, you can add our event plugins library and just write (animationend.self) which does the same thing under the hood.

Drop us a star if our package is helpful to you!

The second hack is monkey-patching the renderer since we want it to keep elements in DOM and not remove them immediately.

Patching the Renderer

First of all, we need to track elements that have the directive applied to them, so that we can delay their removal. Angular Renderer has a prop called data designed specifically to store arbitrary data and that is what we’ll use. Our directive would add host element to the array and remove it from the array in ngOnDestroy (in a setTimeout so that it is removed after Renderer already processed it).

Here’s what we will replace original removeChild method with:

renderer.removeChild = (parent: Node, el: Node, host?: boolean) => {
   const remove = (): void => removeChild.call(renderer, parent, el, host);
   const elements: Element[] = data['app-leave'];
   const element = elements.find((leave) => el.contains(leave));   

   if (!element) {
       remove();

       return;
   }

   element.classList.remove('app-enter');

   const {length} = element.getAnimations();

   element.classList.add('app-leave');

   const animations = element.getAnimations();
   const last = animations.at(-1);
   const finish = (): void => {
       if (!parent || parent.contains(el)) {
           remove();
           this.app.tick();
       }
   };


   if (animations.length > length && last) {
       last.onfinish = finish;
       last.oncancel = finish;
   } else {
       remove();
   }
};

First we try to find the element we are about to remove in the stored array of animated elements, and we will check how many animations it currently has (after removing app-enter class, in case enter animation is still playing). If there’s no such element we proceed with the original removeChild method.

If we found that element we add app-leave class to it and query animations list again. What’s super cool is that if duration is set to 0, browsers will automatically synchronously report that there’s no new animations right on the next line after we added the class! So if the length of the animation list remained the same, we once again use original removeChild. If new animations appear in the list, we add listeners to the last one and call removeChild after it finishes.

Trying it out

That’s pretty much it. Now we can give it a shot and create a CSS animated slideshow using grid layout and simple animations. We will use fancy new @starting-style rule and transitions to show off and to switch directions as we slide left or right. Grid layout will allow all images to be placed in the same spot without absolute positioning, meaning layout under the slideshow will be properly shifted down by the height of the images. We want our images to scale and fade so that’s what we will have as animations:

@keyframes fade {
 from {
   opacity: 0;
 }
}


@keyframes scale {
 from {
   transform: scale(0);
 }
}

We will use class on parent to set direction of use sliding so that we can move images in different directions, based on app-leave class and @starting-style rule:

img {
  // …
  transition: left var(--app-duration, 500ms);

  &.app-leave,
  &.app-enter {
    animation-name: fade, scale;
  }


  @starting-style {
    left: -16rem;
  }


  ._forward & {
    @starting-style {
      left: 16rem;
    }
  }


  &.app-leave {
    left: 6rem;


    ._forward & {
      left: -6rem;
    }
  }
}

Now all we need to do is show only 1 image at a time like that:

@for (image of images; track $index) {
  @if ($index === current()) {
    <img appAnimated [src]="image" />
  }
}

And that’s it! You can check out a full live demo on this StackBlitz. And if you are using the latest Taiga UI version, this is already available for you. In the next major release coming Summer 2025 we will drop @angular/animations dependency completely so that there’s no breaking changes.



Tagged in:

Articles

Last Update: July 09, 2025