Animations: An Overview of Transitions and Timelines

Up to this point in the series, we have explored how AnimationRendererFactory produces its renderers.
Let's now take a closer look at what it means to actually render an animation transition.


Animations: Transitions vs. Timelines

The primary focus of this series is transition animations—the declarative kind that run when an element changes state.
There is, however, another category of animations in Angular: timeline animations, which are triggered explicitly by constructing a dedicated player through the AnimationBuilder class.
Both types share much of the animation lifecycle, yet they differ in specific ways, so the framework must be able to tell them apart when routing their requests through common machinery.
Ultimately, both rely on element properties: transitions expect their properties to be declared in templates, while timeline animations map commands from a player onto DOM nodes.
To differentiate between the two, the framework relies on a simple convention:

  • Properties for transition animations are prefixed with a single @ character, which is added explicitly in the template by the developer, e.g. [@yourAnimationName]="yourCompProp".
  • Properties for timeline animations are automatically prefixed with two @@ characters by RendererAnimationPlayer.

    function issueAnimationCommand(
    renderer: AnimationRenderer, element: any, id: string, command: string, args: any[]): any {
    return renderer.setProperty(element, `@@${id}:${command}`, args);
    }
    

Although this article does not cover timeline animations in depth, this distinction matters because the classes we're about to inspect perform checks based on it to send requests down the correct branch.

Overrides in AnimationRenderer: Where Animation Flow Begins

When the AnimationRendererFactory detects animation triggers in the @Component metadata, it creates an AnimationRenderer instance.
Examining the source, you'll see it extends the "dumb" BaseAnimationRenderer class introduced in the first article of this series.
Two overrides stand out: setProperty and listen.

The setProperty method belongs to Renderer2 and is responsible for adding or updating properties on DOM elements. As mentioned earlier, this is how Angular kicks off the animation process.
The listen method, on the other hand, attaches event listeners to DOM elements. The framework uses it to fire any callbacks you've defined, such as those for the start and done phases of a transition animation.

Here's the constructor of AnimationRenderer:

export class AnimationRenderer extends BaseAnimationRenderer implements Renderer2 {
  constructor(
      public factory: AnimationRendererFactory, namespaceId: string, delegate: Renderer2,
      engine: AnimationEngine, onDestroy?: () => void) {
    super(namespaceId, delegate, engine, onDestroy);
    this.namespaceId = namespaceId;
  }
  ...
Enter fullscreen mode Exit fullscreen mode

Its dependencies include:

  • a reference to the factory, though this is only used for an optimization not covered here (you can check the relevant commit if you're curious).
  • the namespaceId, which uniquely identifies this renderer (explained thoroughly in the previous article).
  • a delegate *DOMRenderer used for non-animation-related tasks.
  • an AnimationEngine, which inspects the nature of each animation and forwards it to the appropriate engine—either TransitionAnimationEngine or TimelineAnimationEngine.
  • a callback for destruction, invoked when the renderer is torn down.

That's all the information it needs to carry out its duties.

setProperty(...): Igniting the Animation

Inside AnimationRenderer

As established, every animation begins when a dedicated DOM property is changed using the setProperty override of AnimationRenderer:

override setProperty(el: any, name: string, value: any): void {
  if (name.charAt(0) == ANIMATION_PREFIX) {
    if (name.charAt(1) == '.' && name == DISABLE_ANIMATIONS_FLAG) {
      value = value === undefined ? true : !!value;
      this.disableAnimations(el, value as boolean);
    } else {
      this.engine.process(this.namespaceId, el, name.slice(1), value);
    }
  } else {
    this.delegate.setProperty(el, name, value);
  }
}
Enter fullscreen mode Exit fullscreen mode

It takes the standard arguments: the target element, the property name, and the value to be set.
Here's what happens inside:

  • It first checks whether the property is actually an animation trigger by seeing if its name begins with the ANIMATION_PREFIX, which is the @ character. If not, the call simply goes to the native *DOMRenderer.
  • When the property does look like an animation binding, the code looks for a disable flag. This flag is set by giving the element a property called @.disabled. To find it, the code checks for a dot . right after the @, and then for the word disabled. If that's the case, the engine is asked to disable animations for that element and all its descendants.
  • If it's a real animation request, the AnimationEngine method process is invoked with these parameters: the namespaceId, the element, the property name minus the leading @, and the new value.

Inside AnimationEngine

The AnimationEngine processes the request this way—keeping in mind that AnimationRenderer has already removed that first @:

process(namespaceId: string, element: any, property: string, value: any) {
  if (property.charAt(0) == '@') {
    const [id, action] = parseTimelineCommand(property);
    const args = value as any[];
    this._timelineEngine.command(id, element, action, args);
  } else {
    this._transitionEngine.trigger(namespaceId, element, property, value);
  }
}
Enter fullscreen mode Exit fullscreen mode
  • It begins by checking the very first character of the property name:
    • If another @ appears, this is a timeline animation, which has the structure @@${id}:${command}. The method parseTimelineCommand tokenizes the property name, removing the second leading @ and splitting on the colon :, which gives the id for the relevant BrowserAnimationFactory and the command name to execute. The property's current value, if any, is turned into an array and passed along as arguments for the command.
    • If the first character is anything other than @, it's without a doubt a transition animation. In this case, the request goes to TransitionAnimationEngine, specifically to its trigger method. That method starts the chain of logic within TransitionAnimationNamespace. After extensive processing to collect and queue all relevant animations on the affected elements, it ultimately initiates one or more TransitionAnimationPlayer instances.

listen(...): capture that animation event

AnimationRenderer

No matter which type of animation we're dealing with, there are times when we need to schedule work around its playback — for instance, right before it starts or after it wraps up. For timeline animations, that also includes the moment the player is explicitly torn down.
Angular gives us a set of listener hooks we can attach callbacks to, making this straightforward.
When working with transition animations, we wire our functions directly in the template via phase outputs, written as (@yourAnimationName.start)="yourFunction()" or (@yourAnimationName.done)="yourFunction()".
But there's a naming convention we need to respect, just like with setProperties:

  • transition animation events require a single @ prefix
  • for timeline animation events, the RendererAnimationPlayer automatically prepends two @@ characters.

    private _listen(eventName: string, callback: (event: any) => any): () => void {
    return this._renderer.listen(this.element, `@@${this.id}:${eventName}`, callback);
    }
    

Those events are intercepted by the listen method on AnimationRenderer, which we'll now break down:

override listen(
    target: 'window'|'document'|'body'|any, eventName: string,
    callback: (event: any) => any): () => void {
  if (eventName.charAt(0) == ANIMATION_PREFIX) {
    const element = resolveElementFromTarget(target);
    let name = eventName.slice(1);
    let phase = '';
    // @listener.phase is for trigger animation callbacks
    // @@listener is for animation builder callbacks
    if (name.charAt(0) != ANIMATION_PREFIX) {
      [name, phase] = parseTriggerCallbackName(name);
    }
    return this.engine.listen(this.namespaceId, element, name, phase, event => {
      const countId = (event as any)['_data'] || -1;
      this.factory.scheduleListenerCallback(countId, callback, event);
    });
  }
  return this.delegate.listen(target, eventName, callback);
}
Enter fullscreen mode Exit fullscreen mode

The method takes three parameters:

  • the target element where we want to observe the event
  • the event's identifier
  • the callback to invoke when it fires

Here's how the logic flows:

  • The first check mirrors what we saw in setProperty: if the event name's initial character isn't @, this isn't an animation-related event, so responsibility falls back to the underlying native *DOMRenderer.
  • If the target was supplied as a string, it gets resolved to its corresponding HTML element — though for transition animations it's hard to imagine when that'd happen, since the Angular template parser is what registers listeners, and entities like window, document, and body sit outside its reach. If you have insight here, feel free to chime in below.
  • We then strip away the leading @ from the event name.
  • Peeking at the first character of what's left, we decide the next step:
    • If it's not another @, we're dealing with a transition animation event and need to break it apart:
      • everything left of the dot . is the trigger name
      • everything right of it is the phase we want to listen for
    • If a second @ shows up, we pass the name along untouched, letting AnimationEngine sort it out — more on that in a moment.
  • Finally, we hand off to AnimationEngine's listen method with these arguments:
    • the namespaceId tied to this renderer
    • the element we resolved earlier
    • the event name (parsed out for transition animations, or kept intact for timeline animations)
    • the phase name (similarly extracted, or an empty string for timeline cases)
    • a wrapper that schedules our real callback on a microtask — though for readability here, we'll treat it as if it were our function directly

AnimationEngine

Just as with setProperty, AnimationEngine doesn't do heavy lifting when it comes to event listening — it mostly routes things to the appropriate sub-engine:

listen(
    namespaceId: string, element: any, eventName: string, eventPhase: string,
    callback: (event: any) => any): () => any {
  // @@listen
  if (eventName.charAt(0) == '@') {
    const [id, action] = parseTimelineCommand(eventName);
    return this._timelineEngine.listen(id, element, action, callback);
  }
  return this._transitionEngine.listen(namespaceId, element, eventName, eventPhase, callback);
}
Enter fullscreen mode Exit fullscreen mode

There's another character check on the event name, which by now feels familiar:

  • If it begins with @, that signals the caller AnimationRenderer's listen didn't tokenize it — meaning we have a timeline animation event. In that case:
    • we remove the leading @
    • we split the remainder at the colon : to pull out the player's id and the action name
    • we invoke the dedicated TimelineAnimationEngine's listen, which locates the right callback registered against that player
  • Otherwise, we forward it unchanged to TransitionAnimationEngine's listen. That method grabs the correct AnimationTransitionNamespace using the namespaceId it received, and then delegates to that namespace's listen, which is what actually appends the listener to the element's collection.

By now, the flow behind registering a transition animation in Angular should feel at least somewhat familiar.
I deliberately glossed over certain implementation specifics and didn't chase the call chain all the way into the playback mechanics of players and their callbacks — that would've muddied the waters for you as much as it did for me.
The goal here was to stick to concepts I've genuinely internalized, but as always, corrections and insights are welcome in the comments.

Thanks for reading!