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?
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:
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
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>
)
}