vercel / next.js

The React Framework
https://nextjs.org
MIT License
123.34k stars 26.32k 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

rijk commented 3 months ago

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

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 📕

paperpluto commented 3 months ago

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

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

@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 commented 2 months ago

@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 commented 2 months ago

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

[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 commented 2 months ago

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

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 commented 2 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.

@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 commented 2 months ago

@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 commented 2 months ago

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

kaisarkuanysh commented 2 months ago

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

mad-zephyr commented 4 weeks ago

is there any news when this bug will be fixed in the app router? How to implement Exit without inventing wheels, will soon be two years old for this bug