Search code examples
next.jsframer-motion

Exit animation on NextJS 14 Framer Motion


I'm attempting to add page transition animations to a Next js 14 app using Framer Motion. I have PageTransitionLayout.tsx which looks like this

'use client';

import { motion, AnimatePresence } from "framer-motion";
import { ReactNode, FC } from "react";

import { usePathname } from "next/navigation";

interface ILayoutProps {
  children: ReactNode;
}

const PageTransitionLayout: FC<ILayoutProps> = ({ children }) => {
  const pathname = usePathname()

  return (
      <AnimatePresence mode={'wait'}>
        <motion.div 
         key={`${pathname}1`}
         className="absolute top-0 left-0 w-full h-screen bg-green-400 origin-middle"
         initial={{ scaleY: 1 }}
         animate={{ scaleY: 0.5 }}
         exit={{ scaleY: 0}}
         transition={{ duration: 1, ease: [0.22, 1, 0.36, 1] }}
       />
       {children}
      </AnimatePresence>
  );
}

export default PageTransitionLayout;

and use it in app/contact/pages.tsx like so

"use client";

import PageTransitionLayout from "../ui/PageTransitionLayout";

export default function Contact() {
    return (
    <PageTransitionLayout>
      <div className="grid h-[90vh] place-items-center bg-orange-400">
        <h1 className="font-bold text-4xl">Contact</h1>
      </div>
    </PageTransitionLayout>
    )
}

but the exit animation of the motion div doesn't fire when navigating to a different page. What may be the issue?


Solution

  • You'll have to wrap your page in a HOC to slow down the app router in NextJS 13/14. The solution presented below will introduce more issues to work through though such as:

    • Suspense Boundaries from using loading.js file in app directory will fail to properly load in the children in effect making it so that you can't use loading.js
      • The solution will cause the page to re-render more but will effectively provide the exit transitions you're looking for.
    • Page will be 'frozen' after it's loaded in, this puts strain on the app router in its current state.

    layout.js

    /src/app/layout.js || /app/layout.js

    import { Inter } from 'next/font/google'
    import './globals.css'
    import PageAnimatePresence from '@components/HOC/PageAnimatePresence'
    
    const inter = Inter({ subsets: ['latin'] })
    
    export const metadata = {
      title: 'Your Website Title',
      description: 'Website metadata description.',
    }
    
    export default function RootLayout({ children }) {
      return (
        <html lang="en">
          <body
            className={inter.className + ` bg-blue-500 transition-colors duration-1000`}
            id="page-container"
          >
            <PageAnimatePresence>{children}</PageAnimatePresence>
          </body>
        </html>
      )
    }
    

    PageAnimatePresence.js

    /src/app/components/HOC/PageAnimatePresence.js || /app/components/HOC/PageAnimatePresence.js

    'use client'
    
    import { usePathname } from 'next/navigation'
    import { AnimatePresence, motion } from 'framer-motion'
    import FrozenRoute from './FrozenRoute'
    
    const PageAnimatePresence = ({ children }) => {
      const pathname = usePathname()
    
      return (
        <AnimatePresence mode="wait">
          {/**
           * We use `motion.div` as the first child of `<AnimatePresence />` Component so we can specify page animations at the page level.
           * The `motion.div` Component gets re-evaluated when the `key` prop updates, triggering the animation's lifecycles.
           * During this re-evaluation, the `<FrozenRoute />` Component also gets updated with the new route components.
           */}
          <motion.div key={pathname}>
            <FrozenRoute>{children}</FrozenRoute>
          </motion.div>
        </AnimatePresence>
      )
    }
    
    export default PageAnimatePresence
    

    FrozenRoute.js

    /src/app/components/HOC/FrozenRoute.js || /app/components/HOC/FrozenRoute.js

    'use client'
    
    import { useContext, useRef } from 'react'
    import { LayoutRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'
    
    const FrozenRoute = ({ children }) => {
      const context = useContext(LayoutRouterContext)
      const frozen = useRef(context).current
    
      return <LayoutRouterContext.Provider value={frozen}>{children}</LayoutRouterContext.Provider>
    }
    
    export default FrozenRoute
    

    template.js

    /src/app/template.js || /app/template.js

    'use client'
    import { motion } from 'framer-motion'
    
    const variants = {
      hidden: { opacity: 0, x: 0, y: 0 },
      enter: { opacity: 1, x: 0, y: 0 },
    }
    
    export default function Template({ children }) {
      return (
        <motion.main
          variants={variants}
          initial="hidden"
          exit="hidden"
          animate="enter"
          transition={{ type: 'linear', duration: 0.25 }}
          key="LandingPage"
        >
          {children}
        </motion.main>
      )
    }