I'm trying to make a page transition with Next.js v14 but no success.
No error is shown, the animation just does't work. I guess it's because the layout.tsx
is rendered on the server. But how can I fix it?
My layout.tsx
code (root level):
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Sidebar from "@/components/Sidebar";
import Breadcrumbs from "@/components/Breadcrumbs";
import Topbar from "@/components/Topbar";
import PageTransitionEffect from "@/components/PageTransitionEffect";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={`${inter.className} flex flex-col min-h-screen`}>
<Topbar />
<div className="bg-gray-800 text-gray-200 flex flex-grow overflow-x-hidden px-0">
<Sidebar />
<div className="flex-col mx-auto flex w-full py-6 px-4 sm:px-6 md:px-14">
<Breadcrumbs />
<PageTransitionEffect>{children}</PageTransitionEffect>
</div>
</div>
</body>
</html>
);
}
My PageTransitionEffect
component code:
"use client";
import { motion } from "framer-motion";
const PageTransitionEffect = ({ children }: { children: React.ReactNode }) => {
const variants = {
hidden: { opacity: 0, x: -200, y: 0 },
enter: { opacity: 1, x: 0, y: 0 },
exit: { opacity: 0, x: 0, y: -100 },
};
return (
<motion.div
initial="hidden"
animate="enter"
exit="exit"
variants={variants}
transition={{ type: "linear" }}
>
{children}
</motion.div>
);
};
export default PageTransitionEffect;
Edit #1 (warning message):
Warning: Detected multiple renderers concurrently rendering the same context provider. This is currently unsupported.
at FrozenRouter (webpack-internal:///(ssr)/./src/components/PageTransitionEffect/index.tsx:21:70)
at div
at MotionComponent (webpack-internal:///(ssr)/./node_modules/framer-motion/dist/es/motion/index.mjs:49:65)
at PopChildMeasure (webpack-internal:///(ssr)/./node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:13:1)
at PopChild (webpack-internal:///(ssr)/./node_modules/framer-motion/dist/es/components/AnimatePresence/PopChild.mjs:33:21)
at PresenceChild (webpack-internal:///(ssr)/./node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:15:26)
at AnimatePresence (webpack-internal:///(ssr)/./node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:72:28)
at PageTransitionEffect (webpack-internal:///(ssr)/./src/components/PageTransitionEffect/index.tsx:49:33)
at div
at div
at body
at html
at RootLayout (webpack-internal:///(ssr)/./src/app/layout.tsx:21:23)
at Lazy
at RedirectErrorBoundary (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/redirect-boundary.js:71:9)
at RedirectBoundary (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/redirect-boundary.js:79:11)
at ReactDevOverlay (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/react-dev-overlay/internal/ReactDevOverlay.js:66:9)
at HotReload (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/react-dev-overlay/hot-reloader-client.js:298:11)
at Router (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/app-router.js:154:11)
at ErrorBoundaryHandler (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/error-boundary.js:99:9)
at ErrorBoundary (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/error-boundary.js:128:11)
at AppRouter (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/app-router.js:426:13)
at Lazy
at Lazy
at C:\Projects\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:35:374733
at C:\Projects\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:35:374733
at ServerInsertedHTMLProvider (C:\Projects\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:38:23140)
The component code:
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { usePathname } from "next/navigation";
import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { useContext, useRef } from "react";
function FrozenRouter(props: { children: React.ReactNode }) {
const context = useContext(LayoutRouterContext);
const frozen = useRef(context).current;
return (
<LayoutRouterContext.Provider value={frozen}>
{props.children}
</LayoutRouterContext.Provider>
);
}
const variants = {
hidden: { opacity: 0, x: -30, y: 0 },
enter: { opacity: 1, x: 0, y: 0 },
exit: { opacity: 0, x: -30, y: 0 },
};
const PageTransitionEffect = ({ children }: { children: React.ReactNode }) => {
// The `key` is tied to the url using the `usePathname` hook.
const key = usePathname();
return (
<AnimatePresence mode="popLayout">
<motion.div
key={key}
initial="hidden"
animate="enter"
exit="exit"
variants={variants}
transition={{ type: "linear", duration: 0.2, delay: 0.1 }}
>
<FrozenRouter>{children}</FrozenRouter>
</motion.div>
</AnimatePresence>
);
};
export default PageTransitionEffect;
Edit:
As Adriano mentioned in his answer, you can easily implement an enter animation for your routes with the template.tsx
file convention in Next JS. This does not, however, perform an exit animation for route changes. The solution I outline below does. I've updated my example to show both solutions.
There is currently a bug caused by the app router and shared layouts that prevents this from working correctly. However, there is a workaround. Besides the workaround, there are 3 other things we need:
AnimatePresence
from framer motion (This helps us use exiting and entering animations)key
on the motion element to trigger a re-render (This triggers the animation when the page changes)mode='popLayout'
to AnimatePresence
(This removes the old page from the tree immediately. You can play around with the mode value, different modes are better for some animations.)Example:
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { usePathname } from "next/navigation";
import { LayoutRouterContext } from "next/dist/shared/lib/app- router-context.shared-runtime";
import { useContext, useRef } from "react";
function FrozenRouter(props: { children: React.ReactNode }) {
const context = useContext(LayoutRouterContext ?? {});
const frozen = useRef(context).current;
return (
<LayoutRouterContext.Provider value={frozen}>
{props.children}
</LayoutRouterContext.Provider>
);
}
const variants = {
hidden: { opacity: 0, x: -200, y: 100 },
enter: { opacity: 1, x: 0, y: 0 },
exit: { opacity: 0, x: 0, y: -100 },
};
const PageTransitionEffect = ({ children }: { children: React.ReactNode }) => {
// The `key` is tied to the url using the `usePathname` hook.
const key = usePathname();
return (
<AnimatePresence mode="popLayout">
<motion.div
key={key}
initial="hidden"
animate="enter"
exit="exit"
variants={variants}
transition={{ type: "linear" }}
className="overflow-hidden"
>
<FrozenRouter>{children}</FrozenRouter>
</motion.div>
</AnimatePresence>
);
};
export default PageTransitionEffect;