Open maurocolella opened 1 year 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.
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.
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
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;```
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>;
};
@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
@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.
@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...
Does nextJS lose its support for page-switching animations, or is SPA less important?
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.
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?
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
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.
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'sAnimatePresence
.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?
When is this getting an update?😮
I can't seem to get shared element transitions across router even now.
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.
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.
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.
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?
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:
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)onClick
with preventDefault
to override the default navigation codenavigate
is called, the pending
flag is set to true @rijk could your example allow crossfading between pages too?
@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.
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.
@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:
It doesn't have support for replace
in my demo, but that's easy to add.
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!)
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.
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.
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/
I divided the transition between an ExitAnimation
component and EnterAnimation
component.
The trick is that navigation buttons don't link to the destination page but instead add a searchParam like ?transitionTo=about
The ExitAnimation
component is mounted in the layout and listens to changes in searchParams. When there is a transitionTo
searchParams, it prefetches the destination page and starts the animation of a simple screen that fades in.
The OnAnimationComplete
callback triggers router.push('/'+transitionTo)
to the desired destination page.
The EnterAnimation
component is mounted on each page and runs on page load with a screen that fades out.
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.
@rijk Is your solution necessary if you only animating the opacity from 0 => 1?
@sommeeeer Not for the entry animation no, just the exit animation (opacity 1 => 0).
While implementing this in my app I iterated on this solution a bit.
Some observations:
key={pathname}
is not a good approach. Sometimes children
is already updated, but the pathname
isn't. This results in a flash of the new content in the div that's fading out, before starting the appear animation, or the appear animation starting twice. Unfortunately there is no better way to discern when the page has changed that I know of, I even tried hashing the contents of the children variable but it doesn't work. I think we need first party tools here, as I've said before.
DELAY
is longer than the exit animation takes to run. Otherwise, the content for the exiting div will be replaced, and because there's no key
animore, framer-motion will see it as an element update rather than a new element and will not run the appear animation, instead it will reverse the exit animation.<div onClickCapture={onClick}>
, that way you don't need to replace all the links in your app (it was cumbersome to do in my case). Also the links can remain server components.Pushed an update here for whoever's interested.
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:
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
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 andpending === 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 ofsleep()
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:
- I wrap the
router.push
call in my own transition (next/link
does this as well, but doesn't give us access to thepending
value which is essential for this approach)- I use an
onClick
withpreventDefault
to override the default navigation code- When
navigate
is called, thepending
flag is set to true- 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
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);
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 🚀
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.
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
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 🙏
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.
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.
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?
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.
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!
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 :( .
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.
Can someone tell me how?
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
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.
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.
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)
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.
Verify canary release
Provide environment information
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.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