Why Angular Animations May No Longer Be Necessary
The Angular team has officially recommended against using the Angular Animations package. This isn't surprising—CSS animations have grown incredibly capable, and the Web Animations API is now universally supported in browsers. The era where we needed an extra 60kB package for animations is behind us, and the formal advice is to follow the migration guide. For most scenarios, migrating is straightforward, but there's one particular edge case that makes the transition tricky: the :leave state—animating an element as it's being removed from the DOM. There's an ongoing RFC to address this. However, if you want to trim your bundle size today, there's a workable solution. In Taiga UI, the component library I contribute to, we built a similar approach just weeks before that RFC appeared. Here's how it works.
The Renderer and Its Limitations
The key hook here is the Renderer. Angular's animations package supplies its own renderer factory that produces a specialized renderer for animations on top of the default one. This isn't something I point out often, but this is where Angular's customization story has a gap. Usually, we can extend any behavior through dependency injection, but this case is an exception. For application developers, it's manageable—you can strip out animations and provide your own renderer factory with the implementation we'll detail. Yet, for library authors, you lack that control. The consuming app might depend on animations elsewhere or even use its own renderer. So, while we await official :leave support from Angular, we'll need to find a way to dynamically augment the renderer's behavior.
Defining the Requirements
Our goal is straightforward:
- Add an
app-enterclass when an element is first created - Remove that class once animations finish—or immediately if there are none
- Apply an
app-leaveclass when removing an element, while keeping it in the DOM - Actually remove it from the DOM after animations complete—or right away if there are none
This logic fits neatly into a directive we'll call appAnimated. We'll also set up some global styles to make usage easier. When targeting component elements, this directive can act as a host directive—for more on both host directives and adding styles to directives, see my earlier article. Here are the base styles we want applied to our elements:
.app-enter,
.app-leave {
animation-duration: var(--app-duration);
pointer-events: none;
}
.app-leave {
animation-direction: reverse;
}
These rules are all easy to override, but they establish a solid foundation: we can set the animation speed once, and then just specify an animation-name to get consistent behavior in both enter and leave directions. Disabling pointer-events during animations is a recommendation, not a requirement, but I suggest it to avoid unwanted hover effects or stray clicks on fading dropdowns. You can even switch off animations entirely by setting the speed variable to 0, for instance, when the user prefers reduced motion via prefers-reduced-motion.
Building the Directive
Creating the actual directive requires two workarounds. The first involves accessing the renderer. You'd assume it's as simple as injecting it, but that's not always reliable. If a component is created via ngComponentOutlet or imperatively with createComponent, for instance, the renderer is baked into the private API.
To ensure this works in every scenario, rather than injecting the renderer, we'll use this approach:
inject(ViewContainerRef)._hostLView[11]
Rest assured, this API has remained stable since the Ivy engine was introduced, and we're only using it as a temporary bridge until Angular provides proper support. We'll also require the host element and the ApplicationRef to permit an application tick when animations complete.
Our directive will assign the app-enter class using the host property in its decorator. We'll attach listeners for animationend and animationcancel events to strip off that class. Additionally, in afterNextRender, we'll verify if the host's getAnimations() list is empty (meaning no enter animations ran, so no event would fire) and remove the class in that case.
One caveat: we should avoid reacting to bubbling animation events from nested elements. In the event callback, you can check if $event.target === $event.currentTarget. Alternatively, for better developer experience, you could use our event plugins library and simply write (animationend.self), which accomplishes the same check under the hood.
If our package proves useful, feel free to give it a star!
The second workaround is monkey-patching the renderer, because we want it to postpone DOM removal rather than immediately deleting elements.
Patching the Renderer
First, we need to keep a registry of elements using our directive so we can delay their removal. Angular's Renderer includes a data property—see the docs—which is designed for storing arbitrary per-renderer information, and that's perfect for our use case. Our directive will put the host element into an array and then remove it in ngOnDestroy (wrapped in a setTimeout so the renderer processes it first).
Here's our replacement for the original removeChild method:
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();
app.tick();
}
};
if (animations.length > length && last) {
last.onfinish = finish;
last.oncancel = finish;
} else {
remove();
}
};
We start by checking if the element about to be removed is in our tracked list. If so, we count how many animations it currently has running (after stripping the app-enter class, in case an enter animation is still active). If the element isn't in our registry, we call the original removeChild as usual.
If we do find it, we add the app-leave class and query the animation list again. A nice detail: when the animation duration is 0, browsers conveniently report synchronously on the very next line that no new animations exist! So, if the animation count is unchanged, we fall back to the original removeChild method. But if new animations have appeared, we subscribe to the final one's completion and then invoke removeChild once it's done.
Putting It to the Test
That covers the core implementation. Now we can test it: we'll build a CSS-animated slideshow using grid layout and simple keyframe animations. We'll take advantage of the modern @starting-style rule and transitions to create slide effects in both directions, controlled by a class. The grid layout lets all images occupy the same spot without needing absolute positioning, which means the content below the slideshow is naturally pushed down according to the image height. Our intended animations will scale and fade the images:
@keyframes fade {
from {
opacity: 0;
}
}
@keyframes scale {
from {
transform: scale(0);
}
}
We'll toggle a class on the parent container to set the slide direction, dictating whether images move left or right based on the app-leave class and the @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 that's left is to display just one image at a time:
@for (image of images; track $index) {
@if ($index === current()) {
<img appAnimated [src]="image" />
}
}
And we're done! You can explore a full live example on StackBlitz. If you're on a recent Taiga UI version, this functionality is already built in. Looking to the future, the next major release, due Summer 2025, will drop the @angular/animations dependency entirely to avoid any breaking changes.


