vercel / next.js

The React Framework
https://nextjs.org
MIT License
122.99k stars 26.28k forks source link

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

Open maurocolella opened 1 year ago

maurocolella commented 1 year ago

Verify 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

Kornform commented 8 months ago

Has anyone found any update on this? its been like a year still nothin

Hey I just wanted to give you an update. The short answer for me is at least NO. After upgrading to NextJs version 14 I tried my Framer Page Transition Animations again (in the hope something would have changed) and it still didn't work for me. I wasted already too much time on that to even bother now about it :I, but I will follow this thread.

joshdavenport commented 7 months ago

The only approach I've ended up using that gets anywhere close to creating some transitions between pages is persistent components in layouts, but this comes with some fairly obvious drawbacks.

clieee commented 7 months ago

would be great to get some communication on this from the team, possible @timneutkens ? there is even a pull request that seems to get the things solved, but kind of quiet on that one too.

Discussion: https://github.com/vercel/next.js/discussions/56594 PR: https://github.com/vercel/next.js/pull/56591

CBrian04 commented 7 months ago

As an alternative to the hack provided few times ago and if some guys are interested by, a simple component like this at the top of each page component allows to make transition far more smoother reducing the flash screen effect (it exists but is not perceived as). Just change the black color by your website dominant color. It uses tailwind but of course it is possible to implement it with vanilla css. If it does not fit your need you can tweak z-index, duration of transition, etc.


import React, { useEffect, useState } from "react";

const NavigationHack = () => {
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    setTimeout(() => {
      setVisible(true);
    }, 50);
  }, []);

  return (
    <div
      className={`h-screen w-screen absolute z-50 bg-black transition-opacity ease-in duration-100 ${
        visible ? "opacity-0" : "opacity-100"
      }`}
    />
  );
};

export default NavigationHack;```
jariz commented 6 months ago

If you're planning on doing anything with useSearchParams: that will not be possible without edits, so here's hack (on top of a hack) to 'unfreeze' the router if searchParams have changed. It will stay frozen if the pathname changes. Use at your own risk...

const FrozenRouter = ({ children }: PropsWithChildren) => {
    const context = useContext(LayoutRouterContext);
    const [frozen, setFrozen] = useState(context);

    const params = useSearchParams().toString();
    const path = usePathname();
    const prevPath = useRef(path);

    useEffect(
        () => {
            // only unfreeze if the path hasn't changed
            if (prevPath.current === path) {
                setFrozen(context);
            }
            prevPath.current = path;
        },
        // eslint-disable-next-line react-hooks/exhaustive-deps
        [params, path]
    );
    const memoizedContext = useMemo(() => ({ ...frozen, childNodes: context?.childNodes }), [frozen, context]);

    return <LayoutRouterContext.Provider value={memoizedContext}>{children}</LayoutRouterContext.Provider>;
};
paperpluto commented 6 months ago

@npostulart The FrozenRouter one does causes some bug when scrolled. https://codesandbox.io/p/devbox/elegant-wood-mgcn59?file=%2Fapp%2Fabout%2Fpage.tsx%3A13%2C19

paperpluto commented 6 months ago

@leerob would be great to get some thoughts on this. It's neglected for more than half a year. Still no avail even from NextJS 14.😭. Not even in canary.

ballermatic commented 6 months ago

@leerob would be great to get some thoughts on this. It's neglected for more than half a year. Still no avail even from NextJS 14.😭. Not even in canary.

Agreed! I've been watching and debating Astro for clients who demand page transitions. I would prefer to stick with next JS...

sohaha commented 6 months ago

Does nextJS lose its support for page-switching animations, or is SPA less important?

IntiSilva commented 6 months ago

This is actually pretty sad, I've used framer with next js app router 2 years ago for my portfolio and I had the same issue with exit animations, now I'm doing a bigger project in which I was sure that I was going to use framer for transitions because well, it's been 2 years it had to be fixed, sadly it wasn't but this shows that sometimes companies don't even care too much about what we are using. Funny thing it's that Framer it's one of the most used libraries for animations in React and Next it's one of the most used frameworks from people that knew React, still this remains unsolved.

joebentaylor1995 commented 5 months ago

Im absolutely baffled how this hasnt been fixed yet... Honestly considering going back to Gatsby.

Can you at least make page transitions work in the pages router?

takuma-hmng8 commented 5 months ago

The problem of page transition animations using Framer Motion not working well in the Next.js app router is very complex...

Based on the idea of using LayoutRouterContext, I created my own page transition animation library inspired by FramerMotion's AnimatePresence.

So far, it works as expected with the latest Next.js.

Please note that this is a personal creation and is not planned to be maintained on a stable basis, so please be careful when using it

demo : mekuri.vercel.app repo : https://github.com/FunTechInc/mekuri

modulareverything commented 5 months ago

I think the solution for now is to use the Pages API instead. There's a video highlighting how to do it here, and this is what originally started my investigation into whether it's possible with the App Router (which, apparently, it just isn't).

https://www.youtube.com/watch?v=WmvpJ4KX30s

I think for my next project I'll unfortunately be going back to the Pages AP until this gets fixed.

danbentonsmith commented 5 months ago

The problem of page transition animations using Framer Motion not working well in the Next.js app router is very complex...

Based on the idea of using LayoutRouterContext, I created my own page transition animation library inspired by FramerMotion's AnimatePresence.

So far, it works as expected with the latest Next.js.

Please note that this is a personal creation and is not planned to be maintained on a stable basis, so please be careful when using it

demo : mekuri.vercel.app repo : https://github.com/FunTechInc/mekuri

This looks interesting - any chance of putting together a tut on how to implement this?

paperpluto commented 5 months ago

When is this getting an update?😮

paperpluto commented 5 months ago

I can't seem to get shared element transitions across router even now.

jariz commented 5 months ago

Be warned: @cutsoy's frozen router hack (that the above 'mekuri' project appears to be using as well) breaks vercel in very subtle ways.

Your builds will run fine and the site will run fine, but ISR will be completely broken and static pages with a revalidation will not update anymore after being build. I spend an entire day debugging this because I was sure this couldn't be the reason, but yeah, do NOT use this if you're using vercel. The normal way of hosting (with a node server) seems to be unaffected and works.

joshdavenport commented 5 months ago

Sad to hear that though my pessimistic side isn't too surprised. Thanks for adding to the conversation with your experience. We really just need a first-class solution to this.

At least for the medium term it looks like there's no sign of pages phasing out where this is easier but I think it's fair to say we all want to adopt the app router while providing nice transitions for the projects that call for it.

hongweitang commented 4 months ago

The best solution so far is to revert back to the pages router until an official solution has been identified. The framer/motion issue on that topic sadly has been closed by the dev.

Also despite its benefits of RSC if you work in creative website coding pages router is the safest choice for now.

hatsumatsu commented 4 months ago

The FrozenRouter approach works great up to next@14.0.4 but somehow breaks for me in the latest 14.1.

The first transition is fine but after that the next navigation creates multiple fetch requests to the target URL, which then time out and break the site.

Anyone else experiencing something similar?

rijk commented 4 months ago

I think I found a less hacky way to do this using a different approach than cloning the previous page, namely leveraging the behavior that during a (route) transition, the current page stays on screen until the new route is ready.

I've whipped together a quick demo: https://app-router-transitions.vercel.app

We can combine that transition behavior with <AnimatePresence>; because when we have existing content and pending === true, it means a page transition is in progress and thus an exit animation should be run. Adding {!pending && …} inside the <AnimatePresence> does the trick.

A final issue is that the transition might finish before the animation has run. To address this the navigate function adds a predefined amount of sleep() ms to make sure the animation always has time to run. The benefit of using Promise.all() for this compared to the approach linked above, is that the route already has time to load during the animation, rather than starting after the animation has finished. To illustrate I've added a delay to the About page, which is invisible when navigating.

Some implementation details:

  1. I wrap the router.push call in my own transition (next/link does this as well, but doesn't give us access to the pending value which is essential for this approach)
  2. I use an onClick with preventDefault to override the default navigation code
  3. When navigate is called, the pending flag is set to true
  4. This removes the currently rendered page, framer-motion takes care of the rest (exit/appear animations)
j2is commented 4 months ago

@rijk could your example allow crossfading between pages too?

rijk commented 4 months ago

@j2is I think so, using the popLayout mode. I've pushed a crossfade branch with an example here: https://app-router-transitions-git-crossfade-uniti.vercel.app

One thing that needed to be added is a key={pathname} on the motion.div, otherwise framer-motion doesn't realize it's a new page. This does not always work (in case of localized/dynamic paths), so might need some tweaking to make sure it's always unique.

Interestingly, I found out when taking this approach the delay only has to be a fraction of a second; as long as there is one render without the previous page, AnimatePresence will make sure to keep the element on screen during the exit animation.

kylemh commented 4 months ago

I use an onClick with preventDefault to override the default navigation code

I want to know if there are any negative trade-offs to this.

rijk commented 4 months ago

@kylemh I don't think so, it's just triggering this return and then performing its own router.push().

So you're just bypassing the linkClicked function, which essentially does the same:

https://github.com/vercel/next.js/blob/6194e49d77ae340633bd064bbcde2847ce2c57ba/packages/next/src/client/link.tsx#L237-L241

It doesn't have support for replace in my demo, but that's easy to add.

minusplusmultiply commented 4 months ago

Great work @rijk! Thanks for sharing your solution!

I've spent the day integrating it into a test project, and it's working perfectly with AppRouter. 🥹

(I still think the longterm plan will be to shift over to a solution that relies on some form of the ViewTransitions() API - but for now this is exactly what I've been hoping for!)

hatsumatsu commented 4 months ago

I think I found a less hacky way to do this using a different approach than cloning the previous page, namely leveraging the behavior that during a (route) transition, the current page stays on screen until the new route is ready.

Very elegant ans concise solution, thank you for sharing this, @rijk

Only thing missing is the transition on browser history navigation like using the back button. AFAIK there is no way to intercept / delay / prevent default behavior of a popstate event similar to a click event on a link, so I can't think of a possible solution for this last missing piece. But still a great solution for probably 90% of use cases.

rijk commented 4 months ago

No definitely, back/forward will come straight from the cache as we can't intercept those I think — although Sanity is doing something like it in their VisualEditing component, so maybe it is possible. But it's debatable whether you'd want it, because it will probably also kill scroll restoration.

In any case, I agree it is still a pretty complex user land workaround for something that should be supported out of the box, as every other major framework does. Page transitions are table stakes these days, and especially important for the SPA model, because right now during a page transition the user gets absolutely no feedback something is happening. This is a big weakness of the app router model and opens it up to (valid) criticism from e.g. Ruby folks, because in a traditional MPA you at least get browser spinner while navigating. There should in my opinion be first party tools to easily incorporate this loading state into your app; something as simple as exposing the pending state for a route transition, and making sure every page has a unique key could already help a lot.

So yeah, first party support is definitely preferable, although I don't know how easy it would be to implement in the new architecture. But until then this is a relatively robust and elegant way to do it I think.

jazsouf commented 4 months ago

Nice work @rijk !

On my side I will share my workaround for this issue.

I was deep in a project using the app router and the client asked for a simple fade transition between pages.

Here is the demo for my solution: https://next-app-transition-workaround.vercel.app/

This is definitely too much work, but hey you have to find a solution. I will use @rijk solution from now on since it's cleaner and more versatile.

sommeeeer commented 4 months ago

@rijk Is your solution necessary if you only animating the opacity from 0 => 1?

rijk commented 4 months ago

@sommeeeer Not for the entry animation no, just the exit animation (opacity 1 => 0).

rijk commented 4 months ago

While implementing this in my app I iterated on this solution a bit.

Some observations:

Pushed an update here for whoever's interested.

dimashpt commented 4 months ago

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>
  );
}

I'm triying this workaround. But i'm facing the issue that when i navigate to the other route, it's just stuck in the loading state. Here's steps to reproduce this issue:

  1. The first time I load a page (page A), everything's fine
  2. When i navigate to the other page (page B, C, D), the endless loading happens (i'm using loading.tsx that only renders text "Loading...")
  3. I navigate to the page A, the page showed up
  4. When I navigate again to page (B, C, D, the page loaded fine.

So, basically it happens when first navigate to the other page, but when the page has been loaded and cached, everything will work properly.

There is also an error in the console says:

Warning: Detected multiple renderers concurrently rendering the same context provider. This is currently unsupported.
    at FrozenRoute (webpack-internal:///(ssr)/./src/components/molecules/frozen-route.tsx:14:24)
    at div

Still trying to fix and looking for help! Here's my approach https://github.com/dimashpt/dimashpt/blob/c4c469a2cb9f5736fe6ede5482b6fac2dbf395c6/src/app/%5Blang%5D/layout.tsx#L29-L51

dextermb commented 4 months ago

I think I found a less hacky way to do this using a different approach than cloning the previous page, namely leveraging the behavior that during a (route) transition, the current page stays on screen until the new route is ready.

I've whipped together a quick demo: https://app-router-transitions.vercel.app

We can combine that transition behavior with <AnimatePresence>; because when we have existing content and pending === true, it means a page transition is in progress and thus an exit animation should be run. Adding {!pending && …} inside the <AnimatePresence> does the trick.

A final issue is that the transition might finish before the animation has run. To address this the navigate function adds a predefined amount of sleep() ms to make sure the animation always has time to run. The benefit of using Promise.all() for this compared to the approach linked above, is that the route already has time to load during the animation, rather than starting after the animation has finished. To illustrate I've added a delay to the About page, which is invisible when navigating.

Some implementation details:

  1. I wrap the router.push call in my own transition (next/link does this as well, but doesn't give us access to the pending value which is essential for this approach)
  2. I use an onClick with preventDefault to override the default navigation code
  3. When navigate is called, the pending flag is set to true
  4. This removes the currently rendered page, framer-motion takes care of the rest (exit/appear animations)

This so far is the best solution I've seen

CCPablo commented 3 months ago

Hey @rijk, thank you for the workaround, it works like a charm.

Regarding the original question of the thread, I achieved the shared layout element animation integration in your solution by performing some changes:

In the <Animation> component I did not set any animation, delegating the animation of the inner components to another component.

export function PageAnimatePresence({ children }: Props) {
  const { pending } = usePageTransitionContext();

  return <AnimatePresence>{!pending && children}</AnimatePresence>;
}

We could name it now something like PageAnimatePresence. The pages now should have only one child, of course. For Transitions, lets keep it the same at the moment.

Now, we can create a component <PageTransitionAnimation>, that would be responsible of animating each member of the pages you want to animate. Luckily we can make use of the hook usePresence of framer motion, that will tell us if the element is present or not.

export function PageTransitionAnimation({ children }: { children: ReactNode }) {
  const [isPresent] = usePresence();

  return (
    <motion.div
      animate={{
        opacity: isPresent ? 1 : 0,
      }}
      transition={{ duration: DELAY / 1000 }}
    >
      {children}
    </motion.div>
  );
}

This will enable us to animate only the required elements, with the possibility of keeping the shared layout element without a fade animation. This will require us to do a manual observation of the required animations for our own implementation.

In my case, I wanted to do a transition between a page for showing a list of (lets say products) and the page of the element itself. Now this is little bit trickier, as if the shared element is given for an specific product, you will not able to use the PageTransitionAnimation for all the product cards.

For achieving that, I added a new field to the context that would be the href clicked clickedHref.

type TransitionContext = {
  pending: boolean;
  navigate: (url: string) => void;
  clickedHref?: string;
};
const Context = createContext<TransitionContext>({
  pending: false,
  navigate: noop,
  clickedHref: undefined,
});

The <Transitions> component is now like this:

export default function Transitions({ children, className }: Props) {
  const [pending, start] = useTransition();
  const router = useRouter();
  const [clickedHref, setClickedHref] = useState<string | undefined>(undefined);

  const navigate = (href: string) => {
    start(async () => {
      router.push(href);
      await sleep(DELAY);
    });
  };

  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);
        setClickedHref(href);
      }
    }
  };

  return (
    <Context.Provider value={{ pending, navigate, clickedHref }}>
      <div onClickCapture={onClick} className={className}>
        {children}
      </div>
    </Context.Provider>
  );
}

This allow us to make a component that will receive the pathname of the product and use it to don't animate it.

export function PageTransitionItemAnimation ({
  children,
  pathname,
  ...rest
}: MotionPageTransitionProps<"div">) {
  const [isPresent] = usePresence();
  const { clickedHref } = usePageTransitionContext();
  var isPathMatch = !!pathname && pathname === clickedHref;
  return (
    <motion.div
      animate={{
        opacity: isPresent || isPathMatch ? 1 : 0,
      }}
      transition={{ duration: DELAY / 1000 }}
      {...rest}
    >
      {children}
    </motion.div>
  );
}

I made a generic implementation of that also. It must be used in client componets, thought, given that MotionComponent is non serializable prop and animate is a function.

function _PageTransitionAnimation (
  {
    children,
    pathname,
    MotionComponent = motion.div,
    animate,
    ...rest
  }: MotionPageTransitionProps<"div"> | MotionPageTransitionProps<"article">,
  ref: ForwardedRef<any>
) {
  const [isPresent] = usePresence();
  const { clickedHref } = usePageTransitionContext();
  var isPathMatch = !!pathname && pathname === clickedHref;
  const animate_ = animate
    ? animate(isPresent, isPathMatch)
    : {
        opacity:
          isPresent || (pathname && pathname === clickedHref)
            ? 1
            : 0,
      };
  return (
    <MotionComponent
      ref={ref}
      animate={animate_}
      transition={{ duration: DELAY / 1000 }}
      {...rest}
    >
      {children}
    </MotionComponent>
  );
}
export const PageTransitionAnimation = forwardRef(_PageTransitionAnimation);
hatsumatsu commented 3 months ago

The FrozenRouter approach works great up to next@14.0.4 but somehow breaks for me in the latest 14.1.

The first transition is fine but after that the next navigation creates multiple fetch requests to the target URL, which then time out and break the site.

Anyone else experiencing something similar?

FrozenRouter works again in 14.1.3 🚀

timmyomahony commented 3 months ago

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.

pete-willard commented 3 months ago

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 commented 3 months ago

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 commented 3 months ago

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 commented 3 months ago

But ignoring the community when this has been over a years worth of conversation is completely unacceptable. - no statement, no road map.

Might as well go back to pages or better yet Gatsby.

hongweitang commented 3 months ago

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 commented 3 months ago

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 commented 3 months ago

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 commented 3 months ago

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 :( .

escape-key-onkeyboard commented 3 months ago

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 commented 3 months ago

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 commented 3 months ago

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 next@14.1.0 and framer-motion@11.0.3

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 commented 3 months ago

I followed this to implement a simple transition between pages, but I get this warning on full refresh of page, and the first time I view it.

Warning: Detected multiple renderers concurrently rendering the same context provider. This is currently unsupported. at FrozenRoute (webpack-internal:///(ssr)/./app/components/HOC/FrozenRoute.js:14:24) at div at MotionComponent (webpack-internal:///(ssr)/./node_modules/framer-motion/dist/es/motion/index.mjs:49:65) at PresenceChild (webpack-internal:///(ssr)/./node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:15:26) at AnimatePresence (webpack-internal:///(ssr)/./node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:72:28) at PageAnimatePresence (webpack-internal:///(ssr)/./app/components/HOC/PageAnimatePresence.js:15:32) at Lazy at body at html at RedirectErrorBoundary (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/redirect-boundary.js:73:9) at RedirectBoundary (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/redirect-boundary.js:81:11) at ReactDevOverlay (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/react-dev-overlay/internal/ReactDevOverlay.js:84:9) at HotReload (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/react-dev-overlay/hot-reloader-client.js:308:11) at Router (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/app-router.js:177:11) at ErrorBoundaryHandler (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/error-boundary.js:114:9) at ErrorBoundary (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/error-boundary.js:160:11) at AppRouter (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/app-router.js:521:13) at Lazy at Lazy at rw (C:\Users\ivans\Projects\prueba-transicion-redonda\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:39:15737) at rw (C:\Users\ivans\Projects\prueba-transicion-redonda\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:39:15737) at ServerInsertedHTMLProvider (C:\Users\ivans\Projects\prueba-transicion-redonda\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:39:21384) ✓ Compiled in 657ms (1345 modules) Warning: Detected multiple renderers concurrently rendering the same context provider. This is currently unsupported. at FrozenRoute (webpack-internal:///(ssr)/./app/components/HOC/FrozenRoute.js:14:24) at div at MotionComponent (webpack-internal:///(ssr)/./node_modules/framer-motion/dist/es/motion/index.mjs:49:65) at PresenceChild (webpack-internal:///(ssr)/./node_modules/framer-motion/dist/es/components/AnimatePresence/PresenceChild.mjs:15:26) at AnimatePresence (webpack-internal:///(ssr)/./node_modules/framer-motion/dist/es/components/AnimatePresence/index.mjs:72:28) at PageAnimatePresence (webpack-internal:///(ssr)/./app/components/HOC/PageAnimatePresence.js:15:32) at Lazy at body at html at RedirectErrorBoundary (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/redirect-boundary.js:73:9) at RedirectBoundary (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/redirect-boundary.js:81:11) at ReactDevOverlay (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/react-dev-overlay/internal/ReactDevOverlay.js:84:9) at HotReload (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/react-dev-overlay/hot-reloader-client.js:308:11) at Router (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/app-router.js:177:11) at ErrorBoundaryHandler (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/error-boundary.js:114:9) at ErrorBoundary (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/error-boundary.js:160:11) at AppRouter (webpack-internal:///(ssr)/./node_modules/next/dist/client/components/app-router.js:521:13) at Lazy at Lazy at rw (C:\Users\ivans\Projects\prueba-transicion-redonda\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:39:15737) at rw (C:\Users\ivans\Projects\prueba-transicion-redonda\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:39:15737) at ServerInsertedHTMLProvider (C:\Users\ivans\Projects\prueba-transicion-redonda\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:39:21384)

ballermatic commented 2 months ago

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.