Add a basic page transition for Next.js

Page transitions are cool and can add that extra layer of polish to your web app.

In this post, I'll explain how to add a very basic page transition to your Next.js app. We are not going to do anything super fancy. The page transition that we're going to use is based on spring animations that generally produces more believable animations since they behave similarly to objects in the physical world.

Next.js setup

I am going to assume you already have a Next.js app. If not, go ahead and set one up now.

The easiest is to run the following command from your terminal:

yarn create next-app

You are going to need a custom Next.js App Component. If you don't have one already, inside you pages directory add a new file called _app.tsx (or _app.js if you're using JavaScript).

Add the following React component as a starting point:

import { AppProps } from 'next/app'

function CustomApp({ Component, pageProps }: AppProps): JSX.Element {
  return <Component {...pageProps} />
}

export default CustomApp

This custom App does nothing more than what Next.js does out of the box. For each page in your app, it will be called with Component and pageProps props, which is the specific page component and the initial props object passed to it via one of Next.js' page data fetching methods, like getStaticProps or getServerSideProps.

The Next.js Custom App component is the perfect place to add any components and other code that will be used for every page of your app. Things like ThemeProviders and global style resets. We will leave those out to keep this example focused on the page transition.

react-flip-toolkit

We will make use of the react-flip-toolkit library to apply the spring-based animation between our pages. This library is based on Paul Lewis's FLIP animations idea. It is worth reading Paul's post if you haven't already. The FLIP concept can be applied to so many different animation problems.

We are mostly interested in the Flipper and Flipped components that the library exports for us. Let's take a look at those.

Flipper

The Flipper component wraps around all the child components that will form part of the animation. You can think of it as a React Context Provider. This component takes one mandatory prop, flipKey, which it uses to determine when the children should be animated. When the key changes, an animation triggers.

Because we want to trigger our transition animation every time the route changes, it naturally makes sense that we use the page's route as the flipKey. Lucky for us, the Next.js custom app component also receives a router prop. Let's add this to our CustomApp:

import { AppProps } from 'next/app'
import { Flipper, Flipped } from 'react-flip-toolkit'

function CustomApp({
  Component,
  pageProps,
  router, // Added this prop
}: AppProps): JSX.Element {
  return (
    // Wrapping our page `Component` with the `Flipper`
    <Flipper flipKey={router.asPath}>
      <Component {...pageProps} />
    </Flipper>
  )
}

export default CustomApp

You might have noticed that we used the asPath attribute of the router object and not pathname. That is because if we are using dynamic routes, the pathname will be the same for pages matching the same dynamic route. The asPath, on the other hand, is the actual path, including the query params.

Flipped

At this stage, our page transition is still not working, and that is because we haven't told the Flipper which child components should be animated. That is where the Flipped component comes in.

The Flipped component wraps each animatable component inside the Flipper parent component. To identify each individual animatable component, a flipId prop is required for the Flipped component. Each component that has the same flipId will be animated from its previous state to the new state when the Flipper is updated. This is very useful if you want to animate a list of items that get reordered.

The Flipped component does not necessarily have to be a direct child of Flipper. It can be deeply nested.

In our case, we don't have a list of items to animate; we want to animate the content of one page into the next. We can, therefore, use the same flipId for all our pages. Let's be extremely creative and call this flipId page:

import { AppProps } from 'next/app'
import { Flipper, Flipped } from 'react-flip-toolkit'

function CustomApp({ Component, pageProps, router }: AppProps): JSX.Element {
  return (
    <Flipper flipKey={router.asPath}>
      // Wrapping our page `Component` with a `Flipped` component
      <Flipped flipId="page">
        <Component {...pageProps} />
      </Flipped>
    </Flipper>
  )
}

export default CustomApp

Depending on how your app's page components use additional props, the page transition might or might not work already. The Flipped component will pass a couple of flippedProps down to its direct child component, i.e. the <Component {...pageProps} /> component. These props are used to set the necessary opacity, translate and scale values on the underlying DOM elements. For this reason, these flippedProps need to be spread into a native DOM component ultimately, like a <div> or <span> and not into a custom React component, like <CustomComponent>.

The easiest to do this, is just to wrap the <Component {...pageProps} /> component in our CustomApp with another <div>:

import { AppProps } from 'next/app'
import { Flipper, Flipped } from 'react-flip-toolkit'

function CustomApp({ Component, pageProps, router }: AppProps): JSX.Element {
  return (
    <Flipper flipKey={router.asPath}>
      <Flipped flipId="page">
        // Wrapping our page `Component` with a native `div`
        <div>
          <Component {...pageProps} />
        </div>
      </Flipped>
    </Flipper>
  )
}

export default CustomApp

With this change, the Flipped component is always able to apply the necessary animation props to the underlying div. The only outstanding part that might stop your page transition from working is if your app is not using client-side routing.

I am not going to say page transitions are impossible if you're using server-side routing, i.e. page refreshes, because people always do amazing things with the web that I thought was impossible. I will say that it would probably not be worth the effort.

Luckily for us, Next.js makes client-side routing very easy and will default to that if we use their Link component correctly and our pages do not use getServerSideProps as the data fetching method. As the name suggests, that will always happen server-side.

Client-side Routing

The recommended declarative way to route within your Next.js app is to use the <Link> component available through the next/link package: import Link from 'next/link'

A link to your "About" page could look like this:

<Link href="/about">
  <a>About</a>
</Link>

This will work fine if you have an about.tsx or about.js file in your pages directory and that component doesn't rely on server-side data fetching for each page render.

If you have dynamic page routes, it is slightly different. Let's say you have the following pages:

pages /
  articles /
    [article].tsx

If you want to create a Link to an individual article page, it would have to look something like this:

<Link href="/articles/[article]" as="/articles/the-article-slug">
  <a>Read more</a>
</Link>

If you forget to add the as and href prop, the page will always perform a refresh and load server-side. Make sure to use this correctly throughout your app.

Sometimes you want to route programmatically within a callback or event handler. For that, you can use the Next.js router directly. If you're using a React function component, the easiest is to use the useRouter custom hook: import { useRouter } from 'next/router'.

For our previous page examples, you would be able to route like this:

router.push('/about')

Or for the dynamic article route:

router.push('/articles/[article]', '/articles/the-article-slug', {
  shallow: true,
})

Note, the shallow: true option. That is necessary to force client-side-only routing.

With these things in place, your page transitions should be working. If for some reason it isn't, check here for ideas to troubleshoot.


Check out this example repo if you want to see it in action.