Search code examples
javascriptreactjsreact-router-domframer-motion

Exit Animations with AnimatePresence (Framer Motion) and createBrowserRouter & RouterProvider (React Router DOM v6.4.1)


React Router v6.4 has introduced a new routing API with createBrowserRouter and RouterProvider.

In older versions of React Router, it was possible to wrap around the routes defined with React Router to enable page transitions. When provided with values for location and key, Framer Motion can detect if a child gets added or removed from the component tree to display a start and exit transition animation.

Before React Router v6.4:

<AnimatePresence exitBeforeEnter>
  <Routes location={location} key={location.pathname}>
    <Route path="/" element={<HomePage />} />
  </Routes>
</AnimatePresence>

While the animation on page load still works with the new routing API, I couldn't find a way to get exit animations to work again.

React Router v6.4.1

...
const router = createBrowserRouter([
    {
      path: '/',
      element: (
         <HomePage />
      ),
    },
]);
...


<AnimatePresence mode="wait">
  <RouterProvider router={router} />
</AnimatePresence>

Here is an example of a complete React application using an older version of React Router and Framer Motion.


Solution

  • For an application with no nested routes, you can solve this with useOutlet.

    Instead of your AnimatePresence wrapping the RouterProvider, it should be inside a layout component that wraps your other routes.

    In your router declaration, create a top level layout component e.g RootContainer. Rest of the routes can be declared using the children prop, where your home page is the index route:

    const router = createBrowserRouter([
        {
          element: (
             <RootContainer />
          ),
          children: [
              {
                 index: true,
                 element: <HomePage />,
              },
              ...
          ]
        },
    ]);
    

    Inside RootContainer you want to render an Outlet, which is a component that will the UI matching the current route.

    To enable exit animations, we need to use a "frozen" outlet. This means that every time the location changes, the outlet reference stays the same. We will essentially control the remount logic with a key, similar to how it was done in the V5 implementation with Routes. More context on the solution and why such functionality is not part of react-router-dom core: https://github.com/remix-run/react-router/discussions/8008#discussioncomment-1280897

    AnimatedOutlet:

    const AnimatedOutlet: React.FC = () => {
        const o = useOutlet();
        const [outlet] = useState(o);
    
        return <>{outlet}</>;
    };
    

    In your RootContainer, wrap the Outlet with a motion component, and remember to give it the current pathname as its key:

    <AnimatePresence mode="popLayout">
         <motion.div
            key={location.pathname}
            initial={{ opacity: 0, x: 50 }}
            animate={{ opacity: 1, x: 0 }}
            exit={{ opacity: 0, x: 50 }}
         >
             <AnimatedOutlet />
         </motion.div>
    </AnimatePresence>
    

    Unfortunately, for an application with many nested routes this solution is not entirely sufficient. In V5 you could prevent the parent routes from rerendering during child route navigation by using location "parts":

    const location = useLocation()
    const locationArr = location.pathname?.split('/') ?? [];
    
    return 
       <Routes location={location} key={locationArr[1]}
         ...
            <Routes location={location} key={locationArr[2]}
               ...
            </Routes>
       </Routes>
    

    I don't yet know whether this is possible with useOutlet.