Framer Motion을 TanStack Router와 통합하는 방법
이 가이드에서는 부드러운 라우트 전환과 탐색 애니메이션을 위해 Framer Motion을 TanStack Router와 설정하는 방법을 설명합니다.
빠른 시작
소요 시간: 30-45분
난이도: 중급
사전 요구 사항: 기존 TanStack Router 프로젝트
수행할 작업
- Framer Motion을 TanStack Router와 함께 설치하고 설정합니다.
- 부드러운 라우트 전환 애니메이션을 생성합니다.
- 애니메이션이 적용된 탐색 컴포넌트를 구현합니다.
- 레이아웃 애니메이션과 공유 요소를 설정합니다.
- 복잡한 애니메이션 시퀀스를 처리합니다.
설치 및 설정
1단계: Framer Motion 설치
npm install framer-motion
2단계: 버전 호환성 확인
호환되는 버전을 사용하는지 확인합니다.
{
"dependencies": {
"@tanstack/react-router": "^1.0.0",
"framer-motion": "^11.0.0",
"react": "^18.0.0"
}
}
라우트 전환 애니메이션
1단계: 애니메이션 라우트 래퍼 생성
// 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>
)
}
2단계: 라우트 애니메이션 컨테이너 설정
// 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>
)
}
3단계: 애니메이션을 위해 루트 라우트 업데이트
// 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 />
</>
),
})
4단계: 라우트에서 애니메이션 사용
// 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>
)
}
애니메이션 탐색 컴포넌트
1단계: 애니메이션 탭 탐색 생성
// 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>
)
}
2단계: 슬라이딩 모바일 메뉴 생성
// 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>
</>
)
}
3단계: 애니메이션이 적용된 플로팅 액션 버튼 생성
// 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>
)
}
고급 애니메이션 패턴
1단계: 공유 요소 전환
// 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>
)
}
2단계: 라우트 기반 애니메이션 변형
// 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>
)
}
3단계: 로딩 애니메이션
// 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>
)
}
일반적인 문제
애니메이션이 트리거되지 않음
문제: 라우트 애니메이션이 작동하지 않거나 끊겨 보입니다.
해결 방법:
-
AnimatePresence에 올바른 key를 사용하는지 확인합니다.
<AnimatePresence mode="wait">
<motion.div key={router.state.location.pathname}>
<Outlet />
</motion.div>
</AnimatePresence> -
레이아웃 애니메이션을 올바르게 사용합니다.
// ❌ This might cause layout shifts
<motion.div animate={{ x: 100 }}>
// ✅ Use layout for changing layouts
<motion.div layout>
성능 문제
문제: 애니메이션으로 성능 문제나 버벅거림이 발생합니다.
해결 방법:
-
transform 및 opacity 애니메이션을 우선합니다.
// ✅ 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' },
} -
will-change CSS 속성을 신중하게 사용합니다.
<motion.div style={{ willChange: 'transform' }} animate={{ x: 100 }} />
레이아웃 이동 문제
문제: 공유 요소 전환으로 레이아웃 이동이 발생합니다.
해결 방법: 레이아웃 애니메이션과 올바른 위치 지정을 사용합니다.
<motion.div
layout
layoutId="shared-element"
style={{ position: 'relative' }}
transition={{
layout: { duration: 0.3 },
}}
>
{children}
</motion.div>
프로덕션 체크리스트
애니메이션이 적용된 TanStack Router 앱을 배포하기 전에 다음을 확인합니다.
성능
- 애니메이션이 GPU 가속 속성(transform, opacity)을 사용합니다.
- 불필요한 will-change CSS 속성이 없습니다.
- 복잡한 애니메이션이 사용자 설정에 따라 조건부로 실행됩니다.
- 대상 기기에서 프레임 속도가 60fps 이상으로 유지됩니다.
사용자 경험
- 애니메이션이 사용자의 모션 설정을 존중합니다.
- 로딩 상태에 적절한 애니메이션이 적용됩니다.
- 탐색이 반응성이 좋고 부드럽게 느껴집니다.
- 애니메이션이 콘텐츠를 방해하지 않고 향상합니다.
접근성
- prefers-reduced-motion 미디어 쿼리를 존중합니다.
- 애니메이션이 스크린 리더를 방해하지 않습니다.
- 전환 중 포커스 관리가 작동합니다.
- 애니메이션 뒤에 필수 콘텐츠가 숨겨지지 않습니다.
기술
- 번들 크기에 미치는 영향이 허용 가능한 수준입니다.
- 애니메이션 관련 콘솔 오류가 없습니다.
- 느린 기기에서도 전환이 부드럽습니다.
- 애니메이션 효과가 적절히 정리됩니다.
관련 리소스
- Framer Motion 문서 - 애니메이션 라이브러리 전체 가이드
- React Transition Group 마이그레이션 - 다른 애니메이션 라이브러리에서 마이그레이션하는 가이드
- 애니메이션 성능 - 애니메이션을 위한 웹 성능 가이드