Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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?

like image 632
Dagem Avatar asked Oct 22 '25 05:10

Dagem


1 Answers

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>
  )
}
like image 84
Apezdr Avatar answered Oct 24 '25 09:10

Apezdr