motiondivision / motion

A modern animation library for React and JavaScript
https://motion.dev
MIT License
24.53k stars 822 forks source link

[BUG] Exit animation with Next.js #1375

Closed MatteoGauthier closed 10 months ago

MatteoGauthier commented 2 years ago

1. Read the FAQs 👇

2. Describe the bug

I tried to integrate framer motion to next.js, I have components that appear on every page and when the road changes there is an animation.

3. IMPORTANT: Provide a CodeSandbox reproduction of the bug

https://codesandbox.io/s/github/MatteoGauthier/vertical-gallery/tree/52627524a54ee628bbde2f360c77b6d75c41593e

5. Expected behavior

Exit animation on route change

6. Video or screenshots

https://user-images.githubusercontent.com/32040951/144331637-d4ef82e2-2092-4b97-b156-1658e850763d.mov

Thanks

MarcGuiselin commented 2 years ago

In _app.tsx, you need to wrap your page component with an AnimatePresence like so:

<AnimatePresence exitBeforeEnter>
  <Component {...pageProps} key={router.pathname} />
</AnimatePresence>

See: https://wallis.dev/blog/nextjs-page-transitions-with-framer-motion

This used to work well with older versions of next.js, but doesn't work anymore. I can't figure out why.

teauxfu commented 2 years ago

I struggled to get this working as well. However, I was able to get it to work after correctly with Next v12.0.7 specifying a key for the component as suggested by @MarcGuiselin.

export default function App({ Component, pageProps, router }) {
  return (
    <AnimatePresence exitBeforeEnter>
      <Component {...pageProps} key={router.pathname} />
    </AnimatePresence>
  );
}

https://www.framer.com/docs/animate-presence/##animating-custom-components

I'm relived that all my nested staggerChildren transitions in children seem to still be working! 👍🏻

marcospassos commented 2 years ago

Same issue here =/

MagicMikeChen commented 2 years ago

Thanks a lot, it fixed the issue. When I wrap at the top level and include other HOC it won't work, need to wrapper the Component exact the parent level like this.

`

`

marcospassos commented 2 years ago

It doesn't work for me on Next's latest version

john-rock commented 2 years ago

@teauxfu solution resolved this for me on 12.0.8.

<Header />
  <AnimatePresence
        exitBeforeEnter
        initial={false}
        onExitComplete={() => window.scrollTo(0, 0)}
       >
          <Component {...pageProps} key={router.pathname} />
  </AnimatePresence>
<Footer />
marcospassos commented 2 years ago

Ok, it's an issue with React 18 (concurrent mode), see https://github.com/framer/motion/issues/1421

mahdisoultana commented 2 years ago

you can refer to this example from official documentation of Nextjs

https://github.com/vercel/next.js/blob/canary/examples/with-framer-motion/pages/_app.js

lately, i don't know why animation presence don't work like expected in the initial animation?

aluku7-wq commented 2 years ago

the exit animation is not working in dynamic routes

beamercola commented 2 years ago

@aluku7-wq Working for me, but be sure to pass the key as router.asPath, otherwise it's "/articles/[slug]"

<Component {...pageProps} key={router.asPath} />
cristobalbahe commented 2 years ago

Hello! I have the same problem but it only happens in some transitions. I have (an example) the following routes: /projects.js /journal.js /[....slug].js ---> /studio, /legal...

I have my app.js as the comment above, wrapped in LazyMotion with domAnimation and AnimatePresence:

 <LazyMotion features={domAnimation}>
   <AnimatePresence
        exitBeforeEnter
        onExitComplete={() => {
              console.log("EXIT COMPLETE", router.asPath);
          }}
    >
             <Component {...pageProps} key={router.asPath} />
    </AnimatePresence>
</LazyMotion>

When I navigate from /projects.js to /journal.js onExitComplete runs, but when I try to navigate from any of those two pages to one of [...slug.js] onExitComplete does not run.

Still, when I navigate between pages from [...slug.js] (from /studio to /legal) the transition works, so I am quite confused as to why this is happening.

Since my app is pretty complex by now (localized routes, caches, styled components) I don't know how I could make a sandbox to give an example, but maybe someone has any idea of what is happening.

Thank you all!

cristobalbahe commented 2 years ago

Okay, I found the cause. I had my _app.js main component wrapped in appWithTranslation HOC from next-i18next and this broke the transitions.

Do any of you guys have any idea how to fix this?

f4z3k4s commented 2 years ago

@cristobalbahe did you find any solution for that? I am experiencing the same.

cristobalbahe commented 2 years ago

@cristobalbahe did you find any solution for that? I am experiencing the same.

Hey @f4z3k4s, I managed to fixed it by changing from next-i18next to next-intl. I guess since next-intl uses a regular component () instead of a HOC the context of framer doesn't get lost (I am not sure is because of this though). The thing is it works now :)

f4z3k4s commented 2 years ago

@cristobalbahe Thanks for the answer. Since then, I've bypassed the issue by implementing a routing animation myself without AnimatePresence with the help of router events. Then, we know it's probably an issue with next-18next based on your solution.

arkaydeus commented 2 years ago

Done some extensive testing all options here. It's not isolated to next-i18next.

I DO not get the issue if I have a [id].tsx I ALWAYS get the issue if I have [...slug].tsx

Something about the spread operator in the page name makes it happen.

0xGar commented 1 year ago

The solution from @teauxfu and @MarcGuiselin works flawlessly for me. Using React 18.1.0 & Next 12.1.6.

Thanks for the knowledge

iamfrisbee commented 1 year ago

Having the same problem in nextjs 13.0.5 and react 18.0.2 with framer-motion 7.6.12; none of the previous solutions have been helpful. onExitComplete never triggers at all

MattWIP commented 1 year ago

@iamfrisbee W/ Next 13.0.5 + React 18.2.0 + framer-motion 7.6.17 I was able to get page entrance & exit animations to work w/ this snippet:

    <AnimatePresence
      mode="wait"
      initial={false}
      onExitComplete={() => window.scrollTo(0, 0)}
    >
      <Component {...pageProps} key={router.pathname} />
    </AnimatePresence>
tiendnm commented 1 year ago

@MattWIP

hi, have you tried with next13 app directory? I do the same but still not working

iamfrisbee commented 1 year ago

@iamfrisbee W/ Next 13.0.5 + React 18.2.0 + framer-motion 7.6.17 I was able to get page entrance & exit animations to work w/ this snippet:

    <AnimatePresence
      mode="wait"
      initial={false}
      onExitComplete={() => window.scrollTo(0, 0)}
    >
      <Component {...pageProps} key={router.pathname} />
    </AnimatePresence>

So I had to upgrade a few packages to get that, but no, it doesn't work. I verify this by putting a console.log in onExitComplete and it never runs.

chipcullen commented 1 year ago

I'm experiencing the same thing as @iamfrisbee - I tried many different ways yesterday, and none worked.

FWIW, commenters on this YouTube video also are running into the same thing.

sebszocinski commented 1 year ago

Yeah we've tried everything here and still nothing. We're on Next 13.1.1, React 18.2.0 and Framer Motion 8.0.2. Fingers crossed this gets fixed soon!

joshdegouveia commented 1 year ago

Running into this issue as well

joshdegouveia commented 1 year ago

In case this isn't resolved soon and anyone else find this thread.. I found a hacky workaround for route animations thats compatible with the nextjs app directory feature.

const router = useRouter()
const controls = useAnimationControls()

const onRoute = useCallback((href: string) => async () => {
  await router.prefetch(href)
  await controls.start('exit')
  await router.push(href)
  await controls.set('hidden')
  await controls.start('enter')
}, [router, controls])
<motion.main
  animate={controls}
  variants={{
    hidden: { opacity: .3, x: -200, y: 0 },
    enter: { opacity: 1, x: 0, y: 0 },
    exit: { opacity: .3, x: 0, y: -100 },
  }}
  transition={{ type: 'keyframes', duration: 2 }}>
   {children}
</motion.main>
<button onClick={onRoute('page-1')}>
  Link
</button>

You can manually trigger enter/exit animations via the useAnimationControls hook. Instead of using <Link /> I call the onRoute method and route once the exit animation has completed.

NOTE: this code only works in layout.tsx components

emilienbidet commented 1 year ago

Next: 13.1.1 framer-motion: 8.4.3 using the app directory

Same issue. I will wait without exit animation until this is resolve.

omerfe commented 1 year ago

So i've faced the same issue using next^12.1.0 & react^18.2.0 & react-dom^18.2.0 & framer-motion^7.6.4 Turns out when you wrap your Component in _app.js like this:

<AnimatePresence
       onExitComplete={() =>
          console.log("exit completed, pathname:", router.asPath)
       }
       mode="wait"
       initial={false}
>
        <Component {...pageProps} key={router.asPath}  />
</AnimatePresence> 

It works fine on static pages that catches multiple routes like [[...slug.js]], but struggles to remove the elements from the dom if its a regular [...slug.js].

Couldn't find a proper way to handle this but instead of wrapping the Component in _app.js, I wrapped the layouts in [[...slug.js]] files fixed the issue.

Done some extensive testing all options here. It's not isolated to next-i18next.

I DO not get the issue if I have a [id].tsx I ALWAYS get the issue if I have [...slug].tsx

Something about the spread operator in the page name makes it happen.

davidkhierl commented 1 year ago

have the same issue, it also stated on the beta docs from nextjs to use templates.

I can see on react-devtools the key is updating but the exit animation is not working

eddsaura commented 1 year ago

Mine is not working either, but on normal components, not even pages.

khuezy commented 1 year ago

Check out my comment on https://github.com/framer/motion/issues/1850#issuecomment-1445239322, it enables exit animations on appDir

hasolu commented 1 year ago

This work for me on _app.tsx

next 13.1.6 framer-motion 8.5.5

import { usePathname } from "next/navigation";

const pathname = usePathname();
    <AnimatePresence
          mode="wait"
          onExitComplete={doSomething}
        >
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.6, ease: "easeInOut" }}
            key={pathname}
          >
            <Component {...pageProps} />
          </motion.div>
        </AnimatePresence>
davidkhierl commented 1 year ago

@hasolu everything works fine using the pages except using the new app dir and the layout template

hasolu commented 1 year ago

@davidkhierl now i realize this code is not working in production, i'm dealing with this.

haaarshsingh commented 1 year ago

I'm having issues with this too. React 18.2, Framer Motion 10.0.1 and Next.js 13.2.3 Have y'all found any workarounds yet? I'm tried @joshdegouveia's solution but I kept getting Error: NextRouter was not mounted.

UPDATE: if you get that error, change next/router to next/navigation. I got the workaround working, but I still can't use it with my navbar which is in the actual layout component just yet. Also, seems like I'm getting the opposite error here. I can't make my intro animations work :sweat_smile:

davidkhierl commented 1 year ago

The problem is the <AnimatePresence/> should not experience any rerender, its either you try to create a wrapper before the layout or wait nextjs to fix the template file, this is where you will put this component, but upon checking from react dev tools the template component somewhat create another wrapper to it children hence it cannot access the key from the motion componet which is required to let the exit animation to fire

psoaresbj commented 1 year ago

I am still fighting with this!!!

Not even in non-dynamic pages and not using the new app dir structure - I can't get the exit animation to trigger nor the onExitComplete callback to run! 😢

Dep versions:

  "next": "^13.2.4",
  "react": "^18.2.0",
  "framer-motion": "^10.9.2"

Anyone cracked this already?

ShueiYang commented 1 year ago

@psoaresbj The Exit Animation work in the page dir, did you give a try ?

haaarshsingh commented 1 year ago

@ShueiYang not super helpful. We all know the exit animations work with the page directory—the entire point of this issue is that they don't work with Next.js 13's app directory.

ShueiYang commented 1 year ago

@harshhhdev my bad if i misunderstood when he said it's not working even when he don't use the new app dir, I am also fighting with the exit animation in the app dir that's why i am here.

psoaresbj commented 1 year ago

Yes, in page dir, everything works fine!

graceyudhaaa commented 1 year ago

appdir is officially stable, hope this solved soon

seantai commented 1 year ago

no idea what needs to happen to fix this, but Ill be super happy when app directory works with exit animations

zackdotcomputer commented 1 year ago

I tracked down what I think is the issue with appdir - documented here https://github.com/vercel/next.js/issues/49596

TLDR; Next is sticking an unkeyed component between the layout (which persists between pages) and template (which does not). Because this means your only choices are the parent of an unkeyed element or something that will be cleared away on page navigation, there's nowhere to put the AnimatePresence object to capture a page-exit for animation.

Cuteappi commented 1 year ago

Whaat? Im having a hard time trying to figure out how to get it working in the page directory. I use "framer-motion": "^10.12.9", "next": "13.4.1", "react": "18.2.0"

index.jsx import Starterpage from '@/components/Starterpage/Starterpage.jsx' import Textani from '@/components/Starterpage/textani.jsx' import Dots from '@/components/Starterpage/Dots.jsx' import { motion } from 'framer-motion' import { useRouter } from "next/router";

import Head from 'next/head'

export default function Welcome(props) { const router = useRouter()

return (
    <>
        <motion.div
            key={router.route}
            initial="initialState"
            animate="animateState"
            exit="exitState"
            transition={{
                duration: 0.75,
            }}
            variants={{
                initialState: {
                    opacity: 0
                },
                animateState: {
                    opacity: 1
                },
                exitState: {
                    scale : 0
                }
            }}
            style={{width: '100%', minHeight: '100vh'}}
        >
            <Head>
                <title>Welcome</title>
                <meta name="description" content="Welcome To my portfolio page" />
                <meta name="viewport" content="width=device-width, initial-scale=1" />
                <link rel="icon" href="/favicon.ico" />
            </Head>

            <Starterpage>
                <Textani word={props.randomWord} />
                <Dots numdots={3} />
            </Starterpage>
        </motion.div>
    </>
)

}

export async function getStaticProps(context) { const wordList = ['Hello', 'Konnichiwa', 'Bonjour', 'Guten', 'Ciao', 'Ola', 'Marhaba', 'Nǐn hǎo']

const randomWord = wordList[Math.floor(Math.random() * wordList.length)]

return {
    props: { randomWord }
};

}

__app.js import '@/styles/globals.scss' import { AnimatePresence } from 'framer-motion'

export default function App({ Component, pageProps }) { return ( <AnimatePresence mode='wait' initial={false} onExitComplete={()=>{console.log('exit completed')}}> <Component {...pageProps} /> ) }

/home.jsx import Head from 'next/head' import Homepage from '@/components/Homepage/Homepage' import { motion } from 'framer-motion' import { useRouter } from "next/router";

export default function Home() { const router = useRouter()

return (
    <>
        <motion.div
            key={router.route}
            initial="initialState"
            animate="animateState"
            exit="exitState"
            transition={{
                duration: 0.75,
            }}
            variants={{
                initialState: {
                opacity: 0,
                clipPath: "polygon(0 0, 100% 0, 100% 100%, 0% 100%)",
                },
                animateState: {
                opacity: 1,
                clipPath: "polygon(0 0, 100% 0, 100% 100%, 0% 100%)",
                },
                exitState: {
                opacity: 0,
                clipPath: "polygon(50% 0, 50% 0, 50% 100%, 50% 100%)",
                },
            }}
            style={{width: '100%', minHeight: '100vh'}}
        >
            <Head>
                <title>Home</title>
                <meta name="description" content="Here you will some info about me" />
                <meta name="viewport" content="width=device-width, initial-scale=1" />
                <link rel="icon" href="/favicon.ico" />
            </Head>

            <Homepage />
        </motion.div>
    </>
)

}

rnnyrk commented 1 year ago

I'm trying to make it work with the app directory as well, but can't seem to get an exit animation working properly.. Instead the elements are just removed without an animation. I thought the if statement within the AnimatePresence should trigger the exit animation? This component is in the root layout.tsx

// PageWrapper.tsx
'use client';

import { useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';
import { AnimatePresence, motion } from 'framer-motion';

export const PageWrapper = ({ children }: PageWrapperProps) => {
  const pathname = usePathname();
  const [isTransitioning, setIsTransitioning] = useState(false);

  useEffect(() => {
    setIsTransitioning(true);
    setTimeout(() => {
      setIsTransitioning(false);
    }, 2000);
  }, [pathname]);

  const variants = (index: number) => ({
    hidden: {
      scaleY: 0,
      transition: {
        duration: 0.2,
        delay: index * 0.1,
      },
    },
    visible: {
      scaleY: 1,
      transition: {
        duration: 0.2,
        delay: index * 0.1,
      },
    },
  });

  return (
    <AnimatePresence
      initial={false}
      onExitComplete={() => console.log('EXIT COMPLETE')}
    >
      {isTransitioning ? (
        <>
          <motion.div
            key={`${pathname}_animation_1`}
            variants={variants(1)}
            initial="hidden"
            animate="visible"
            exit="hidden"
            className="w-[50vw] h-[100vh] bg-rnny-primary fixed bottom-0 left-0 z-50"
          />
          <motion.div
            key={`${pathname}_animation_2`}
            variants={variants(2)}
            initial="hidden"
            animate="visible"
            exit="hidden"
            className="w-[50vw] h-[100vh] bg-rnny-primary-tint fixed bottom-0 left-[50vw] z-50"
          />
        </>
      ) : null}
      {children}
    </AnimatePresence>
  );
};

What am I missing or not understanding?

EDIT: Nvm, found it; Had to wrap my motion components in a <Fragment key="loading_animator"> with a key, so "Framer Motion can track the AnimatePresence" direct child presence.

dnlaviv commented 1 year ago

@rnnyrk can you provide a code example of how you solved it?

rnnyrk commented 1 year ago

@dnlaviv For full code see my repo

// layout.tsx
import './global.css';
import type * as i from 'types';

import { PageWrapper } from 'modules/layouts/PageWrapper';

const Layout = ({ children }: Props) => {
  return (
    <html lang="en">
      <head />
      <body>
        <main>
          <PageWrapper>{children}</PageWrapper>
        </main>
      </body>
    </html>
  );
};

type Props = i.NextPageProps<{
  children: React.ReactNode;
}>;

export default Layout;
// PageWrapper.tsx
'use client';

import { Fragment, useEffect, useRef, useState } from 'react';
import { usePathname } from 'next/navigation';
import { AnimatePresence, motion, Variants } from 'framer-motion';

import { cn } from 'utils';

export const PageWrapper = ({ children }: PageWrapperProps) => {
  const pathname = usePathname();
  const [isTransitioning, setIsTransitioning] = useState(true);
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }

    setIsTransitioning(true);
    timeoutRef.current = setTimeout(() => {
      setIsTransitioning(false);
      window.scrollTo(0, 0);
    }, 1100);

    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, [pathname]);

  const variants: (index: number) => Variants = (index: number) => ({
    hidden: {
      scaleY: 0,
      transition: {
        duration: 0.4,
        delay: index * 0.05,
        ease: 'easeInOut',
      },
    },
    visible: {
      scaleY: 1,
      transition: {
        duration: 0.4,
        delay: index * 0.05,
        ease: 'easeInOut',
      },
    },
  });

  return (
    <AnimatePresence>
      {isTransitioning ? (
        <Fragment key="route_transition_animator">
          {[...Array(4).keys()].map((index) => {
            return (
              <motion.div
                key={`${pathname}_animation_${index}`}
                variants={variants(index)}
                initial="hidden"
                animate="visible"
                exit="hidden"
              />
            );
          })}
        </Fragment>
      ) : null}
      {isTransitioning ? <div className="min-w-screen min-h-screen" /> : children}
    </AnimatePresence>
  );
};

type PageWrapperProps = {
  children: React.ReactNode;
};
gimwachan-git commented 1 year ago

The exit animation seems to be determined by whether the element is unmounted or not. And AnimatePresence is too complex and it seems that the timing of the exit animation can't be customized, so I wrote a component to control when the motion animations are executed. Although it's not perfect, I'm going to use it in a production environment. It is an example of how it's used in CodeSandbox.

colonder commented 1 year ago

@rnnyrk Please, don't mind my question as I'm not a web dev per se - how does your solution relate to SSR? I guess when you wrap all your components in a component with use client at the top, SSR is basically impossible, and this in turn impacts SEO.

rnnyrk commented 1 year ago

@colonder This solution indeed doesnt support SSR. But there shouldn't be any hydration errors, because it's just an visual element on top of the current page. Next page loaded in the background. Didn't find a better solution yet. Open for one tho