Page Transitions with Gatsby + Framer Motion

Jan 28, 2021

How to use Framer Motion for enter and exit animations in a Gatsby site

This post assumes a basic knowledge of React and Gatsby. Some knowledge of Framer Motion is also helpful, but not necessary.

Today, my team and I at Apollo GraphQL launched our brand new learning platform, Odyssey! We really want Odyssey to provide a place where developers can have a great learning experience through an approachable curriculum, interactive activities, and a polished UI. The smoother the user interface and experience, the fewer distractions and the more people can focus on what they came to the site to do: learn.


Adding smooth transition animations can really help a site feel more like a native app. It's something we're so used to seeing already on our phones that when "normal" websites don't have those kinds of transitions, it can feel a bit jarring when navigating from page to page. You know what I mean? With a native app, images or text will fade away or slide in. But on a web app it just suddenly changes. There's no easing into the next page. The content just suddenly disappears and is just as suddenly replaced with new content. Not exactly nice on the eyes.

In Odyssey, we used Framer Motion, a production-ready React animation library. When you're doing a lesson in Odyssey, you can navigate to the previous or next lesson, or select a completely different lesson altogether. To give a visual sense of progression, of moving forwards or backwards in the course, we used Framer Motion's AnimatePresence component to enable components to animate out as they're removed from the React tree. Normally when you just use the motion component, you can animate a component as it's being mounted, but not as it's being unmounted. Using AnimatePresence allows us to give a motion component an exit animation. Fun fact: this is the same methodology I used in this site for the page transitions 😉


TL;DR here's a CodeSandbox demo for implementing page transitions in a Gatsby site using Framer Motion's AnimatePresence component to enable enter and exit animations.

Framer Motion

The basics of Framer Motion involve creating a motion component and passing some values to set the styles we want an element to start at and animate to. So if I want to have an element look like it's fading in when it's rendered, I can play with the opacity:



In the above example, you'll notice that the top text fades in as it's being mounted, while the "unanimated" text renders without any sort of transition. The fade in is smoother, but both texts exhibit the same behavior when they are unmounted. Even though our motion.div component fades in, it doesn't fade out. To enable this, we can wrap AnimatePresence around our conditionally rendered element. Now we can use the exit prop on our motion.div to have the element fade out as it's being unmounted.


We can take this same idea to apply page transition animations as a component is rendered and unmounted during a route change.

Typically with single page application built in React, you can have your routes all in a main App file and use a routing library like Reach Router to render certain components based on the url path.

<Home path="/" />
<About path="/about" />

But you don't exactly do that with Gatsby. I mean, you could. But you really don't have to since Gatsby takes care of a lot of routing for you under the hood. You create all of your separate pages and Gatsby makes them for you at build time and the routes automagically work! It's really nice, but made me stop and think for a bit as to how to approach our animation goal. All of the pages need to be wrapped in this AnimatePresence component for the exit prop to be enabled. If you're "manually" controlling your routing, it makes it really simple to wrap everything in things like Context Providers or in a Layout component or with AnimatePresence.

<Home path="/" />
<About path="/about" />

Routing like this allows AnimatePresence to stay rendered the entire time. Then all we have to do is use a motion component inside of those Home and About components and to use those animate and exit props. You can see a more fleshed out (albeit unstyled) example on CodeSandbox.

Page transitions with Gatsby

But how do we do this with Gatsby? Even if we create a Layout component that wraps its children with AnimatePresence and pull that Layout into all of our page files, they're all still separate instances of Layout. It might not look like it with how fast the pages load, but as each page unmounts, the ENTIRE thing unmounts, including that Layout component with AnimatePresence. So we would never see any exit animations getting fired. It's ok for the children to mount and unmount just as long as AnimatePresence itself never does. Well, shoot. How do we do that with Gatsby? We need a way to ensure that AnimatePresence is never getting unmounted during route changes!


Luckily for us, Gatsby exposes some "browser" APIs for us that we can use. In the root of your project, you can create a gatsby-browser.js file (the file naming is important) and get access to all of these cool things! One of them is a hook called wrapPageElement. This allows us to essentially wrap a component around all of our pages, without the wrapper getting unmounted on route changes. Just what we want! We can use wrapPageElement to wrap all of our pages with a persistent AnimatePresence.

wrapPageElement or wrapRootElement?

wrapRootElement is used for things like Providers that give our app access to data and things like that. wrapPageElement is used for more UI-related things that we want to wrap around all of our pages, such as a persistent layout or in our case here, AnimatePresence

import React from 'react';
import {AnimatePresence} from 'framer-motion';
export const wrapPageElement = ({element}) => (
<AnimatePresence exitBeforeEnter>{element}</AnimatePresence>

The syntax looks like your typical React functional component. We also have to make sure to export wrapPageElement so Gatsby can use it. And then we pass an exitBeforeEnter prop to AnimatePresence so that Framer Motion will run any exit animation on an unmounting component before running the enter animation for a component that's about to mount on the page. This is the behavior we want, otherwise they'll happen at the same time and there could be a funky overlap between the animations and it might look like only one of the animations is firing.

Then all that's left is to use a motion component in our pages and define our initial, animate, and exit props like we did earlier!

Pretty cool, huh? Let's break down the code a bit. We have our wrapPageElement in gatsby-browser.js to wrap our pages in a persistent AnimatePresence component.

Since this is a Gatsby site, we've just created some files in the pages directory. Gatsby will use the names of these files to create the pages and handle the routing for you. In the CodeSandbox example, we have two pages: index.js and page-2.js. Within each of those files, we use Gatsby's Link component to route us to the specified path. These paths match the names of our files in the pages directory, so we'll see the JSX for those corresponding files rendered accordingly.

Both our index.js and page-2.js pages are utilizing a Layout component. If we take a look at that file, we'll find our motion component! The header and footer are not in a motion component, so they won't have any sort of transition animation on our route changes. motion.main WILL have our animation applied, so all of the children inside of motion.main will be affected. I've copied it here for better visibility, with some things excluded for our purposes in this post.

1import React from "react"
2import { motion } from "framer-motion"
4import Header from "./header"
6const Layout = ({ children }) => {
7 return (
8 <>
9 <Header />
10 <div
11 style={{
12 margin: `0 auto`,
13 maxWidth: 960,
14 padding: `0 1.0875rem 1.45rem`
15 }}
16 >
17 <motion.main
18 initial={{ opacity: 0, x: -200 }}
19 animate={{ opacity: 1, x: 0 }}
20 exit={{ opacity: 0, x: 200 }}
21 transition={{
22 type: "spring",
23 mass: 0.35,
24 stiffness: 75,
25 duration: 0.3
26 }}
27 >
28 {children}
29 </motion.main>
30 <footer
31 style={{
32 marginTop: `2rem`
33 }}
34 >
35 © {new Date().getFullYear()}, Built with
36 {` `}
37 <a href="">Gatsby</a>
38 </footer>
39 </div>
40 </>
41 )

On a route change, the current page's motion.main component (inside of Layout) will fire its exit animation. Then, the page we're navigating to will have an animation fired by THAT page's motion.main component (inside of Layout) since it's a separate instance of Layout. And we get those nice exit animations thanks to our persistent AnimatePresence that's being used in wrapPageElement.


Another nice thing Gatsby does for us, that you may or may not have ever noticed, is preserving your scroll position in the browser's location history. In other words, if you've scrolled down on a page, then clicked on a link to go to a different page, and then hit the back button on your browser, a Gatsby site will not only take you to the previous page, but it will take you to the last position you had scrolled to on that page. Pretty cool, huh?

If we're firing some exit animations to transition our pages, it might be a jumpy experience to have the page simultaneously running an animation and scrolling to a new position if you hit the back button. To help with this, we can tap into Gatsby's shouldUpdateScroll function in gatsby-browser.

Gatsby passes in some props to shouldUpdateScroll: pathname, routerProps, prevRouterProps, and getSavedScrollPosition. Here, we'll just be using routerProps and getSavedScrollPosition. With routerProps, we can access the location object for the current router state. getSavedScrollPosition is a function that takes in a location and returns the x and y coordinates for the last scroll position of that location. We can use these two props to update the scroll position as expected, but just delaying it long enough to let our animations finish. To do this, we use window.setTimeout to force the window to wait before scrolling to our desired position.

There was a breaking change to getSavedScrollPosition that was patched in v2.28.1 of Gatsby. If you are using an older version, there is no guarantee that this solution will work.

For reference, see this GitHub issue and merged fix

1export const shouldUpdateScroll = ({
2 routerProps: { location },
3 getSavedScrollPosition
4}) => {
5 // transition duration from `layout.js` * 1000 to get time in ms
6 // * 2 for exit + enter animation
7 const TRANSITION_DELAY = 0.3 * 1000 * 2
9 // if it's a "normal" route
10 if (location.action === "PUSH") {
11 window.setTimeout(() => window.scrollTo(0, 0), TRANSITION_DELAY)
12 }
14 // if we used the browser's forwards or back button
15 else {
16 const savedPosition = getSavedScrollPosition(location) || [0, 0]
18 window.setTimeout(() => window.scrollTo(...savedPosition), TRANSITION_DELAY)
19 }
21 return false