Capítulo 20 de 24

Chapter 20: Framer Motion Page Transitions

Core Idea

This example animates route transitions by wrapping the router's Outlet in Framer Motion's AnimatePresence, keying it off the next route match so outgoing and incoming route components animate in and out. Each route component wraps its content in motion.div with shared transition presets.

Setup

  • Routing style: code-based, single file (src/main.tsx)
  • Key dependencies: @tanstack/react-router, framer-motion, redaxios
  • Structure: everything lives in one file: fetch helpers, shared mainTransitionProps/postTransitionProps objects, the root route component (which renders AnimatePresence around Outlet), and the posts/$postId routes whose components are wrapped in motion.div.

Code Example

export const mainTransitionProps = {
  initial: { y: -20, opacity: 0, position: 'absolute' },
  animate: { y: 0, opacity: 1, damping: 5 },
  exit: { y: 60, opacity: 0 },
  transition: { type: 'spring', stiffness: 150, damping: 10 },
} as const

const rootRoute = createRootRoute({
  component: () => {
    const matches = useMatches()
    const match = useMatch({ strict: false })
    const nextMatchIndex = matches.findIndex((d) => d.id === match.id) + 1
    const nextMatch = matches[nextMatchIndex]

    return (
      <>
        {/* nav links */}
        <AnimatePresence mode="wait">
          <Outlet key={nextMatch.id} />
        </AnimatePresence>
        <TanStackRouterDevtools position="bottom-right" />
      </>
    )
  },
})

const indexRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/',
  component: () => (
    <motion.div className="p-2" {...mainTransitionProps}>
      <h3>Welcome Home!</h3>
    </motion.div>
  ),
})
  • What it demonstrates: keying AnimatePresence's child (Outlet) by the id of the next route match, which is what makes AnimatePresence mode="wait" recognize a route change as an exit/enter transition rather than a prop update.

Key Takeaways

  1. AnimatePresence needs a stable, changing key on its direct child to detect enter/exit; deriving nextMatch.id from useMatches()/useMatch() is the trick that makes this work with the router's own match state.
  2. Each route component must render a motion.* element (not a plain div) for its portion of the transition, since AnimatePresence only animates its immediate motion children.
  3. Nested route layouts (like the posts layout wrapping $postId) can nest their own AnimatePresence/Outlet pair for independent inner transitions, as shown around the posts detail outlet.
  4. This pattern is orthogonal to data loading, in this example postsLayoutRoute still uses a plain loader: () => fetchPosts() with useLoaderData(), no React Query involved.

Connects To

  • basic-react-query (ch018): shows the loader/query-client pattern this example intentionally omits in favor of a simpler loader + useLoaderData().
  • motion-docs skill: for deeper reference on AnimatePresence, motion.div props, and transition types beyond what this example uses.