-
I plan on migrating my app to Tanstack Router, but I wanted to know what implementing page transitions with Framer Motion would look like? React Router v6 makes animating page transitions kind of a pain compared to v5, and seemingly impossible in the case of nested routes, which is one of many reasons I want to migrate. I'm currently using React Router v6 with Thanks! |
Beta Was this translation helpful? Give feedback.
Replies: 8 comments 27 replies
-
This article talks about how to do it with NextJs. https://blog.stackademic.com/how-to-perfect-slide-in-and-slide-out-page-transitions-in-next-js-with-framer-motion-67a2f320762 This should work the same way with Tanstack. In the PageTransitionLayout component, you will need to use useRouter from tanstack. The key prop for motion.div should be router.state.location.pathname I think |
Beta Was this translation helpful? Give feedback.
-
Okay, I think I've cracked this! The trick seems to be keeping hold of the old router context and using that when the component is on the way out. Thankfully, TSR exposes all the context stuff you need to make this work: const AnimatedOutlet = forwardRef<HTMLDivElement>((_, ref) => {
const RouterContext = getRouterContext();
const routerContext = useContext(RouterContext);
const renderedContext = useRef(routerContext);
const isPresent = useIsPresent();
if (isPresent) {
renderedContext.current = cloneDeep(routerContext);
}
return (
<motion.div ref={ref} {...transitionProps}>
<RouterContext.Provider value={renderedContext.current}>
<Outlet />
</RouterContext.Provider>
</motion.div>
);
}); Then in your parent component: const Root = () => {
const matches = useMatches();
const match = useMatch({ strict: false });
const nextMatchIndex = matches.findIndex((d) => d.id === match.id) + 1;
const nextMatch = matches[nextMatchIndex];
return (
<main>
<AnimatePresence mode="popLayout">
<AnimatedOutlet key={nextMatch.id} />
</AnimatePresence>
</main>
);
}; I haven't tested this super extensively, but initial results seem promising. Notes:
Would love to know if anyone uses this, and how you get on! |
Beta Was this translation helpful? Give feedback.
-
I can't get it to work too. |
Beta Was this translation helpful? Give feedback.
-
Here is a solution using react transition group: export const AnimatedOutlet = () => {
const matches = useMatches();
const match = useMatch({ strict: false });
const matchIndex = matches.findIndex((d) => d.id === match.id);
const nextMatchIndex =
matchIndex === matches.length - 1 ? matchIndex : matchIndex + 1;
const nextMatch = matches[nextMatchIndex];
const RouterContext = getRouterContext();
const routerContext = useContext(RouterContext);
const renderedContext = useRef(routerContext);
const isPresent = useRef(true);
if (isPresent.current) {
const clone = cloneDeep(routerContext);
clone.options.context = routerContext.options.context;
renderedContext.current = clone;
}
return (
<FadeTransition
mountOnEnter={true}
unmountOnExit={true}
appear={true}
transitionKey={nextMatch.pathname}
onEnter={() => {
isPresent.current = true;
}}
onExit={() => {
isPresent.current = false;
}}
>
<RouterContextProvider router={renderedContext.current}>
<Outlet />
</RouterContextProvider>
</FadeTransition>
);
}; export const FadeTransition = ({
transitionKey,
children,
...props
}: Props) => {
const nodeRef = useRef(null);
return (
<SwitchTransition>
<CSSTransition
{...props}
key={transitionKey}
timeout={150}
nodeRef={nodeRef}
>
<TransitionCont ref={nodeRef}>{children}</TransitionCont>
</CSSTransition>
</SwitchTransition>
);
}; |
Beta Was this translation helpful? Give feedback.
-
Is the accepted answer still the simplest away to achieve page transitions with Framer Motion? Seems like a big chore to have to manually implement simultaneous tracking of both the current and previous router context just to get transitions working. Every experience with TanStack Router has been vastly superior to React Router except for this little corner. |
Beta Was this translation helpful? Give feedback.
-
There should be an official guide on how to do it |
Beta Was this translation helpful? Give feedback.
-
I was having a bunch of issues with the cloneDeep solution above:
So I tried a different approach: When the route changes, I capture the DOM node of the old page. Then I reinsert it into a new EDIT: Ok, so of course after posting, I found some issues. The updated code works, but its less efficient than I'd like because it clones the DOM nodes, which for complex trees could be more expensive than just deep cloning the router state. I noticed this is only an issue for my root level outlet, but I don't have time to dig into it more right now. So I've made cloning an option - by default it does it and everything works, but if you set Here it is in case its useful to others. I'm using tailwind, you may need to replace the classes to suit your own needs. To use it, first wrap your root component with:
And then replace your <AnimatedOutlet
from={Route.id}
enter={{
initial: { filter: 'blur(15px)', opacity: 0 },
animate: { filter: 'blur(0)', opacity: 1 }
}}
exit={{
initial: { filter: 'blur(0)', opacity: 1 },
animate: { filter: 'blur(15px)', opacity: 0 }
}}
transition={{ duration: 0.3 }}
/> WARNING: While I have tested it and it works for me so far, there may be some edge cases where it breaks. If you find any, please let me know! Here's the implementation: import { useState, useEffect, useRef, createContext, useContext } from 'react';
import { useRouter, Outlet, AnyRoute, useMatch } from '@tanstack/react-router';
import { motion, MotionProps } from 'motion/react';
interface TransitionProps {
initial?: MotionProps["initial"];
animate?: MotionProps["animate"];
}
interface AnimatedOutletProps {
enter: TransitionProps;
exit: TransitionProps;
transition?: MotionProps["transition"];
from: AnyRoute['id']
clone?: boolean
}
interface AnimatedOutletWrapperProps {
children: React.ReactNode
}
type TakeSnapshotFn = () => void
type Registry = Map<string, TakeSnapshotFn>
const AnimatedOutletContext = createContext<Registry>(new Map())
function isDescendant(pathname: string, destinationPath: string) {
return pathname === '/' ||
(destinationPath.startsWith(pathname) &&
(destinationPath.length === pathname.length ||
destinationPath.charAt(pathname.length) === '/'))
}
export function AnimatedOutletWrapper({ children }: AnimatedOutletWrapperProps) {
const router = useRouter();
const registry = useRef<Registry>(new Map())
useEffect(() => {
// NOTE: This should be onBeforeNavigate, but due to https://github.com/TanStack/router/issues/3920 it's not working.
// For now, we use onBeforeLoad, which runs right after onBeforeNavigate.
// See: https://github.com/TanStack/router/blob/f8015e7629307499d4d6245077ad84145b6064a7/packages/router-core/src/router.ts#L2027
const unsubscribe = router.subscribe('onBeforeLoad', ({ toLocation, pathChanged }) => {
if (pathChanged) {
const destinationPath = toLocation.pathname
// Find the outlet with the longest pathname, that is part of the destination route
let takeSnapshot: TakeSnapshotFn | null = null
let longestLength = 0
for (const [pathname, snapshotFn] of registry.current.entries()) {
if (isDescendant(pathname, destinationPath) && pathname.length > longestLength) {
longestLength = pathname.length
takeSnapshot = snapshotFn
}
}
if (takeSnapshot) {
// Take a snapshot of the deepest outlet
takeSnapshot()
}
}
});
return () => unsubscribe();
}, [router]);
return (
<AnimatedOutletContext.Provider value={registry.current}>
{children}
</AnimatedOutletContext.Provider>
)
}
export function AnimatedOutlet({
enter,
exit,
transition = { duration: 0.3 },
from,
clone = true
}: AnimatedOutletProps) {
const [snapshots, setSnapshots] = useState<{ node: HTMLElement, id: number }[]>([]);
const [pathname, setPathname] = useState<string | null>(null)
const outletRef = useRef<HTMLDivElement>(null);
const nextId = useRef(0);
const registry = useContext(AnimatedOutletContext)
useEffect(() => {
if (pathname) {
registry.set(pathname, () => {
const outletNode = outletRef.current!;
const snapshotNode = outletNode.firstChild as HTMLElement;
if (snapshotNode) {
let node = snapshotNode;
if (clone) {
node = snapshotNode.cloneNode(true) as HTMLElement;
} else {
outletNode.removeChild(snapshotNode)
}
const newSnapshot = { node, id: nextId.current++ }
setSnapshots((prevSnapshots) => [...prevSnapshots, newSnapshot]);
}
});
return () => { registry.delete(pathname) };
}
}, [registry, pathname]);
const handleAnimationComplete = (id: number) => {
setSnapshots((prevSnapshots) =>
prevSnapshots.filter((snapshot) => snapshot.id !== id)
);
};
return (
<>
{pathname === null && <GetPathName setPathname={setPathname} from={from} />}
<div className="relative w-full h-full">
{snapshots.map((snapshot) => (
<motion.div
key={snapshot.id}
className="absolute inset-0 pointer-events-none w-full h-full"
initial={exit.initial}
animate={exit.animate}
transition={transition}
onAnimationComplete={() => handleAnimationComplete(snapshot.id)}
aria-hidden="true"
ref={(el) => {
if (el && snapshot.node) {
el.appendChild(snapshot.node);
}
}}
/>
))}
<motion.div
key={nextId.current}
ref={outletRef}
className="relative w-full h-full"
initial={enter.initial}
animate={enter.animate}
transition={transition}
>
<div className="w-full h-full">
<Outlet />
</div>
</motion.div>
</div>
</>
);
}
function GetPathName({ from, setPathname }: { from: AnyRoute['id'], setPathname: (pathname: string) => void }) {
const match = useMatch({ from })
useEffect(() => {
setPathname(match.pathname)
}, [match.pathname])
return null
} A few things to note:
I'm not sure if this is useful to anyone. Besides fixing the bug (2 in the list), its far more complex than the other solutions presented... but maybe someone who understands React internals better can use this as a starting point and build on it. |
Beta Was this translation helpful? Give feedback.
Okay, I think I've cracked this! The trick seems to be keeping hold of the old router context and using that when the component is on the way out. Thankfully, TSR exposes all the context stuff you need to make this work: