Journal

Build a router for creative transitions

Animating route transitions can quickly become a nightmare depending on the router you're dealing with. It's a subject I wanted to tackle early on so I'd never be limited in developing transition scenarios, which led me to develop my own solutions.

Build a Router

At first, I was amazed by the idea of a web interface behaving like a native application. Navigation became so smooth and enjoyable. I started by using AJAX requests (do you know pjax?), then built my own library compose to load new pages without reloading the entire context, back when routing was still server-side. Then came the concept of SPAs (Single Page Applications), which was a major shift in how we build web apps: only the first request was made to the server, and after that, routing happened on the client side.

It was the gateway to a world of graphic possibilities, much like when CSS3 arrived. The web literally started to move, a lot.

rezo-zero.com page transition
justinesoulie.fr page transition
oggy-story.com page transition

In addition to providing a smooth user experience, page transitions allow for animations that help users understand that the page has changed. They also enable the anchoring of different stages of a narrative by using the browser's history state, allowing navigation to a specific section like an anchor. The router can then be used as a state manager.

But before diving deeper into how we can deal with routes to provide some route animation scenarios, let's focus on how a router actually works.

The basics

What is a router

The router definition is pretty simple: A router is a function that executes a selected callback based on the result of parsing a URL.

The process step by step could be summarized as follows:

  • Listen to URL changes (this happens when the browser history changes, for example when the user clicks on a link)
  • Match the URL with the available list of routes
  • Execute a specific callback linked to the matching route (such as updating a view)

The module below demonstrates, step by step, what happens when a new path is requested from the router. It iterates through the registered routes and finds the matching one. When the matching route is found (or not), it executes the associated callback. Try it yourself by clicking on "Random path" or by entering a path yourself in the URL bar.

Random path
Random path
https://web.io
  • /foo"Hello Foo"
  • /bar"Hello Bar"
  • /user/:id"Hello User {id}"
  • /:rest*"404 - {rest} doesn't exist"
Hello Foo

Build your own router

Before talking about transition management, we need to create a router whose role is to resolve a URL path and execute a callback associated with that path. Two dependencies are needed to avoid reinventing the wheel: path-to-regexp to match dynamic routes and history to listen to URL changes. Of course, these two dependencies could be developed from scratch (which I actually did on low-router to get the smallest size lib as possible), but that's beyond the scope of this article.

Let's build a simple router. Here is a basic implementation:

class Router {
  constructor(routes) {
    this.routes = routes
  }
  resolveRoute(pathname) {
    const route = this.getRouteFromPathname(pathname)
    if (route) return route.action()
    else console.warn(`No route found`)
  }
  getRouteFromPathname(pathname) {
    return this.routes.find((route) => route.path === pathname)
  }
}

This router implementation is currently not able to actually parse a route containing dynamic parameters like /user/:id. So we will use a URL parser path-to-regexp to allow this type of dynamic match.

import { match } from "path-to-regexp"

class Router {
  constructor(routes) {
    this.routes = routes
  }
  resolveRoute(pathname) {
    const route = this.getRouteFromPathname(pathname)
    if (route) return route.action(route.params)
    else console.warn("No route found")
  }
  getRouteFromPathname(pathname) {
    for (let route of this.routes) {
      const matcher = match(route.path)(pathname)
      if (matcher) {
        return {
          ...route,
          params: matcher.params
        }
      }
    }
  }
}

Now we have our "routes matching class", we can use it to resolve a route based on the current URL. The resolveRoute method will return the response of the action associated with the matching route. Here is how you can use it:

import { createBrowserHistory } from "history"

const routes = [
  {
    path: "/",
    action: () => "Hello home"
  },
  {
    path: "/about",
    action: () => "Hello about"
  },
  {
    path: "/user/:id",
    action: (params) => `Hello user ${params.id}`
  }
]

const router = new Router(routes)

const browserHistory = createBrowserHistory()

browserHistory.listen(({ location }) => {
  const response = router.resolveRoute(location.pathname)
  if (response) {
    // ✅ We have a matching route!
    // What can we do with the response?
  }
})

Now we have a ready-to-use basic Router! That's all a Router is (crazy, right?). The action that returns a response can be anything, like a function that updates the DOM, a React component, or just a console.log. It makes our mechanism low-level & flexible. All the question now is about, how deal with this new response to perform a transition?

But before that, try it yourself in the sandbox below. You will notice that each action() function associated with the routes creates a div containing a string. Each time a route is resolved, this div is appended to a container abruptly. There is no transition effect, just a simple DOM update, but the whole page is not reloaded. Our router works as expected.

This implementation is quite naive because it doesn't handle essential topics like base URLs, query parameters, hashes, or more advanced features such as sub-routing.

Also, note that the browserHistory.listen() function is called outside the Router class. This is important if we want to use the router on the server side as well in the future, where browser history is not available.

So we have the basics, let's start to talk about how we can implement a transition management now.

Transitions management

Now that we have a functional router, we can focus on the topic of transitions. We want to implement a route transition management system to be able to handle transitions whenever a new route is requested. In the previous sandbox, when the route changes, the DOM view is replaced without any transition effect. (And now we enter the nightmare of existing routing systems).

Before detailing how it is possible to develop and structure this technical subject, here are some examples of what I have needed as a so-called "creative" developer. In any case, these are the kinds of things motion designers have put me through.

Some scenarios

Sequential transition

A common case we need, is to wait for the previous route to finish its exit animation before starting the entry animation of the new route. This could be translated by this module below and this simple imperative code:

previous
page play-out
current
page play-in
...
...
await previous.playOut()
await current.playIn()
// transition complete

Crossed transition

Another common scenario is when the previous and current routes need to play their animations at the same time. You generally need to be careful about the DOM view position to achieve a superimposed effect. The associated module and imperative code:

previous
page play-out
current
page play-in
...
...
previous.playOut()
await current.playIn()
// transition complete

Real world transition

But transitions are never that simple, because in the real world, many states need to be managed during the transition cycle. This is partly what makes the subject complex.

For example, we may need to close the menu if it is open, play out the previous page, display a wall while loading data for the new route, show a loader, kill a WebGL scene, remount the new WebGL scene, etc.

menu
close menu
previous
page play-out
overlay
wall play-in
loader
fetch data
overlay
wall play-out
current
page play-in
kill webgl view
mount webgl view
...
...
...
...
...
...

The imperative preview would look like this:

// leave
if (menuIsOpen) await closeMenu()
await previousRoute.playOut()
wall.playIn()
webglView.dispose()

// load
await fetchData()

// show
webglView.mount()
wall.playOut()
await currentRoute.playIn()

// Transition complete"

Mix transition

And we could have even more complex needs. Assuming that a transition scenario depends on both the route you are coming from and the route you are going to. We would also need to be able to condition the order of execution or call different instructions depending on whether you go from A to B or from B to A.

# A -> B
A out
THEN B in

# B -> A
B out
DURING A in

These scenarios are complicated to handle with the built-in routers of the frameworks I have tested. In reality, it's not really a "router problem" (not a route matching issue), but rather a "route exposure problem." What can we do when a route changes? What are my options to intercept and manipulate them as I wish, depending on where I'm coming from and where I'm going?

Implementation

A middleware solution

Since it's unacceptable to tell designers "it's impossible" (as far as possible), I ended up developing my own solutions—starting with a basic router, of course, but then implementing a transition management function that acts as middleware every time a route changes. This function receives the previous and current (new) routes and orchestrates the transition between them in a imperative way, exactly as in the examples above.

To meet these needs, here are the key points of the router architecture I require, first, that each route action is associated to a Route class with its own playIn and playOut methods.

Don't forget that this methodology is possible because we designed our low-level router ourselves. The action method can return any type of object, including a class instance like this one.

class Home {
  name = "Home"
  root = this.render()

  // assuming we use gsap for animations
  playIn(from: string): Promise<any> {
    return gsap.fromTo(this.root, { opacity: 0 }, { opacity: 1 })
  }

  playOut(to: string): Promise<any> {
    return gsap.to(this.root, { opacity: 0 })
  }

  render(): HTMLElement {
    const dom = document.createElement("div")
    dom.textContent = this.name
    return dom
  }

  dispose(): void {
    this.root.remove()
  }
}

class About {
  // ... same as Home
  name = "About"
}

We work here in vanilla JS, without frameworks such as React or Vue, that's why each page class like Home or About has its own render method that returns a DOM element. But you can adapt this to your own framework, for example, by returning a React component or a Vue component. (For example, check my low-router-preact implementation).

In a second step, we have our middleware function that allows me to orchestrate when and how these functions should be executed when a route changes (pseudo code):

const routes = [
  { path: "/", action: () => new Home() },
  { path: "/about", action: () => new About() }
]

// ...

const store: { prev: Route; current: Route } = {
  prev: null,
  current: null
}

// Here is the middleware function that will orchestrate the transition
const middleware = async (prev, current): Promise<void> => {
  if (prev) {
    await prev.playOut(current?.name)
    prev.dispose()
  }
  if (current) {
    content.appendChild(current.root)
    await current.playIn(prev?.name)
  }
  console.log(`Transition complete`)
}

browserHistory.listen(({ location }) => {
  const response = router.resolveRoute()
  if (response) {
    // swap the previous and current routes
    store.prev = store.current
    store.current = response
    // ✅ call the middleware, here is where the magic happens!
    middleware(store.prev.action(), store.current.action())
  }
})

// ...

Et voilà, we have the basic of a router with transition management. Test the sandbox below by yourself. 👇

A special transition is implemented in the sandbox, if we come from "Home" and go to "User". This is a dummy example, just to show that we have control over transition contexts. This is exactly the "mix transition" case we saw above!

With this architecture, I have been able to handle the most complex cases submitted to me so far.

  • I can define a specific play-in and play-out method for each route.
  • I can decide at any moment the lifecycle of my incoming and outgoing routes.
  • I can implement any extra logic I want, such as loading data, displaying a wall, or showing a loader between transitions.
  • It's a comprehensive and imperative way to orchestrate the logic, making it easy for anyone to understand.

Note that this last point is really important, perhaps the "main feature" of all this. The middleware function allows anyone to understand what happens in a single chain of function calls. This is the power of a promise-based approach, which allows actions to be chained in a readable way, like a scenario.

Built-in routers comparison

Like many front-end developers, I have used various routers in the past, such as vue-router. It includes a built-in transition system by exposing the vue-router Navigation Guards API, which provides access to each route's lifecycle. Transition logic can be defined globally or per route using Per-route Guards. Finally, the way the previous and current routes are orchestrated is determined by a mode property (out-in, fade), which can be difficult to adapt depending on the context. The architecture seems less intuitive to me; I found it quite challenging to use for complex scenarios like the ones described above because it requires dispatching the transition logic across many different places.

A quick note on React routers, such as react-router and Next.js page/app routers. Neither exposes a transition API, which makes managing transitions very limited. It is possible to use third-party libraries or create custom solutions, but this remains complicated for complex scenarios too. I did manage to achieve a result (very hacky) with Next.js pages-router a few years ago by catching routes before they were mounted, but this no longer seems possible with the app-router.

Conclusion

I can't finish without mentioning Zouloux, who has contributed a lot to all this reflection when we worked together, and the basic architecture for transition management was inspired by his work on Solidify router (no longer available).

Remember that the router is essentially a state manager for views management that we want to transition between. Breaking down this subject led me to write low-router, whose API corresponds to what I described here. It is lightweight (1.9kB), dependency-free, and allows for managing routes as needed.

That said, page transitions should have a specific purpose, such as improving the user experience or enriching the story by creating emotion. Most of the time, websites don't need transitions, and it's important to avoid overusing them. I definitely don't want extra movement on Wikipedia or documentation websites.

I hope this article has helped clarify how to build custom routers and manage creative transitions. If you learned something new or enjoyed reading, consider supporting my work ☕️ or feel free to connect with me on Bluesky or LinkedIn 👋

...
...
...
...
...
...
Next article.

Advent of Code 2024, some notes

About

With over 12 years of experience in design-driven front-end development, I specialize in developing design systems and interactive experiences, from Lyon, France.

Previously lead front-end developer at cher-ami.tv, I now work as a freelancer, collaborating with engineering teams and studios to balance the big picture with the finer details, ensuring that projects align perfectly with client needs and personality.

I have recently been serving agencies and clients such as Cher Ami, Rezo Zero, Immersive Garden, Netflix, Warner music, Amazon Prime, Sandro, Opéra de Paris and many more.

Stack

  • typescript
  • vanilla JS
  • node JS
  • PHP
  • webgl
  • preact
  • nuxt
  • vite
  • esbuild
  • ogl
  • use-gesture
  • preact signals
  • gsap
  • interpol
  • debug
  • low-router
  • docker
  • github action
  • gitlab CI

I remain open for collaborations and projects, feel free to contact me for any request.