This guide covers setting up Framer Motion with TanStack Router for smooth route transitions and navigation animations.
Time Required: 30-45 minutes
Difficulty: Intermediate
Prerequisites: Existing TanStack Router project
npm install framer-motion
Ensure you're using compatible versions:
{
"dependencies": {
"@tanstack/react-router": "^1.0.0",
"framer-motion": "^11.0.0",
"react": "^18.0.0"
}
}
// src/components/animated-route.tsx
import { motion, type MotionProps, type Variants } from 'framer-motion'
import { ReactNode } from 'react'
interface AnimatedRouteProps extends MotionProps {
children: ReactNode
variant?: 'fade' | 'slide' | 'scale' | 'slideUp'
}
const routeVariants: Record<string, Variants> = {
fade: {
initial: { opacity: 0 },
in: { opacity: 1 },
out: { opacity: 0 },
},
slide: {
initial: { opacity: 0, x: -20 },
in: { opacity: 1, x: 0 },
out: { opacity: 0, x: 20 },
},
scale: {
initial: { opacity: 0, scale: 0.95 },
in: { opacity: 1, scale: 1 },
out: { opacity: 0, scale: 1.05 },
},
slideUp: {
initial: { opacity: 0, y: 20 },
in: { opacity: 1, y: 0 },
out: { opacity: 0, y: -20 },
},
}
const pageTransition = {
type: 'tween',
ease: 'anticipate',
duration: 0.3,
}
export function AnimatedRoute({
children,
variant = 'fade',
...motionProps
}: AnimatedRouteProps) {
return (
<motion.div
initial="initial"
animate="in"
exit="out"
variants={routeVariants[variant]}
transition={pageTransition}
{...motionProps}
>
{children}
</motion.div>
)
}
// src/components/route-animation-container.tsx
import { useRouter } from '@tanstack/react-router'
import { AnimatePresence } from 'framer-motion'
import { ReactNode } from 'react'
interface RouteAnimationContainerProps {
children: ReactNode
}
export function RouteAnimationContainer({
children,
}: RouteAnimationContainerProps) {
const router = useRouter()
return (
<AnimatePresence mode="wait" initial={false}>
<div key={router.state.location.pathname}>{children}</div>
</AnimatePresence>
)
}
// src/routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
import { RouteAnimationContainer } from '@/components/route-animation-container'
export const Route = createRootRoute({
component: () => (
<>
<RouteAnimationContainer>
<Outlet />
</RouteAnimationContainer>
<TanStackRouterDevtools />
</>
),
})
// src/routes/posts/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { motion } from 'framer-motion'
import { AnimatedRoute } from '@/components/animated-route'
export const Route = createFileRoute('/posts/')({
component: PostsPage,
})
function PostsPage() {
return (
<AnimatedRoute variant="slide">
<div className="container mx-auto p-4">
<motion.h1
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="text-3xl font-bold mb-6"
>
Posts
</motion.h1>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="grid gap-4"
>
{/* Post cards with staggered animations */}
{posts.map((post, index) => (
<motion.div
key={post.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 + index * 0.1 }}
className="border rounded-lg p-4"
>
<h2 className="text-xl font-semibold">{post.title}</h2>
<p className="text-gray-600">{post.excerpt}</p>
</motion.div>
))}
</motion.div>
</div>
</AnimatedRoute>
)
}
// src/components/navigation/animated-tabs.tsx
import { Link, useMatchRoute } from '@tanstack/react-router'
import { motion } from 'framer-motion'
interface TabItem {
to: string
label: string
exact?: boolean
}
interface AnimatedTabsProps {
items: TabItem[]
className?: string
}
export function AnimatedTabs({ items, className }: AnimatedTabsProps) {
const matchRoute = useMatchRoute()
return (
<nav className={`flex space-x-1 p-2 bg-gray-100 rounded-lg ${className}`}>
{items.map((item) => {
const isActive = matchRoute({ to: item.to, fuzzy: !item.exact })
return (
<Link
key={item.to}
to={item.to}
className={`relative px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive ? 'text-blue-600' : 'text-gray-600 hover:text-gray-900'
}`}
>
{isActive && (
<motion.div
layoutId="activeTab"
className="absolute inset-0 bg-white rounded-md shadow-sm"
initial={false}
transition={{
type: 'spring',
bounce: 0.2,
duration: 0.6,
}}
/>
)}
<span className="relative z-10">{item.label}</span>
</Link>
)
})}
</nav>
)
}
// src/components/navigation/animated-mobile-menu.tsx
import { useState } from 'react'
import { Link } from '@tanstack/react-router'
import { motion, AnimatePresence } from 'framer-motion'
interface MenuItem {
to: string
label: string
icon?: React.ReactNode
}
interface AnimatedMobileMenuProps {
items: MenuItem[]
trigger: React.ReactNode
}
export function AnimatedMobileMenu({
items,
trigger,
}: AnimatedMobileMenuProps) {
const [isOpen, setIsOpen] = useState(false)
const menuVariants = {
closed: {
opacity: 0,
x: '-100%',
transition: {
type: 'spring',
stiffness: 400,
damping: 40,
},
},
open: {
opacity: 1,
x: 0,
transition: {
type: 'spring',
stiffness: 400,
damping: 40,
},
},
}
const itemVariants = {
closed: { opacity: 0, x: -20 },
open: { opacity: 1, x: 0 },
}
return (
<>
{/* Trigger */}
<button onClick={() => setIsOpen(!isOpen)}>{trigger}</button>
{/* Overlay */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black bg-opacity-50 z-40"
onClick={() => setIsOpen(false)}
/>
)}
</AnimatePresence>
{/* Menu */}
<motion.nav
initial="closed"
animate={isOpen ? 'open' : 'closed'}
variants={menuVariants}
className="fixed top-0 left-0 h-full w-64 bg-white shadow-lg z-50"
>
<div className="p-4">
<motion.div
initial="closed"
animate={isOpen ? 'open' : 'closed'}
transition={{ staggerChildren: 0.1, delayChildren: 0.2 }}
className="space-y-2"
>
{items.map((item) => (
<motion.div key={item.to} variants={itemVariants}>
<Link
to={item.to}
className="flex items-center space-x-3 p-3 rounded-lg hover:bg-gray-100 transition-colors"
onClick={() => setIsOpen(false)}
>
{item.icon}
<span className="text-gray-700">{item.label}</span>
</Link>
</motion.div>
))}
</motion.div>
</div>
</motion.nav>
</>
)
}
// src/components/navigation/animated-fab.tsx
import { Link } from '@tanstack/react-router'
import { motion } from 'framer-motion'
import { Plus } from 'lucide-react'
interface AnimatedFabProps {
to: string
label?: string
icon?: React.ReactNode
className?: string
}
export function AnimatedFab({
to,
label = 'Add',
icon = <Plus className="w-6 h-6" />,
className = '',
}: AnimatedFabProps) {
return (
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
className={`fixed bottom-6 right-6 ${className}`}
>
<Link
to={to}
className="flex items-center space-x-2 bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg hover:bg-blue-700 transition-colors"
>
<motion.div
initial={{ rotate: 0 }}
whileHover={{ rotate: 90 }}
transition={{ type: 'spring', stiffness: 300 }}
>
{icon}
</motion.div>
<span className="font-medium">{label}</span>
</Link>
</motion.div>
)
}
// src/components/animations/shared-element.tsx
import { motion } from 'framer-motion'
import { ReactNode } from 'react'
interface SharedElementProps {
layoutId: string
children: ReactNode
className?: string
}
export function SharedElement({
layoutId,
children,
className,
}: SharedElementProps) {
return (
<motion.div
layoutId={layoutId}
className={className}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
>
{children}
</motion.div>
)
}
// Usage in post list
function PostCard({ post }: { post: Post }) {
return (
<Link to="/posts/$postId" params={{ postId: post.id }}>
<SharedElement layoutId={`post-${post.id}`}>
<div className="border rounded-lg p-4">
<h2 className="text-xl font-semibold">{post.title}</h2>
<p className="text-gray-600">{post.excerpt}</p>
</div>
</SharedElement>
</Link>
)
}
// Usage in post detail
function PostDetail({ post }: { post: Post }) {
return (
<SharedElement layoutId={`post-${post.id}`}>
<div className="border rounded-lg p-6">
<h1 className="text-3xl font-bold">{post.title}</h1>
<div className="prose mt-4">{post.content}</div>
</div>
</SharedElement>
)
}
// src/components/animations/route-variants.tsx
import { motion } from 'framer-motion'
import { useRouter } from '@tanstack/react-router'
import { ReactNode } from 'react'
interface RouteVariantsProps {
children: ReactNode
}
export function RouteVariants({ children }: RouteVariantsProps) {
const router = useRouter()
const currentPath = router.state.location.pathname
// Different animations based on route depth
const getVariants = (path: string) => {
const depth = path.split('/').length - 1
if (depth === 1) {
// Top-level routes slide from right
return {
initial: { opacity: 0, x: 100 },
in: { opacity: 1, x: 0 },
out: { opacity: 0, x: -100 },
}
} else if (depth === 2) {
// Sub-routes slide up
return {
initial: { opacity: 0, y: 50 },
in: { opacity: 1, y: 0 },
out: { opacity: 0, y: -50 },
}
} else {
// Deep routes fade
return {
initial: { opacity: 0 },
in: { opacity: 1 },
out: { opacity: 0 },
}
}
}
return (
<motion.div
key={currentPath}
initial="initial"
animate="in"
exit="out"
variants={getVariants(currentPath)}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
>
{children}
</motion.div>
)
}
// src/components/animations/loading-animation.tsx
import { motion } from 'framer-motion'
export function LoadingAnimation() {
return (
<div className="flex items-center justify-center min-h-screen">
<motion.div
className="flex space-x-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{[0, 1, 2].map((index) => (
<motion.div
key={index}
className="w-3 h-3 bg-blue-600 rounded-full"
animate={{
scale: [1, 1.2, 1],
opacity: [1, 0.8, 1],
}}
transition={{
duration: 1,
repeat: Infinity,
delay: index * 0.2,
}}
/>
))}
</motion.div>
</div>
)
}
// Usage in routes with loading states
export const Route = createFileRoute('/posts/$postId')({
component: PostPage,
pendingComponent: LoadingAnimation,
})
// src/routes/posts/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { motion } from 'framer-motion'
import { AnimatedRoute } from '@/components/animated-route'
import { AnimatedTabs } from '@/components/navigation/animated-tabs'
import { AnimatedFab } from '@/components/navigation/animated-fab'
import { SharedElement } from '@/components/animations/shared-element'
export const Route = createFileRoute('/posts/')({
component: PostsPage,
})
const tabItems = [
{ to: '/posts', label: 'All Posts', exact: true },
{ to: '/posts/published', label: 'Published' },
{ to: '/posts/drafts', label: 'Drafts' },
]
function PostsPage() {
const posts = [
{ id: '1', title: 'First Post', excerpt: 'This is the first post' },
{ id: '2', title: 'Second Post', excerpt: 'This is the second post' },
]
return (
<AnimatedRoute variant="slide">
<div className="container mx-auto p-4">
{/* Animated header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="mb-6"
>
<h1 className="text-3xl font-bold mb-4">Posts</h1>
<AnimatedTabs items={tabItems} />
</motion.div>
{/* Animated post grid */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="grid gap-4"
>
{posts.map((post, index) => (
<motion.div
key={post.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 + index * 0.1 }}
whileHover={{ y: -2 }}
className="cursor-pointer"
>
<SharedElement layoutId={`post-${post.id}`}>
<div className="border rounded-lg p-4 hover:shadow-lg transition-shadow">
<h2 className="text-xl font-semibold">{post.title}</h2>
<p className="text-gray-600">{post.excerpt}</p>
</div>
</SharedElement>
</motion.div>
))}
</motion.div>
{/* Floating action button */}
<AnimatedFab to="/posts/new" label="New Post" />
</div>
</AnimatedRoute>
)
}
Problem: Route animations don't work or appear choppy.
Solutions:
Ensure proper key for AnimatePresence:
<AnimatePresence mode="wait">
<motion.div key={router.state.location.pathname}>
<Outlet />
</motion.div>
</AnimatePresence>
Use layout animations correctly:
// ❌ This might cause layout shifts
<motion.div animate={{ x: 100 }}>
// ✅ Use layout for changing layouts
<motion.div layout>
Problem: Animations cause performance problems or jank.
Solutions:
Prefer transform and opacity animations:
// ✅ GPU-accelerated properties
const variants = {
initial: { opacity: 0, scale: 0.95 },
in: { opacity: 1, scale: 1 },
}
// ❌ Avoid animating layout properties
const badVariants = {
initial: { width: 0, height: 0 },
in: { width: 'auto', height: 'auto' },
}
Use will-change CSS property sparingly:
<motion.div style={{ willChange: 'transform' }} animate={{ x: 100 }} />
Problem: Shared element transitions cause layout shifts.
Solution: Use layout animations and proper positioning:
<motion.div
layout
layoutId="shared-element"
style={{ position: 'relative' }}
transition={{
layout: { duration: 0.3 },
}}
>
{children}
</motion.div>
Before deploying your animated TanStack Router app: