DEV Community

loading...
Cover image for Systemizing Router-Based Animations in Angular

Systemizing Router-Based Animations in Angular

zackderose profile image Zack DeRose Updated on ・10 min read

What We'll Be Building

In this guide, we'll be building an 'animation system' for an Angular application, where our top-level routes will slide to the right or the left, based on an order configuration property.

The main idea in terms of user experience is to use animations to queue "spatial familiarity" with our app, by which I mean:

  • Routing from page 1 to page 2 in our app: page 1 slides to the left as page 2 slides in from the left
  • Routing from page 2 to page 1 in our app: page 2 slides to the right as page 1 slides in from the right

This is a little subliminal nudge that reinforces in our user the idea: "page 2 comes after page 1", and helps (if only just a little) their brain to build a map of our app.

Flipping through cards

As a slightly related aside: I learned recently that the best card-counters/"memory athletes" will typically associate each card of a 52-card deck with a person they know. They also come up with 52 spots in a house or physical location that they're familiar with. When they're given a deck to memorize, as they survey the order of cards, they'll "walk" though their imagined house, placing each person in one of these spots. When called upon to recall the order then, they'll mentally walk through this house again, naming the cards associated with each person as they go!

This is a 'brain hack' in that we are evolved to have special recognition and memory around human faces, and our brains tend to best associate the concept of 'sequence' with traversing a physical place.

In creating this type of animation system for our application, we're seeking to leverage these same principles, but in reverse.

This is hardly a necessary feature for all apps, but I find that it provides a nice experience for most users, and it's a little hint of craftsmanship, that I think helps a website/app stand out to users that are so used to the soul-less desert of our modern web where every random site you visit immediately asks for notification or location permissions before you even get started.

Here's a real-life example of this system in action, which I've actually implemented in my [work-in-progress] portfolio site: zackderose.dev!

Our Example from zackderose.dev

Top-Level Architecture

From a top-level, here's how we'll break down this problem:

  1. Create a slideLeft and slideRight animation to describe the sliding animations that components should perform as they enter/leave the DOM.
  2. Enumerate the 'states' of our app (the top-level routes).
  3. Create configuration that will map each of these states to their typical route config, plus an order property, that will determine whether to slide left/right as it enters/leaves.
  4. Create an array of all possible state transitions, as sourced by our state array and config data, to determine whether to slide left or right.
  5. Use our configuration data to create the route array that we'll pass to RouterModule import.
  6. Hook everything up with the rest of the Application!

Step 1: Creating Animations

Let's start by defining our @angular/animation objects. As stated, we'll create a slideLeft and a slideRight animation for our 2 scenarios, and we'll add them in a file we'll call animations.ts. Here's the slideLeft animation:

import { animate, group, query, style } from "@angular/animations";

const ANIMATION_SPEED = "500ms";

export const slideLeft = [
  query(":enter, :leave", style({ position: "fixed", width: "100%" })),
  group([
    query(":enter", [
      style({ transform: "translateX(150%)" }),
      animate(
        `${ANIMATION_SPEED} ease-in-out`,
        style({ transform: "translateX(0)" })
      )
    ]),
    query(":leave", [
      style({ transform: "translateX(0%)" }),
      animate(
        `${ANIMATION_SPEED} ease-in-out`,
        style({ transform: "translateX(-150%)" })
      )
    ])
  ])
];
Enter fullscreen mode Exit fullscreen mode

Walking through this quickly, the first item in our array will add a fixed position (so we can properly translate the elements) and 100% width (so that both elements take up the whole width of screen) to both the entering (:enter) and exiting (:leave) element.

The second item in this array is a nested group() that contains its own array. This group() will cause both of the inner animations to occur simultaneously, which we'll want as we want both the entering and exiting pages to be sliding at the same time to give us this 'spatial familiarity' effect.

For the entering element and sliding left, we'll want to start it out transformed by being translated 150% to the right of its destination. Then we'll animate it for 500ms (and using a ease-in-out sequence) to back to its normal position (the translateX:(0) in the above code).

For the exiting element, we'll do something very similar. The only difference is since the element on its way out is already starting out in the correct place, we can skip the initial transform and have a single animate() here that will transform 150% to the left over the same duration and with the same sequence.

slideRight is essentially this same thing in reverse. We'll export both of these animations now so we can use these down the road!

Step 2: Enumerating App States

Now let's think about our states. For this example, we'll have 3 states: 'home', 'page-1', and 'page-2':

export const STATES = ["home", "page-1", "page-2"] as const;
Enter fullscreen mode Exit fullscreen mode

For Typescript magic we'll take more advantage of later, we'll create a Type that uses the above read-only array as it's source of truth:

export type ExampleAppState = typeof STATES[number];
Enter fullscreen mode Exit fullscreen mode

This will behave the same as a 'union type' of all the states from our STATES array, while still having the benefit of a single source of truth for both the array of states (which we'll use later to help define our state transitions and routes) and the typing, which we'll leverage to prevent typos and to ensure all states are present as keys to our upcoming configuration map.

Proper Intellisence

Step 3: Configuration

Next, we'll define a configuration interface that will store all configuration our app will use for a given state:

interface StateConfiguration {
  path: string;
  component: any;
  order: number;
  linkText: string;
}
Enter fullscreen mode Exit fullscreen mode

path and component will be used the same as you would with a standard Route from the Angular router. order will be used to order our links, as well as to determine the proper animation direction for a given state transition, and linkText will be used for putting correct text into our template.

Since we'll want a single configuration object that should contain a property for every state, we'll reach forRecord<ExampleAppState, StateConfiguration> - which will give us exactly that!

export const stateConfiguration: Record<ExampleAppState, StateConfiguration> = {
  home: {
    path: "",
    component: HomeComponent,
    order: 0,
    linkText: "Home"
  },
  // ....
};
Enter fullscreen mode Exit fullscreen mode

Note too that when we add a new state to our STATES array, Typescript will now warn us with an appropriate clarifying message, that the new state is missing from the stateConfiguration object!

Attempting to add a new route

Step 4: Creating a Router Transition trigger()

Next, we'll create an @angular/animations trigger() that will list all of our state-to-state transitions, and define the entering and leaving animation associated with each state.

Normally, this would look something like this:

const routerTransition = trigger(
  'routerTransition',
  [
    transition('home => page-1', shiftLeft),
    transition('page-1 => home', shiftRight)
    // ... more for every possible transition
    // for these 3 states: 2 * 3 = 6
    // if 4 states: 3 * 4 = 12
    // if 5 states: 4 * 5 = 20
    // ...etc.
  ]
);
Enter fullscreen mode Exit fullscreen mode

Lots of Typing

But that's a whole bunch of typing - and more typing give us more chance for a typo in our names (which are not type-safe... though with using TS 4.1 template types, we could get there:

type RouterTransition = `${ExampleAppState} => ${ExampleAppState}`;
Enter fullscreen mode Exit fullscreen mode

There's also a good chance of accidentally using a wrong animation for maybe just one or two of the transitions (which is immeasurable worse than them all being wrong - since the bug wouldn't be immediately apparent while using the app!).

So instead, we'll look to programmatically build out this information from the STATES array and the stateConfiguration object.

Let's start by creating an array of every possible valid tuple of states. This looks like:

const allStateCombinations: [
  ExampleAppState,
  ExampleAppState
][] = STATES.reduce(
  (acc, state, index) =>
    acc.concat(
      STATES
        .filter((_, i) => i !== index)
        .map(target => [state, target])
    ),
  []
);
Enter fullscreen mode Exit fullscreen mode

There's a bit of fancy reduce()ing happening here, but essentially what this code is doing is saying:

  1. Start with an empty array
  2. For each state, append to that array all possible tuples where the given state is the first state of the tuple. For example, for the first state, home, those tuples will look like this:
[
  ['home', 'page-1'],
  ['home', 'page-2']
]
Enter fullscreen mode Exit fullscreen mode

With this array of all tuples built, we can now map each tuple to the correct transition() object in our trigger(), based on stateConfiguration data:

export const routerTransition = trigger(
  "routerTransition",
  allStateCombinations.map(([entering, leaving]) =>
    transition(
      `${entering} => ${leaving}`,
      stateConfiguration[entering].order < stateConfiguration[leaving].order
        ? slideLeft
        : slideRight
    )
  )
);
Enter fullscreen mode Exit fullscreen mode

Note that we're destructuring our tuples here in our map() to an entering and leaving state. The string defining the transition is now: ${entering} => ${leaving}, and depending on the order property of the entering and leaving's state configuration, the animation associated with that state transition will either be slideRight or slideLeft.

Perfect! Now we're export this routerTransition trigger() so we can use it in our application!!

Step 5: Creating Routes

Similarly to how we programmatically built our @angular/animations trigger() from the STATES and stateConfigurations sources of truth, we'll look to do the same here! (But this should be an order of magnitude easier)

To create our routes array, we'll map out the STATES array and enrich it with the relevant data from the stateConfiguration object:

export const routes = STATES.map(state => ({
  path: stateConfiguration[state].path,
  component: stateConfiguration[state].component,
  data: { state } // <== note that here we are associating
                  // a `state` with the route data, which
                  // we'll use later in our template
}));
Enter fullscreen mode Exit fullscreen mode

Step 6: Hooking This Up to the Rest of our App

Now that we've got all the lego blocks we'll need properly exported from our routes.ts file, let's go ahead and hook these up to the rest of our app.

First, the AppModule:

import { routes } from "./routes";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";

@NgModule({
  imports: [
    BrowserModule,
    FormsModule,
    RouterModule.forRoot(routes), // <== routes go here!
    BrowserAnimationsModule // <== don't forget to import this!!
  ],
  declarations: [AppComponent, HomeComponent, Page1Component, Page2Component],
  bootstrap: [AppComponent]
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Next up, our AppComponent. We'll start with the @Component() decorator:

import { routerTransition } from "./routes";

@Component({
  selector: "my-app",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"],
  animations: [routerTransition] // <== add the trigger here
})
export class AppComponent {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

And inside the component class, we'll create a links property based off our STATES and stateConfiguration:

import {
  stateConfiguration,
  STATES
} from "./routes";

@Component({
  // ...
})
export class AppComponent {
  // ...

  links: { linkText: string; path: string }[] = [...STATES]
    .sort((a, b) =>
      stateConfiguration[a].order > stateConfiguration[b].order ? 1 : -1
    )
    .map(state => stateConfiguration[state]);
}
Enter fullscreen mode Exit fullscreen mode

Note that I think there's a decent argument for wrapping this up into a factory function that lives in the routes.ts file, but I think works just fine as well.

Finally, the AppComponent Template:

<h1>Example App</h1>

<nav>
  <ol>
    <a *ngFor="let link of links" [routerLink]="link.path">
      <li>
        {{ link.linkText }}
      </li>
    </a>
  </ol>
</nav>

<main [@routerTransition]="(o.activatedRoute.data | async).state">
  <router-outlet #o="outlet"></router-outlet>
</main>
Enter fullscreen mode Exit fullscreen mode

Note that the [@routerTransition]="(o.activatedRoute.data | async).state" directive is named after the routerTransition trigger that we wrote. The value passed to this directive should be a statement that evaluates to the current state. Since we've created a reference #o to the <router-outlet>'s outlet, we can listen to emissions from that outlet's activedRoute.data Observable with the async pipe. Since in our routes data, we add the state property to each route's data (see the note in the code comment in Part 5 above), we can get this state property out of that data object emitted.

The SYSTEMZ

Together this all works well, and creates a fairly solid architecture, in my opinion. Our routes.ts serves as a system that fairly easily plugins in with the rest of our app. Further, the simple STATES array and stateConfiguration object operate as sources of truth, from which all other data/logic/behavior of our routing and animations is derived! This is further solidified by proper Typescript typing - to prevent typos or mis-configurations - and further serves to enforce the system we've built going forward.

Solid Architecture

I would venture to say that, even without comments, another engineer would be able to add another route to this app, and without direct communication with us, they'd be able to deduce how to add another route. I base this claim on the fact that this system is so ubiquitous to our implementation, that it would be much more difficult to rip it out than continue to follow it. And the way that it's built makes the patterns straight-forward to follow and append to.

Source Code

For a working demo with full source-code, be sure to check out:

Where We Could Take This Next

Let's do something fun with this one: I'll commit to writing some more blogs building up on the topics presented here based on the number/kind of up-votes this post gets:

20 💙s: Adding swiping for routing with hammerjs
35 🦄s: Explanation and demo of list animations (like the ones in the gif at the beginning and at zackderose.dev
50 📕s: Adding a vertical dimension to our "spacial familiarity" concept - and using it for children of the existing top-level routes.

That's it! Looking forward to seeing how this turns out!!

Credit Where It's Due!

Much credit to this article by Gerard Sans for this article where I originally learned about router animations, and (Matias Niemela)[https://www.yearofmoo.com/2017/06/new-wave-of-animation-features.html] for great in-depth explanations of many of the features of @angular/animations!! (I believe Matias is also responsible for writing the original code for @angular/animations!)

More Content By Zack

Blogs
YouTube
Twitch
Twitter
All Video Content Combined

Discussion (0)

pic
Editor guide