Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NEXT-1151] App router issue with Framer Motion shared layout animations #49279

Open
1 task done
maurocolella opened this issue May 5, 2023 · 114 comments
Open
1 task done
Labels
area: app App directory (appDir: true) bug Issue was opened via the bug report template. linear: next Confirmed issue that is tracked by the Next.js team.

Comments

@maurocolella
Copy link

maurocolella commented May 5, 2023

Verify canary release

  • I verified that the issue exists in the latest Next.js canary release

Provide environment information

Operating System:
      Platform: linux
      Arch: x64
      Version: #22 SMP Tue Jan 10 18:39:00 UTC 2023
    Binaries:
      Node: 16.17.0
      npm: 8.15.0
      Yarn: 1.22.19
      pnpm: 7.1.0
    Relevant packages:
      next: 13.4.1-canary.1
      eslint-config-next: 13.0.7
      react: 18.2.0
      react-dom: 18.2.0

Which area(s) of Next.js are affected? (leave empty if unsure)

App directory (appDir: true)

Link to the code that reproduces this issue

https://codesandbox.io/p/sandbox/stupefied-browser-tlwo8y?file=%2FREADME.md

To Reproduce

I provided a larger repro for context, as it is unclear which combination of factors leads to the specific bug, although a number of other people report the same issue.

Describe the Bug

Framer Motion supports a feature called shared layout animation that automatically transitions components whose styles have changed when the container (that contains them) re-renders.

This feature appears not to be working in multiple scenarios with Next.js 13 under the app folder.

In the provided example, this feature is applied to the blue navigation highlight.

The affected container in the code sandbox is:
https://codesandbox.io/p/sandbox/stupefied-browser-tlwo8y?file=%2Flib%2Fcomponents%2FNavigation.tsx

To produce the undesired behavior, I simply applied layoutId as specified in the relevant Framer Motion documentation to the motion elements expected to transition.

Framer Motion 5 removes the AnimateSharedLayout component.
Now, you can use the layoutId prop and components will animate from one to another without the need for the AnimateSharedLayout wrapper.

I believe I also tried more explicit variations. Others have reported similar or identical issues in the bug currently open with Framer Motion.

Expected Behavior

I expect the blue highlight to slide smoothly to its new position when the nav container re-renders.

Which browser are you using? (if relevant)

Version 113.0.5672.63 (Official Build) (64-bit)

How are you deploying your application? (if relevant)

Usually Vercel

NEXT-1151

@maurocolella maurocolella added the bug Issue was opened via the bug report template. label May 5, 2023
@github-actions github-actions bot added the area: app App directory (appDir: true) label May 5, 2023
@joshdavenport
Copy link

joshdavenport commented May 5, 2023

As an extra point for this issue, the docs specify that using templates is a way to achieve enter/exit animations (web archive link, see edit) with either CSS or an animation library (for which framer-motion would be one of the go-to choices for most I feel) but doesn't offer any indication as to how to achieve this.

With templates, even if you wrap children in layout (where templates is rendered, and even given a key) transitions just don't work because there's no way to make that template a framer motion element with animate props.

Edit: This wording is now removed, just linking the docs as they were at this moment in time so it doesn't look like I was saying the docs say something it doesn't: web archive link

@zackdotcomputer
Copy link
Contributor

I believe I've pinpointed the issue that is causing this problem. As part of the new app router structure, Next lays out a tree of components that includes the following loop (rendering a hypothetical /path/subpath request):

- Root Layout
  - OuterLayoutRouter
    - TemplateContext.Provider key="path"
      - Root Template
        - InnerLayoutRouter
          - Path Layout
            - OuterLayoutRouter
              - TemplateContext.Provider key="subpath"
                - Path Template (not subpath, as that is a page.tsx)
                  - InnerLayoutRouter
                    - OuterLayoutRouter
                      - TemplateContext.Provider key="__PAGE__"
                        - Page contents

Crucially, the app framework is inserting an OuterLayoutRouter component between each Layout and corresponding Template. This component is what is responsible for performing the content swap when the user navigates to a new path. The system seems to select the lowest OuterLayoutRouter that can be used (e.g. for the path navigation /sign-up/step1 to /sign-up/step2 the second OuterLayoutRouter, which represents the directory /sign-up and lives inside any layout.tsx for that directory, would be used. If the paths were /sign-up/step/1 to /sign-up/step/2, then the third OuterLayoutRouter would be used. And so on...).

Because the OuterLayoutRouter fully swaps its contents to the new path, and because those contents include the template.tsx, the template cannot provide an "on exit" effect (as it will have been pruned from the tree already). Because the OuterLayoutRouter itself does not have a key (because it is not swapped), the layout cannot include an AnimatePresence because that component requires its direct child have a key that indicates navigation. Finally, because the usePathname function only updates after the render swap has been performed, one cannot sneak a key into the stack. If one tries to hack in an exit-enter effect by making a layout that uses the pathname to indicate when its child has changed, then the contents will be swapped to the new page and then fade-out and fade-back-in.

The potential fixes I see for this issue are:

  1. Next's render stack could be changed so that the Layout and Template are rendered in an immediate parent/child relationship, which TBH is what the documentation says should happen (I'll open a separate bug for this)
  2. Next could expose an API or event that indicates when a navigation begins and what the destination path is, so that one could add a key to the layout when loading begins.

I don't think there is a way with the APIs currently exposed to solve this issue on Framer's side without at least some change from Next.

@jamesvclements
Copy link

Just adding a +1 for this to be looked at soon, there's a lot of conversation about this in the Next.js discord as well

@alainkaiser
Copy link
Contributor

Would also love if you guys could have a look at it soon. A good amount of information already present in the framer-motion thread:

framer/motion#1850

@timneutkens timneutkens added the linear: next Confirmed issue that is tracked by the Next.js team. label May 11, 2023
@timneutkens timneutkens changed the title App router issue with Framer Motion shared layout animations [NEXT-1151] App router issue with Framer Motion shared layout animations May 11, 2023
@timneutkens
Copy link
Member

timneutkens commented May 24, 2023

@seantai @jamesvclements @alainkaiser Please do not ping the thread with comments that do not add value. The issue is already synced into our tracker, there's hundreds of issues to be investigated and spamming issues demanding for it to be looked at is not the way to get us to look into it any faster, on the contrary, by posting these comments you're actively taking time away from us investigating / fixing issues. If all you want to do is "increase priority" you can use the 👍 on the initial post (not this post) to convey that you're running into it too.

Or you can focus your efforts on investigating / providing context on what might be causing the issue like the great comment @zackdotcomputer added.

@lmatteis
Copy link

lmatteis commented Jun 13, 2023

Wouldn't an easier approach simply be to let us choose where to put the key in the tree? Then we can have more fine grained control over when things are re-mounted.

As an example right now two different pages that return the same component in the same position will get remounted:

// app/foo/page.js
export default function Page() {
  return <Counter />;
}

// app/bar/page.js
export default function Page() {
  return <Counter />;
}

These two counter's state will be lost when soft-navigating between /foo and /bar.

By removing the key props from the tree that Next builds, we can decide ourselves if we want to remount things:

// app/foo/page.js
export default function Page() {
  const pathname = usePathname();
  return <Counter key={pathname} />; // <-- don't preserve the counter state on soft-navigations
}

This would of course allow us to properly adjust exit-animations, as well as other more fine-grained things we want to happen when soft-navigating.

@Piglow19
Copy link

Piglow19 commented Jul 9, 2023

Hello,
Any update ?

@harshv5094
Copy link

I have a question, I'm new to using nextjs framework. So It's my first time using an app router but I don't know how to use framer motion in app router.

@yawlad
Copy link

yawlad commented Jul 13, 2023

You can't correctly use framer motion for layout animations with app router for now

@Systemcluster
Copy link

You can't correctly use framer motion with app router for now

Apart from layout animations, Framer Motion works perfectly fine in client components.

@zackdotcomputer
Copy link
Contributor

Yeah to clarify, @harshv1741 - if you're looking to use Framer Motion to perform page transitions as the user navigates from page route to page route in app router, then that is what this issue is saying is broken. Because of how the NextJS team have structured their layouts feature, you can't do that right now.

If you're looking for how to use Framer Motion inside of page or specific component, then that is out of the scope of this thread to help you with - I'd suggest taking that over to Framer Motion's site and community.

@valenguerra
Copy link

Hi, is there any update? Or at least someone knows of another way to make an exit animation without using framer-motion?

@fweth
Copy link

fweth commented Jul 19, 2023

Hello, I just wanted to add that as far as I understand, Framer Motion as well as React Transition Group use React's cloneElement on children (or whatever you put in the ref). You can create a minimal page transition in a few lines of code for the old Next.js or Remix without any extra library, would be nice to have this support also for the App Router.

Here is how a simple exit-before-enter-animation looks like in vanilla Remix (and I'd expect it to work similarly with the App Router in the future):

export default function Layout() {
  const outlet = useOutlet();
  const [cloned, setCloned] = useState(outlet);
  const href = useHref();
  const mainRef = useRef();
  useEffect(
    function () {
      mainRef.current.style.opacity = 0;
      const timeout = window.setTimeout(function () {
        mainRef.current.style.opacity = 1;
        setCloned(cloneElement(outlet));
      }, 500);
      return function () {
        window.clearTimeout(timeout);
      };
    },
    [href],
  );
  return (
    <>
      <header>
        <nav>
          <Link to="/">Home</Link>
          <Link to="/news">News</Link>
          <Link to="/about">About</Link>
        </nav>
      </header>
      <main style={{ transition: "opacity 500ms" }} ref={mainRef}>
        {cloned}
      </main>
    </>
  );
}

@JasonA-work
Copy link

I agree with @fweth. Even in my app using the pages router, the only thing I'm using framer motion for is page transition animations. It'll be amazing if a solution and examples can be provided for simple page transition animation for both the pages and app router. A solution with vanilla css / js / react will help reduce a good amount of bundle size.

@ShahriarKh
Copy link
Contributor

For animating modals using parallel routes, since we use router.back() to close the modal (see nextgram example), we can set a timeout so we have enough time to render the exit animation before changing the route.

Inside the modal component:

'use client';

import css from './Modal.module.scss';
import { useCallback, useRef, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { AnimatePresence, motion } from 'framer-motion';

export default function Modal() {
  const router = useRouter();
  const pathname = usePathname(); // to use pathname as motion key

  const [show, setShow] = useState(true); // to handle mounting/unmounting

  const onDismiss = useCallback(() => {
    setShow(false);
    setTimeout(() => {
      router.back();
    }, 200); // 200ms, same as transition duration (0.2)
  }, [router]);

  return (
    <AnimatePresence>
      {show && (
        <motion.div
          key={pathname}
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          transition={{
            duration: 0.2,
            ease: 'easeInOut',
          }}
        >
          {/* your modal content */}
        </motion.div>
      )}
    </AnimatePresence>
  );
}

To see the complete example of creating modals with parallel routes (albeit without animations), check out Nextgram

@cutsoy
Copy link

cutsoy commented Aug 11, 2023

The workaround I'm using memorizes the LayoutRouterContext (using useRef) and passes its down to its children. This ensures that the old route doesn't get unmounted on navigation.

Full example below:

/// layout.tsx
function FrozenRouter(props: PropsWithChildren<{}>) {
    const context = useContext(LayoutRouterContext);
    const frozen = useRef(context).current;

    return (
        <LayoutRouterContext.Provider value={frozen}>
            {props.children}
        </LayoutRouterContext.Provider>
    );
}

export default function Layout(props: PropsWithChildren<{}>) {
    const pathname = usePathname();
    
    return <AnimatePresence>
        <motion.div
            key={pathname}
            initial={{ opacity: 0 }}
            animate={{ opacity: 0 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.4, type: "tween" }}
        >
            <FrozenRouter>{props.children}</FrozenRouter>
        </motion.div>
    </AnimatePresence>;
}

This is working pretty good so far. Fingers crossed it works for you too! 🤞

@eldevyas
Copy link

Hey, I appreciate your help. Can you add a Repo Link to the working code example or provide more information about the Context Provider Configuration?

@eldevyas
Copy link

eldevyas commented Aug 11, 2023

I just managed to piece it together. Here's how I implemented the solution:

import React, {useContext, useRef} from "react";
import { motion, AnimatePresence } from 'framer-motion';
import { PropsWithChildren, useRef } from 'react';
import { usePathname } from 'next/navigation'; // Import your pathname utility

import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context";

function FrozenRouter(props: PropsWithChildren<{}>) {
  const context = useContext(LayoutRouterContext);
  const frozen = useRef(context).current;

  return (
    <LayoutRouterContext.Provider value={frozen}>
      {props.children}
    </LayoutRouterContext.Provider>
  );
}

export default function Layout(props: PropsWithChildren<{}>) {
  const pathname = usePathname();

  return (
    <AnimatePresence>
      <motion.div
        key={pathname}
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
        transition={{ duration: 0.4, type: 'tween' }}
      >
        <FrozenRouter>{props.children}</FrozenRouter>
      </motion.div>
    </AnimatePresence>
  );
}

@cutsoy
Copy link

cutsoy commented Aug 11, 2023

Almost! The LayoutRouterContext is from next.js and seems to contain all of the routing state (hence why it needs to be frozen while animating the unmount of one of the routes). So you shouldn't define it yourself. Instead, just import it from next.

import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context";

I will try to share an example of how to use it with nextgram tomorrow.

@lmatteis
Copy link

lmatteis commented Aug 12, 2023

Almost! The LayoutRouterContext is from next.js and seems to contain all of the routing state (hence why it needs to be frozen while animating the unmount of one of the routes). So you shouldn't define it yourself. Instead, just import it from next.

import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context";

I will try to share an example of how to use it with nextgram tomorrow.

This actually works. Any reason why the API isn't public?

@leerob
Copy link
Member

leerob commented Aug 13, 2023

A few questions we'd love to hear more feedback on here:

  • Are you wanting to show a loading indicator like nprogress for every navigation?
    • Would you want to show this every time a Server Actions happens, as well? (a data mutation)
  • What would you want to happen when starting other React transitions?
    • e.g. If there's a Suspense boundary on the second page, should it stop when the loading state of the Suspense boundary is shown or when the last one completes?

@kylemh
Copy link
Contributor

kylemh commented Aug 14, 2023

Let me know if I'm way off base here, but this thread - I don't think - has anything to do with things like nProgress. It's more like transitioning within a layout between routes.

Easy to imagine with modal animations or fade-in and -out of previous to next route. Basically, all the example sites you see with the Chrome View Transitions API (but Framer Motion specifically)

Essentially, in the pages router, the AnimatePresence component worked flawlessly. Now, it's not viable and it seems much more difficult to do complex, staggered, or JS-heavy animations between routes of the same layout component.

@pete-willard
Copy link

I very rarely weight in negatively on issues as I understand projects have a huge number of things to contend with, but it's absolutely absurd that this is still an issue. I've been using Next.js for years at this point and this has been a recurring issue on literally every single project I've used Next.js for. I've had either go back to the pages router unwillingly or completely restructure projects with odd component layouts to get around the problem. We shouldn't have to come up with hacks to get something as simple as a page transition animation working correctly. Again, I'm sorry to be so negative but this issue alone making me consider moving on from Next.js.

My sentiment exactly, spot on. I applaud everyone in the thread for coming up with such inventive workarounds and digging so deep but it's absolutely preposterous it had to come to this at all. I guess you'd have to go on a podcast to get their attention: https://twitter.com/timneutkens/status/1767943917024985531 @timneutkens @leerob

@eriksachse
Copy link

My guess was always that this opens up the possibility to create customised transitions for each page. SvelteKit uses the page transition API, super simple to set up, see https://svelte.dev/blog/view-transitions … And I still prefer the NextJS way, because I don't want to have the same opacity transition on each page. It is a huzzle to set up right, and sometimes I worry that it won't work on mobile browsers and such. Let's pray together 🙏

@rijk
Copy link

rijk commented Mar 14, 2024

And I still prefer the NextJS way

To be clear, there currently is no "NextJS way".

However, we shouldn't get sour because they respond to another issue first. I think it's just an inherent trait of the new page/layout architecture that makes this hard to implement for them. Lee told me it's definitely on their radar.

@joebentaylor1995
Copy link

joebentaylor1995 commented Mar 14, 2024 via email

@hongweitang
Copy link

hongweitang commented Mar 14, 2024

Recently I had to migrate a project back to pages router because the project was in need of page transitions. Other projects are still in a questionable state if I even want to use Next.js again. Even considered to try Nuxt again or switch to SvelteKit to find a solution going forward. For design-focused websites it's unfathomable why the new App router doesn't work natively with page transitions.

How are you all solving the situation right now? Still relying on pages router until a better solution arrives?

@eriksachse
Copy link

How are you all solving the situation right now? Still relying on pages router until a better solution arrives?

I'm using React and react-router. My practice is design focussed as well, so pardon my lacking terminological knowledge earlier.

@devinatbryt
Copy link

devinatbryt commented Mar 17, 2024

Hey @rijk, I absolutely love your solution, but found the downside being that the animation relies on a set DELAY and can't be different on a per route basis. My solution to this is the following code, now it might be best to separate this code into different files, but this is the general idea.

// transitions.tsx
"use client";

import { usePathname, useParams, useRouter } from "next/navigation";
import {
  type HTMLMotionProps,
  type TargetAndTransition,
  type Target,
  AnimatePresence,
  motion,
  useAnimationControls,
} from "framer-motion";
import {
  createContext,
  MouseEventHandler,
  use,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useTransition,
} from "react";

export const DELAY = 200;

const noop = (): void => {};
const asyncNoop = async (): Promise<void> => {};

type TransitionContext = {
  pending: boolean;
  navigate: (url: string) => void;
  controls: ReturnType<typeof useAnimationControls>;
  routerPath: ReturnType<typeof useRouterPath>;
};

const Context = createContext<TransitionContext>({
  pending: false,
  navigate: noop,
  controls: {
    //@ts-ignore
    mount: noop,
    start: asyncNoop,
    set: noop,
    stop: noop,
  },
  routerPath: {
    current: "/",
    previous: "/",
  },
});

function usePreviousValue<TValue>(value?: TValue): TValue | undefined {
  const prevValue = useRef<TValue>();

  useEffect(() => {
    prevValue.current = value;

    return () => {
      prevValue.current = undefined;
    };
  });

  return prevValue.current;
}

const useIsFirstRender = () => {
  const isFirstRenderRef = useRef(true);

  if (isFirstRenderRef.current) {
    isFirstRenderRef.current = false;

    return true;
  }

  return isFirstRenderRef.current;
};

function useRouterPath() {
  const pathname = usePathname();
  const params = useParams();
  const currentRouterPath = useMemo(() => {
    return Object.entries(params).reduce((path, [paramKey, paramValue]) => {
      return path.replace(`/${paramValue}`, `/[${paramKey}]`);
    }, pathname);
  }, [pathname, params]);
  const previousRouterPath = usePreviousValue(currentRouterPath);

  return { current: currentRouterPath, previous: previousRouterPath };
}

const usePageTransition = () => use(Context);

type RouterPath = ReturnType<typeof useRouterPath>;
type VanillaTagName = keyof HTMLElementTagNameMap;
type PageVariants = {
  enter:
    | TargetAndTransition
    | ((
        routerPath: RouterPath,
        current: Target,
        velocity: Target
      ) => TargetAndTransition | string);
  exit:
    | TargetAndTransition
    | ((
        routerPath: RouterPath,
        current: Target,
        velocity: Target
      ) => TargetAndTransition | string);
};

type PageAnimationProps<TagName extends VanillaTagName> = Omit<
  HTMLMotionProps<TagName>,
  "initial" | "exit" | "animate" | "custom"
> & {
  as?: TagName;
  variants: PageVariants;
};

type PageTransitionProps<TagName extends VanillaTagName> = Omit<
  HTMLMotionProps<TagName>,
  "onClickCapture"
> & {
  as?: TagName;
};

export function PageTransitions<TagName extends VanillaTagName>({
  children,
  as,
  ...props
}: PageTransitionProps<TagName>) {
  const controls = useAnimationControls();
  const [pending, start] = useTransition();
  const router = useRouter();
  const routerPath = useRouterPath();
  const pathname = usePathname();

  const navigate = useCallback(
    (href: string) => {
      if (pathname === href) return;
      start(async () => {
        router.push(href);
        await controls.start("exit");
      });
    },
    [pathname, controls]
  );

  const onClick: MouseEventHandler<HTMLDivElement> = (e) => {
    const a = (e.target as Element).closest("a");
    if (a) {
      e.preventDefault();
      const href = a.getAttribute("href");
      if (href) navigate(href);
    }
  };

  const Motion = useMemo(() => {
    return motion(as || "div");
  }, [as]);

  return (
    <Context.Provider
      value={{
        pending,
        navigate,
        controls,
        routerPath,
      }}
    >
      <Motion onClickCapture={onClick} {...props}>
        {children}
      </Motion>
    </Context.Provider>
  );
}

export function PageAnimation<TagName extends VanillaTagName>({
  as,
  ...props
}: PageAnimationProps<TagName>) {
  const isFirstRender = useIsFirstRender();
  const { controls, pending, routerPath } = usePageTransition();
  const Motion = useMemo(() => {
    return motion(as || "div");
  }, [as]);

  useEffect(() => {
    if (pending || isFirstRender) return;
    controls.start("enter");
  }, [pending, isFirstRender]);

  return (
    <AnimatePresence initial={false}>
      <Motion
        key={routerPath.current}
        initial="exit"
        custom={routerPath}
        animate={controls}
        {...props}
      />
    </AnimatePresence>
  );
}

export function PageMotion<TagName extends VanillaTagName>({
  children,
  as,
  ...props
}: PageAnimationProps<TagName>) {
  const { routerPath } = usePageTransition();
  const Motion = useMemo(() => motion(as || "div"), [as]);
  return (
    <Motion custom={routerPath} {...props}>
      {children}
    </Motion>
  );
}

The idea here is that the "PageTransitions" component is used as a wrapper and allows us to capture all navigation events. Whenever we catch a navigation click that isn't the same path, we use animation controls to start the: "exit" animation on the variants. This allows us to await for the exit animation to be completed before we end the transition. Then we use the: "PageAnimation" component to first register the "controls" to an actual motion element.

The reason I didn't make "PageTransitions" and "PageAnimation" components one component is to allow "PageTransitions" to wrap the entire layout to catch all link clicks whilst still having the control to place: "PageAnimation" component closer to where you want the actual animations to happen.

Finally we have the: "PageMotion" component which is similar to "PageAnimation" except it's meant to be used on individual pages.

Here's a few examples of how you could use the following components :) .

Example 1

// layout.tsx

import { PageTransitions, PageAnimation } from "@/components/transitions"

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" className={`${lato.variable} ${theme}`}>
      <body className="dark:bg-[#151515] bg-[#fcfcfc] text-black dark:text-white font-lato">
        <PageTransitions>
            <NavBar />
            {/* Means every page transition starts with 0 opacity and transitions to an opacity of 1 */}
            <PageAnimation
              variants={{
                pageEnter: { opacity: 1 },
                pageExit: { opacity: 0 },
              }}
            >
              <main className="min-h-screen">{children}</main>
            </PageAnimation>
        </PageTransitions>
      </body>
    </html>
  );
}

// page.tsx
export default function Home() {
  return (
    <PageMotion variants={{ pageEnter: { y: 0 }, pageExit: { y: "-100%" } }}>
      <h1>Hello world!</h1>
    </PageMotion>
  );
}

Example 2

// PageAnimations.tsx
"use client";

import { PageAnimation } from "@/components/transitions";
export default function PageAnimations({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <PageAnimation
      variants={{
        pageEnter(routerPath) {
          if (routerPath.previous === "/[slug]" && routerPath.current === "/") {
            return {
              opacity: 1,
              y: 0,
              transition: {
                duration: 1,
              },
            };
          }
          return {
            opacity: 1,
          };
        },
        pageExit(routerPath) {
          if (routerPath.previous === "/" && routerPath.current === "/[slug]") {
            return {
              opacity: 0,
              y: "-100%",
              transition: {
                duration: 1,
              },
            };
          }
          return {
            opacity: 0,
          };
        },
      }}
    >
      {children}
    </PageAnimation>
  );
}

// layout.tsx
import { PageTransitions } from "@/components/transitions"
import PageAnimations from "@/components/pageAnimations"

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" className={`${lato.variable} ${theme}`}>
      <body className="dark:bg-[#151515] bg-[#fcfcfc] text-black dark:text-white font-lato">
        <PageTransitions>
            <NavBar />
            {/* 
                it's important we make this our own client component so we can take advantage of how the "custom" prop in framer 
                motion works, this essentially allows us to use a function to dynamically change how the variants animate depending 
                on our custom logic 
            */}
            <PageAnimations>
              <main className="min-h-screen">{children}</main>
            </PageAnimations>
        </PageTransitions>
      </body>
    </html>
  );
}

// page.tsx
export default function Home() {
  return (
    <PageMotion variants={{ pageEnter: { y: 0 }, pageExit: { y: "-100%" } }}>
      <h1>Hello world!</h1>
    </PageMotion>
  );
}

It'd be nice if Next.js had a solution to this problem, because I imagine the above solution works best for statically generated pages and or pages that heavily use suspense. As if the page has to wait for the document at all it can break the seamless page transition feel. Leaving the user with a blank screen until the new content for the page has been fully retrieved from the server.

Please feel free to critique this solution, I'd love to work with someone to make this solution a more solid implementation that covers all use cases! I hope this helps some people! Heck, if Nextjs doesn't provide a solution, I guess it'd be cool to start working on a npm package to help solve this problem!

@devinatbryt
Copy link

devinatbryt commented Mar 20, 2024

I thought the above solution was a decent solution, but then I tested the "back" and "forward" functions of my browser and the children are just frozen. The meta content and everything else changes, but anything below where the: "PageAnimation" component that's rendered is stuck to the pages content you were just on. I also tested this functionality on the "Frozen Router" method and neither solutions here work :( .

woohyun1031 referenced this issue in npostulart/nextgram-with-page-transitions Mar 27, 2024
@escape-key-onkeyboard
Copy link

escape-key-onkeyboard commented Mar 30, 2024

I found a website which uses app router and has done page transitions with shared layout transitions.
The transition also happens using browser back/forward.

https://www.lens.xyz/

Can someone tell me how?

@j2is
Copy link

j2is commented Apr 2, 2024

While fully acknowledging the tremendous effort invested in developing the app router, the possibility of building app-like experiences with the new model is enticing. Astro are setting a high standard with their view transitions api. I'm hoping that there's an official solution as this issue has persisted for a year

@lochie
Copy link

lochie commented Apr 7, 2024

I found a website which uses app router and has done page transitions with shared layout transitions. The transition also happens using browser back/forward.

https://www.lens.xyz/

Can someone tell me how?

Hey all, I built this website.

I used this method from this thread, but my LayoutRouterContext import was from a different path.

Here's the exact code for my frozen router.

import { PropsWithChildren, useContext, useRef } from "react";

import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";

export function FrozenRouter(props: PropsWithChildren<{}>) {
  const context = useContext(LayoutRouterContext);
  const frozen = useRef(context).current;
  return (
    <LayoutRouterContext.Provider value={frozen}>
      {props.children}
    </LayoutRouterContext.Provider>
  );
}

Dependencies are [email protected] and [email protected]

Unsure if I can be any more helpful here, as this solution worked mostly fine for my use case, but I had to do some context provider magic for the persistent animation on the right-hand side of the website that is async from the main routes.

@ivansgarcia

This comment was marked as off-topic.

@ballermatic
Copy link

ballermatic commented Apr 15, 2024

Check this out (from no other than @shuding ) https://github.com/shuding/next-view-transitions/tree/main

I have not tested it but following this issue closely. Almost all of my clients expect page transitions now. Sigh. My hunch is that @leerob et al. aren't sleeping on this but I couldn't pretend to know.

@rijk
Copy link

rijk commented Apr 15, 2024

CSS view transitions are not supported in Safari yet (next version).

@shuding uses the same approach of overriding Link. Sam Selikoff used it too in his article. Although it works I dislike having to do that just to get view transitions. It is a lot of work and in this case there is no support for replace, and no checks for modifier keys so e.g. cmd+click for opening new tab won't work anymore.

So in my opinion it's still a hack and not a good long term solution.

@takuma-hmng8
Copy link

Updated Page transition Animation demo using App Router.

When integrating into App Router, I refer to the Frozen Router idea.

Demo : https://mekuri.vercel.app/
Repo : https://github.com/FunTechInc/mekuri

Features 📕

  • wait and sync modes
  • scrollRestoration in popstate.
  • When in sync mode, routing is possible in wait mode when in popstate.
  • Supports frameworks such as Next.js and Remix. Can also integrate with Next.js App Router.
  • useMekuri hook for each component.
  • Integration into smooth scrolling libraries such as lenis is also possible.

@paperpluto
Copy link

Updated Page transition Animation demo using App Router.

When integrating into App Router, I refer to the Frozen Router idea.

Demo : https://mekuri.vercel.app/
Repo : https://github.com/FunTechInc/mekuri

Features 📕

  • wait and sync modes
  • scrollRestoration in popstate.
  • When in sync mode, routing is possible in wait mode when in popstate.
  • Supports frameworks such as Next.js and Remix. Can also integrate with Next.js App Router.
  • useMekuri hook for each component.
  • Integration into smooth scrolling libraries such as lenis is also possible.

Are Shared layout animations possible? I can't get it to work😭

@takuma-hmng8
Copy link

takuma-hmng8 commented Apr 19, 2024

Are Shared layout animations possible? I can't get it to work😭

@paperpluto
Yes, it is possible. This demo is a reproduction of FramerMotion's AnimatePresense. See repo source code for more information.

@MauricioCorzo
Copy link

@rijk I am having an issue with your aproach, if I put a delay of 5seconds, the pending state last forever, i have to click again in the link and then it works, but this doesnt happends with 4 seconds for example 🤷‍♂️. I am working with Promis.all example

@MauricioCorzo
Copy link

MauricioCorzo commented Apr 26, 2024

@rijk I am having an issue with your aproach, if I put a delay of 5seconds, the pending state last forever, i have to click again in the link and then it works, but this doesnt happends with 4 seconds for example 🤷‍♂️. I am working with Promis.all example

And I dont understand why async callback works when react says that the function should be sync (except server actions)

@andrew-d-jackson
Copy link

The solution by @rijk and other here worked for me but failed for my use case of nested transitions. I want to have a layout and transition just the sub page segment, and be able to nest these. I've modified it so it supports this:

"use client";

import { AnimatePresence, motion } from "framer-motion";
import { useSelectedLayoutSegment } from "next/navigation";
import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";

import { useContext, useEffect, useRef } from "react";

function usePreviousValue<T>(value: T): T | undefined {
  const prevValue = useRef<T>();

  useEffect(() => {
    prevValue.current = value;
    return () => {
      prevValue.current = undefined;
    };
  });

  return prevValue.current;
}

function FrozenRouter(props: { children: React.ReactNode }) {
  const context = useContext(LayoutRouterContext);
  const prevContext = usePreviousValue(context) || null;

  const segment = useSelectedLayoutSegment();
  const prevSegment = usePreviousValue(segment);

  const changed =
    segment !== prevSegment &&
    segment !== undefined &&
    prevSegment !== undefined;

  return (
    <LayoutRouterContext.Provider value={changed ? prevContext : context}>
      {props.children}
    </LayoutRouterContext.Provider>
  );
}

export function LayoutTransition(props: {
  children: React.ReactNode;
  className?: React.ComponentProps<typeof motion.div>["className"];
  style?: React.ComponentProps<typeof motion.div>["style"];
  initial: React.ComponentProps<typeof motion.div>["initial"];
  animate: React.ComponentProps<typeof motion.div>["animate"];
  exit: React.ComponentProps<typeof motion.div>["exit"];
}) {
  const segment = useSelectedLayoutSegment();

  return (
    <AnimatePresence>
      <motion.div
        className={props.className}
        style={props.style}
        key={segment}
        initial={props.initial}
        animate={props.animate}
        exit={props.exit}
      >
        <FrozenRouter>{props.children}</FrozenRouter>
      </motion.div>
    </AnimatePresence>
  );
}

Use like this:


export function MyTransition(props: {
  children: React.ReactNode;
}) {
  return (
    <div className="relative w-full grow">
      <LayoutTransition
        className="grow w-full absolute left-0 right-0"
        initial={{ opacity: 0, y: -15 }}
        animate={{
          opacity: 1,
          y: 0,
          transition: { delay: 0.1, duration: 0.1 },
        }}
        exit={{ opacity: 0, y: 15 }}
      >
        {props.children}
      </LayoutTransition>
    </div>
  );
}

export default function MyLayout({
  children,
}: { children: React.ReactNode }) {
  return (
    <>
      <MySidebar />
      <MyTransition>{children}</MyTransition>
    </>
  );
}

Hope this helps someone out

@OKok-3
Copy link

OKok-3 commented Apr 28, 2024

[Note: I am very new to both next.js and framer] I've found another potentially very "hacky" way as a work around for exit animations on page redirect. I created a client component that monitors the browser URL (using usePathname), let's call it "Wrapper". It then simply returns the appropriate component based on the URL and acts as the "router". Any redirects then must be triggered with window.history.pushState and in page.tsx of every route you just return <Wrapper />. Doing this allowed me to make exit animations work. However, I feel like this kind of defeats the whole purpose of using next.js app router as there isn't any real redirects happening, and this is now essentially a single page application.

@fweth
Copy link

fweth commented Apr 30, 2024

I found another way to make page transitions work with the app router. It's a bit hacky, it uses the fact that useMemo runs before the HTML is updated, so inside useMemo I have the chance to clone the HTML node and then put it back after the router removed it:

"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import { usePathname } from "next/navigation";

export default function Transition({ children }) {
  const [exiting, setExiting] = useState(false);
  const path = usePathname();
  const cloneRef = useRef();
  const innerRef = useRef();
  const outerRef = useRef();
  useMemo(
    function () {
      if (!innerRef.current) return;
      setExiting(true);
      cloneRef.current = innerRef.current;
    },
    [path]
  );
  useEffect(
    function () {
      if (exiting) {
        outerRef.current.appendChild(cloneRef.current);
        cloneRef.current.style.transition = "none";
        cloneRef.current.style.opacity = 1;
        window.setTimeout(function () {
          cloneRef.current.style.transition = "opacity 400ms";
          cloneRef.current.style.opacity = 0;
        }, 100);
        window.setTimeout(function () {
          setExiting(false);
          cloneRef.current.remove();
        }, 500);
        return () => cloneRef.current.remove();
      }
      window.setTimeout(function () {
        if (!innerRef.current) return;
        innerRef.current.style.opacity = 1;
      }, 100);
    },
    [exiting]
  );
  return (
    <div ref={outerRef}>
      {!exiting && (
        <div
          key={path}
          ref={innerRef}
          style={{ opacity: 0, transition: "opacity 400ms" }}
        >
          {children}
        </div>
      )}
    </div>
  );
}

@nadeemc
Copy link

nadeemc commented May 9, 2024

Was running into a similar issue with page router not updating motion.div in a way that would go from initial -> animate values. So, came up with this simple wrapper/workaround, for those looking for only a minimal entry animation like I was:

/* motion-div-reveal.tsx */
'use client';

import {DynamicAnimationOptions, HTMLMotionProps, useAnimate} from 'framer-motion';
import {PropsWithChildren, useEffect} from 'react';

export type MotionDivRevealProps = HTMLMotionProps<'div'>;

// This is a basic replacement for <motion.div> in scenarios where the app router is used to navigate
// between components that have an entry animation.
// This is a workaround for this issue:
// https://github.com/vercel/next.js/issues/49279
export const MotionDivReveal = (props: PropsWithChildren<MotionDivRevealProps>) => {
  const [scope, animate] = useAnimate();
  useEffect(() => {
    if (!scope.current) {
      return;
    }

    let containerKeyFrames: Record<string, any[]> = {};
    // Check if props.initial is a boolean type
    if (props.initial instanceof Object && props.animate instanceof Object) {
      // eslint-disable-next-line guard-for-in
      for (const key in props.initial) {
        // @ts-expect-error any type is inferred for this keys/values
        containerKeyFrames[key] = [props.initial[key], props.animate[key] ?? props.initial[key]];
      }
    } else {
      console.warn('MotionDivReveal: initial and/or animate prop is not an object, skipping animation.');
      return;
    }

    void animate(
      scope.current,
      containerKeyFrames,
      props.transition ?? {
        bounce: 0,
        duration: 0.3, /* 300ms */
      },
    );
  }, [/* no dependencies to ensure the animation only runs once, or is skipped if the scope is not set */]);

  return (
    <div ref={scope} className={props.className}>
      {props.children}
    </div>
  );
};

Then, this can be used like you would have used a <motion.div>, i.e.:

<MotionDivReveal 
    initial={{opacity: 0, x: -50}}
    animate={{opacity: 1, x: 0}}
>
    Watch me slide in
</MotionDivReveal>

This works because it fires the animations with a useEffect and useAnimate, so they always run on the client.

@huyngxyz
Copy link

huyngxyz commented May 16, 2024

I found a website which uses app router and has done page transitions with shared layout transitions. The transition also happens using browser back/forward.
https://www.lens.xyz/
Can someone tell me how?

Hey all, I built this website.

I used this method from this thread, but my LayoutRouterContext import was from a different path.

Here's the exact code for my frozen router.

import { PropsWithChildren, useContext, useRef } from "react";

import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";

export function FrozenRouter(props: PropsWithChildren<{}>) {
  const context = useContext(LayoutRouterContext);
  const frozen = useRef(context).current;
  return (
    <LayoutRouterContext.Provider value={frozen}>
      {props.children}
    </LayoutRouterContext.Provider>
  );
}

Dependencies are [email protected] and [email protected]

Unsure if I can be any more helpful here, as this solution worked mostly fine for my use case, but I had to do some context provider magic for the persistent animation on the right-hand side of the website that is async from the main routes.

@lochie Just curious, have you had any issues with using the FrozenRouter method in production? Would love to use page transitions as apart of my client projects and personal projects but I'm afraid there might be drawbacks to using this method.

Fingers crossed that the Next.js team will be tackling this real soon, we all have been eagerly waiting for an official solution 🥹

@lochie
Copy link

lochie commented May 16, 2024

@lochie Just curious, have you had any issues with using the FrozenRouter method in production? Would love to use page transitions as apart of my client projects and personal projects but I'm afraid there might be drawbacks to using this method.

Fingers crossed that the Next.js team will be tackling this real soon, we all have been waiting eagerly for an official solution 🥹

@huyngxyz there are different issues for styling depending on what styling solution you use. i know we had issues with (s)css modules and had to implement an unmount delay for component styles, and styled-components also had some issues. tailwind might be okay 🤷‍♀️

there were definitely more issues than not, it was enough that i considered switching back to page router multiple times during development, and i still generally prefer to opt for page router when it comes to having animation-rich web apps depending if the app would benefit hugely from server components. it's all a balancing act.

@huyngxyz
Copy link

@lochie Awesome, thanks for sharing! Gonna give it a try and see how it's like with tailwind

@kaisarkuanysh
Copy link

is there any way to start exit animation when the data of next page started loading from server?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area: app App directory (appDir: true) bug Issue was opened via the bug report template. linear: next Confirmed issue that is tracked by the Next.js team.
Projects
None yet
Development

No branches or pull requests