본문으로 건너뛰기

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>
)
}

일반적인 문제

애니메이션이 트리거되지 않음

문제: 라우트 애니메이션이 작동하지 않거나 끊겨 보입니다.

해결 방법:

  1. AnimatePresence에 올바른 key를 사용하는지 확인합니다.

    <AnimatePresence mode="wait">
    <motion.div key={router.state.location.pathname}>
    <Outlet />
    </motion.div>
    </AnimatePresence>
  2. 레이아웃 애니메이션을 올바르게 사용합니다.

    // ❌ This might cause layout shifts
    <motion.div animate={{ x: 100 }}>

    // ✅ Use layout for changing layouts
    <motion.div layout>

성능 문제

문제: 애니메이션으로 성능 문제나 버벅거림이 발생합니다.

해결 방법:

  1. 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' },
    }
  2. 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 미디어 쿼리를 존중합니다.
  • 애니메이션이 스크린 리더를 방해하지 않습니다.
  • 전환 중 포커스 관리가 작동합니다.
  • 애니메이션 뒤에 필수 콘텐츠가 숨겨지지 않습니다.

기술

  • 번들 크기에 미치는 영향이 허용 가능한 수준입니다.
  • 애니메이션 관련 콘솔 오류가 없습니다.
  • 느린 기기에서도 전환이 부드럽습니다.
  • 애니메이션 효과가 적절히 정리됩니다.