I am using Framer Motion to animate Next.js page transitions. However using the using AnimatePresence breaks the hash
link navigation and the page no longer goes to the targeted id
element.
The page transitions are perfect until you want to navigate to a harsh ID on the page :(
// I have a link component setup like this
// index.tsx
<Link href="/about#the-team" scroll={false}>
<a>The Team</a>
</Link>
// Targeting another page `about.tsx` with the id
// about.tsx
{/* ...many sections before.. */}
<section id="the-team">{content}</section>
I have a custom _app.tsx
as shown below.
// _app.tsx
import { AppProps } from 'next/app';
import { useRouter } from 'next/router';
import { AnimatePresence } from 'framer-motion';
const MyApp = ({ Component, pageProps }: AppProps): JSX.Element => {
const router = useRouter();
return (
<AnimatePresence exitBeforeEnter>
<Component {...pageProps} key={router.route} />
</AnimatePresence>
);
};
export default MyApp;
I am expecting to go directly to the section with id="the-team"
but it won't work. A refresh of the page with the hash link shows that it's originally at the target element but quickly jumps to the top. It's so fast and easy to miss. How do I retain the page transitions but still be able to navigate to hash id?
The Culprit is the exitBeforeEnter
prop on on AnimatePresence
. Removing the prop fixes the hash id navigation but breaks some of my use-case.
If set to true,
AnimatePresence
will only render one component at a time. The exiting component will finish its exit animation before the entering component is rendered. - framer-motion docs
I couldn't just remove the exitBeforeEnter
prop as I had included it to fix a bug I had where targeting a node in the entering page collided with the identical one in the old instance of the exiting page. For example a ref
logic on an animated svg header in the exiting Page colliding with the entering page's header svg ref logic.
To get the best of both worlds, Using the onExitComplete
that "Fires when all exiting nodes have completed animating out", I passed it a callback that checks for the hash from the widow.location.hash
and smooth scrolls to the id using scrollIntoView
Note: onExitComplete
is only effective if exitBeforeEnter
prop is true
.
// pages/_app.tsx
import { AppProps } from 'next/app';
import { useRouter } from 'next/router';
import { AnimatePresence } from 'framer-motion';
// The handler to smoothly scroll the element into view
const handExitComplete = (): void => {
if (typeof window !== 'undefined') {
// Get the hash from the url
const hashId = window.location.hash;
if (hashId) {
// Use the hash to find the first element with that id
const element = document.querySelector(hashId);
if (element) {
// Smooth scroll to that elment
element.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest',
});
}
}
}
};
const MyApp = ({ Component, pageProps }: AppProps): JSX.Element => {
const router = useRouter();
return (
<AnimatePresence exitBeforeEnter onExitComplete={handExitComplete}>
<Component {...pageProps} key={router.route} />
</AnimatePresence>
);
};
export default MyApp;
Live CodeSandbox here.
PS: For some reason the the window.location.hash
in the sandbox preview is always an empty string, breaking the hash navigation but opening the preview in a separate browser tab works like a charm.