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.
Motivation
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 😉
Demo
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
:
AnimatePresence
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.
<Router><Home path="/" /><About path="/about" /></Router>
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
.
<AnimatePresence><Router><Home path="/" /><About path="/about" /></Router></AnimatePresence>
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!
wrapPageElement
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
orwrapRootElement
?
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"34import Header from "./header"56const Layout = ({ children }) => {7 return (8 <>9 <Header />10 <div11 style={{12 margin: `0 auto`,13 maxWidth: 960,14 padding: `0 1.0875rem 1.45rem`15 }}16 >17 <motion.main18 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.326 }}27 >28 {children}29 </motion.main>30 <footer31 style={{32 marginTop: `2rem`33 }}34 >35 © {new Date().getFullYear()}, Built with36 {` `}37 <a href="https://www.gatsbyjs.com">Gatsby</a>38 </footer>39 </div>40 </>41 )42}
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
.
Bonus
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 getSavedScrollPosition4}) => {5 // transition duration from `layout.js` * 1000 to get time in ms6 // * 2 for exit + enter animation7 const TRANSITION_DELAY = 0.3 * 1000 * 289 // if it's a "normal" route10 if (location.action === "PUSH") {11 window.setTimeout(() => window.scrollTo(0, 0), TRANSITION_DELAY)12 }1314 // if we used the browser's forwards or back button15 else {16 const savedPosition = getSavedScrollPosition(location) || [0, 0]1718 window.setTimeout(() => window.scrollTo(...savedPosition), TRANSITION_DELAY)19 }2021 return false22}