Search code examples
reactjsnext.jsframer-motionreact-server-components

How to make a page transition with Framer Motion and Next.js 14?


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;

Solution

  • 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:

    1. AnimatePresence from framer motion (This helps us use exiting and entering animations)
    2. A key on the motion element to trigger a re-render (This triggers the animation when the page changes)
    3. Add 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;
    

    Here is a working example.