Solving Framer Motion Page Transitions in Next.js App Router
At the time of writing, Next.js does not have built-in support for page transitions. This is a feature that many developers have been asking for, and there are a few libraries out there that can help you achieve this. One of the most popular libraries for adding page transitions to Next.js is Framer Motion.
However, getting Framer Motion to work with Next.js App Router can be a bit tricky, especially if you want to add exit animations to your pages. An issue arises when the Next.js app router causes components to unmount and remount abruptly, disrupting animations. Here's how we can tackle this problem.
The versions used at the time of writing are as follows:
Next.js: 14.2.3
Framer Motion: 11.2.10
Shoutout to @lochieaxon for initially discovering this hack.
Setting up Framer Motion
The first thing you need to do is install Framer Motion in your Next.js project. You can do this by running the following command in your terminal:
bun add framer-motion
(Yes - I think bun is the best. If you are using npm, you can runnpm install framer-motion
instead.)
Creating theLayoutTransition
component
Next, let's create a new component called LayoutTransition
that will handle the page transitions for us:
// components/layout-transition.tsx
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { usePathname } from "next/navigation";
interface LayoutTransitionProps {
children: React.ReactNode;
className?: React.ComponentProps<typeof motion.div>["className"];
style?: React.ComponentProps<typeof motion.div>["style"];
initial: React.ComponentProps<typeof motion.div>["initial"];
animate: React.ComponentProps<typeof motion.div>["animate"];
exit: React.ComponentProps<typeof motion.div>["exit"];
}
export function LayoutTransition({
children,
className,
style,
initial,
animate,
exit,
}: LayoutTransitionProps) {
const path = usePathname();
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
className={className}
style={style}
key={path}
initial={initial}
animate={animate}
exit={exit}
>
{children}
</motion.div>
</AnimatePresence>
);
}
In this component, we are using the AnimatePresence
component from Framer Motion to handle the page transitions. We are also using the motion.div
component to create a container for our page content. The key
prop is set to the current path, which will trigger the exit animation when the pathname changes.
Using theLayoutTransition
component
Now that we have our LayoutTransition
component, we can use it in our layout components to add page transitions to our Next.js app. Here is an example of how you can use the LayoutTransition
component in your layout:
// app/layout.tsx
import { LayoutTransition } from "@/components/layout-transition";
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<LayoutTransition
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{children}
</LayoutTransition>
</body>
</html>
)
}
We are getting there! However, this doesn't really work as intended. Even though we do in fact see an exit animation, it manages to load the next page first, and then animate the exit transition followed by the enter transition. This is not what we want.
The Core Issue with Next.js App Router and Framer Motion
The primary challenge with using Framer Motion in a Next.js app lies in the router's handling of component lifecycles. The Next.js app router updates the context frequently during navigation, causing components to unmount and remount abruptly. This disrupts the smooth flow of animations provided by Framer Motion's AnimatePresence
and motion
components. The sudden unmounting interrupts ongoing animations, leading to jarring transitions and a suboptimal user experience.
Investigating the Problem
Router Context Changes: The router context updates with each navigation action, leading to new instances of components.
Component Lifecycle Interruption: These frequent updates force components to unmount and remount, which is detrimental to animations that rely on the stability of the component tree.
Proposed Solution
To mitigate this, we devise a two-part solution: FrozenRouter
and LayoutTransition
.
Step 1: Custom Hook -usePreviousValue
We create a custom hook, usePreviousValue
, to retain the previous value of a variable. This hook utilizes a ref
to store the previous value, updating it during each render and resetting it when the component unmounts.
function usePreviousValue<T>(value: T): T | undefined {
const prevValue = useRef<T>();
useEffect(() => {
prevValue.current = value;
return () => {
prevValue.current = undefined;
};
});
return prevValue.current;
}
Step 2:FrozenRouter
Component
The FrozenRouter
component uses useContext
to access the current router context and usePreviousValue
to track the previous context and layout segment. It then compares the current and previous segments to decide if the context should change. This approach ensures the context only updates when necessary, preventing unnecessary unmounting and remounting of components.
function FrozenRouter(props: { children: React.ReactNode }) {
const context = useContext(LayoutRouterContext);
const prevContext = usePreviousValue(context) || null;
const segment = useSelectedLayoutSegment();
const prevSegment = usePreviousValue(segment);
const changed = segment !== prevSegment && segment !== undefined && prevSegment !== undefined;
return (
<LayoutRouterContext.Provider value={changed ? prevContext : context}>
{props.children}
</LayoutRouterContext.Provider>
);
}
Wait - hold your horses! Here we are using LayoutRouterContext
and useSelectedLayoutSegment
- what are these?
What isLayoutRouterContext
?
LayoutRouterContext
is a context object provided by Next.js, which contains the current layout state of the router. This context helps in managing and propagating layout state changes throughout the application.
How We UseLayoutRouterContext
Importing the Context: We import
LayoutRouterContext
from Next.js and use it within our components.import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
Using it in
FrozenRouter
: In theFrozenRouter
component, we access the current context and track the previous context using theusePreviousValue
hook. This helps us determine whether the context has changed due to navigation.Context Access: We use
useContext(LayoutRouterContext)
to get the current router context.Previous Context Tracking: The
usePreviousValue
hook helps in storing the previous context value.Context Decision: We determine if the context should be updated based on whether the segment has changed. If it has, we provide the previous context to preserve the current state, thus not unmounting the currently shown route.
This approach prevents the disruption of Framer Motion animations during route changes, ensuring a seamless user experience. By controlling the context updates, we mitigate the issues caused by the Next.js app router's handling of component lifecycles.
What isuseSelectedLayoutSegment
?
useSelectedLayoutSegment
is a Next.js navigation hook that retrieves the current layout segment from the router context. This hook helps in determining the layout segment for the current route, allowing us to compare it with the previous segment to decide if the context should be updated.
import { useSelectedLayoutSegment } from "next/navigation";
Why do we useuseSelectedLayoutSegment
?
We used usePathname
to get the current path, and set the respective key in the LayoutTransition
component. However, what if we wanted to use this animation component in other layout files?
By using useSelectedLayoutSegment
, we can get the current layout segment from the router context, and by comparing the current state and previous state, we can decide whether to perform the animation or not.
Say you have a layout on root level and another layout in your /posts
directory. When we use useSelectedLayoutSegment
, we can wrap the LayoutTransition
component around the children in the posts layout, which will then be the only place where the animation will be performed when the user navigates within the /posts
route segment.
Step 3:LayoutTransition
Component
LayoutTransition
wraps its children in Framer Motion's AnimatePresence
and motion.div
components for animations. By using FrozenRouter
, it ensures the context remains stable during animations, preventing the disruption of animations due to component unmounting and remounting.
export function LayoutTransition({
children,
className,
style,
initial,
animate,
exit,
}: LayoutTransitionProps) {
const segment = useSelectedLayoutSegment();
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
className={className}
style={style}
key={segment}
initial={initial}
animate={animate}
exit={exit}
>
<FrozenRouter>
{children}
</FrozenRouter>
</motion.div>
</AnimatePresence>
);
}
Notice how we are now using useSelectedLayoutSegment
instead of usePathname
to get the current layout segment, and setting it as the key for the motion.div
component.
Here is the complete code for the layout-transition.tsx
file:
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useSelectedLayoutSegment } from "next/navigation";
import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { useContext, useEffect, useRef } from "react";
function usePreviousValue<T>(value: T): T | undefined {
const prevValue = useRef<T>();
useEffect(() => {
prevValue.current = value;
return () => {
prevValue.current = undefined;
};
});
return prevValue.current;
}
function FrozenRouter(props: { children: React.ReactNode }) {
const context = useContext(LayoutRouterContext);
const prevContext = usePreviousValue(context) || null;
const segment = useSelectedLayoutSegment();
const prevSegment = usePreviousValue(segment);
const changed =
segment !== prevSegment &&
segment !== undefined &&
prevSegment !== undefined;
return (
<LayoutRouterContext.Provider value={changed ? prevContext : context}>
{props.children}
</LayoutRouterContext.Provider>
);
}
interface LayoutTransitionProps {
children: React.ReactNode;
className?: React.ComponentProps<typeof motion.div>["className"];
style?: React.ComponentProps<typeof motion.div>["style"];
initial: React.ComponentProps<typeof motion.div>["initial"];
animate: React.ComponentProps<typeof motion.div>["animate"];
exit: React.ComponentProps<typeof motion.div>["exit"];
}
export function LayoutTransition({
children,
className,
style,
initial,
animate,
exit,
}: LayoutTransitionProps) {
const segment = useSelectedLayoutSegment();
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
className={className}
style={style}
key={segment}
initial={initial}
animate={animate}
exit={exit}
>
<FrozenRouter>{children}</FrozenRouter>
</motion.div>
</AnimatePresence>
);
}
Conclusion
You should now have beautiful page transitions in your Next.js app using Framer Motion and app router.
By using FrozenRouter
to manage router context and prevent unmounts, we can maintain smooth transition experiences in Next.js applications with Framer Motion. This approach ensures a seamless user experience even during complex layout transitions.
For further details, visit the GitHub issue page
Subscribe to my newsletter
Read articles from Corfitz directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by