Framer Motion is an incredibly well-made animation library that makes it super-easy to add smooth animations to your React-based site.
Pair Framer Motion with Next.js and you can add stunning page transitions that aren't possible with a traditional server-first website!
In this article, I will show you how to add page transitions to Next.js and some hidden gotchas to watch out for.
Basic Setup
After setting up a basic Next.js app and installing framer-motion, follow these steps:
1. Add AnimatePresence to _app.tsx
pages/_app.tsx
import { AnimatePresence } from 'framer-motion'import { AppProps, useRouter } from 'next/app'export default function App({ Component, pageProps }: AppProps) {const router = useRouter()const pageKey = router.asPathreturn (<AnimatePresence initial={false} mode="popLayout"><Component key={pageKey} {...pageProps} /></AnimatePresence>)}
It's a simple piece of code but there are some important things going on:
1. AnimatePresence is rendered with initial={false}
If you leave this out, framer-motion will animate the page on first load, which will cause visual jitter in a server-rendered app.
2. AnimatePresence is rendered with mode="popLayout"
This does two things:
- When transitioning Page A to Page B, exit and enter animations will happen at the same time
- When a page animates out, it receives
position: absolute;
while it's animating out to make room for the next page
3. Component has a unique key prop
<Component>
is the component of the page (from the pages/
directory) that is currently being displayed.
We're using the complete path of the current route to give it a unique key
property.
Without this key
property, framer-motion can't distinguish two separate pages that use the same component, for example if
/post-1
and /post-2
were both rendered by pages/[slug].tsx
.
2. Add a PageTransition Component
The <PageTransition>
component will define our in- and out-animations.
The following component slides pages in from the right and slides them out to the left:
components/PageTransition.tsx
import React, { forwardRef, useMemo } from 'react'import { motion, HTMLMotionProps } from 'framer-motion'type PageTransitionProps = HTMLMotionProps<'div'>type PageTransitionRef = React.ForwardedRef<HTMLDivElement>function PageTransition({ children, ...rest }: PageTransitionProps, ref: PageTransitionRef) {const onTheRight = { x: '100%' }const inTheCenter = { x: 0 }const onTheLeft = { x: '-100%' }const transition = { duration: 0.6, ease: 'easeInOut' }return (<motion.divref={ref}initial={onTheRight}animate={inTheCenter}exit={onTheLeft}transition={transition}{...rest}>{children}</motion.div>)}export default forwardRef(PageTransition)
One important thing is that we're forwarding the ref to <motion.div>
. This is required because we're using <AnimatePresence mode="popLayout">
.
3. Add the PageTransition Component to your Pages
This is the final puzzle piece. We include the <PageTransition>
component at the top
of each page that we would like to equip with our fancy animated page transitions.
pages/index.tsx
type IndexPageProps = {}type IndexPageRef = React.ForwardedRef<HTMLDivElement>function IndexPage(props: IndexPageProps, ref: IndexPageRef) {return (<PageTransition ref={ref}><div className="IndexPage">{/* ... */}</div></PageTransition>)}export default forwardRef(IndexPage)
Note that, again, we're forwarding the ref to the <PageTransition>
component, completing a chain of ref-passing from <AnimatePresence>
all the way down to the top-level <motion.div>
.
AnimatePresence -> PageComponent -> PageTransition -> motion.div
Is this really how easy it is to add page transitions to a Next.js app?
Kind of! But there are some gotchas:
Gotcha 1: Scrolling Complications
If your pages are long enough so that the user has to scroll, you will notice that the page gets scrolled to the top before the page transition happens.
Solution 1: Give Each Pages Its Own Scroll Container
One easy (slightly hacky) way to prevent this, is to give your PageTransition
component max-height: 100%; overflow-y: auto;
.
This way, each page basically gets its own scroll container.
It's hacky, because usually the Next.js handles scrolling of the page and e.g. sets the scroll position correctly when you use the back-button in your browser.
Solution 2 doesn't do that any better is slightly more complex, but it's good to know your options:
Solution 2: Use mode="wait" and Handle Scrolling Ourselves
If we wait for Page A to exit before we mount Page B and animate it in, we don't have to deal with two pages with separate scrolling positions at the same time.
We can then tell Next.js not to scroll to top automatically when the route has transitioned (this is when the page transition starts), but instead handle scrolling ourselves as soon as Page A has left the viewport.
So this solution has two pieces to it:
1. Adjust AnimatePresence Component
Change the mode
prop to "wait"
and scroll to the top of the page when a component exits (e.g. Page A gets unmounted).
TSX
const onExitComplete = () => {window.scrollTo({ top: 0 })}// ...return (<AnimatePresence onExitComplete={onExitComplete} mode="wait" initial={false}>{/* ... */}</AnimatePresence>)
2. Disable Next.js' Scroll Restoration
There isn't currently no way to disable Next.js' scroll restoration globally, so make sure to edit every <Link>
component to have scroll={false}
(see docs)
and any imperative route transitions using something like router.push({ scroll: false })
(see docs).
Notice how we have a blank screen in-between exit and enter transitions.
This is because we now wait for Page A to exit completely before animating Page B in.
Other Approaches
I've solved the scroll jumping problem in the past by giving the exiting page position: fixed;
and a calculated offset
to account for the current scrolling position.
It worked pretty well but was pretty complicated to implement and I would rather use Solution 1 these days, simply giving each page its own scroll container.
More Tradeoffs
The more you think about page transitions, the more you see how little the web is prepared for it at the moment, although there are some promising things in the works with the View Transitions API.
Something else to think about, is how animations should behave when hitting the back-button.
This is likely dependent on your animation but actually finding a way to disable animations while using the back-button would be nice!
Gotcha 2: Don't Trust useRouter!
Another interesting thing happens when building page transitions with Next.js.
To illustrate it, let me make a simple page that shows the current path:
TSX
function PathPage(props: PathPageProps, ref: PathPageRef) {const router = useRouter()return (<PageTransition style={style[num]} ref={ref}>{router.asPath}</PageTransition>)}
See what happens when the pages transition:
It's a little subtle but the text of {router.asPath}
updates for the page doing the exit-transition.
This can cause very subtle bugs and it's important to be aware of this.
A workaround can be to remember the router's state when the page first mounted:
TSX
function PathPage(props: PathPageProps, ref: PathPageRef) {const router = useRouter()// Storing asPath in a state variable will make sure it doesn't// change after initialization:const asPath = useState(router.asPath)return (<PageTransition style={style[num]} ref={ref}>{asPath}</PageTransition>)}
Conclusion
Page transitions are fancy and can really wow your customers. It's really easy to add them using framer-motion and Next.js.
There are some problems and trade-offs to be aware of, so use page transitions wisely and keep an eye out for a proper solution like the View Transitions API.