이 글은 누구를 위한 것인가
- CSS 트랜지션으로 구현하기 어려운 복잡한 애니메이션을 만들려는 팀
- AnimatePresence로 라우트 전환 효과를 구현하려는 개발자
- 스크롤에 연동된 패럴랙스 효과를 구현하고 싶은 프론트엔드 개발자
들어가며
CSS 애니메이션으로는 마운트/언마운트 전환 효과를 만들기 매우 어렵다. Framer Motion은 이를 AnimatePresence 하나로 해결한다. variants로 복잡한 상태 전환을 선언적으로 표현하고, useScroll로 스크롤 기반 애니메이션을 간단히 만든다.
이 글은 bluefoxdev.kr의 Framer Motion 실전 가이드 를 참고하여 작성했습니다.
1. Framer Motion 핵심 개념
[핵심 컴포넌트/훅]
motion.div, motion.button 등:
animate: 목표 상태
initial: 초기 상태
exit: 제거 시 상태 (AnimatePresence 필요)
transition: 애니메이션 설정
variants:
여러 상태를 이름으로 관리
부모 → 자식 전파 (stagger 효과)
AnimatePresence:
언마운트 시 exit 애니메이션 실행
조건부 렌더링, 라우트 전환에 활용
useScroll:
scrollY, scrollX: 픽셀 단위 스크롤
scrollYProgress: 0~1 정규화
useTransform:
스크롤 값 → 다른 값으로 변환
예: scrollYProgress(0→1) → opacity(1→0)
useSpring:
스프링 물리 효과로 부드러운 팔로우
LayoutAnimation:
layout prop으로 레이아웃 변경 자동 애니메이션
AnimateSharedLayout 대체
2. Framer Motion 고급 구현
import { motion, AnimatePresence, useScroll, useTransform, useSpring } from 'framer-motion';
import { useState, useRef } from 'react';
// 1. 기본 variants - 상태 전환
const cardVariants = {
hidden: {
opacity: 0,
y: 40,
scale: 0.95,
},
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94], // cubic-bezier
},
},
exit: {
opacity: 0,
y: -20,
transition: { duration: 0.2 },
},
};
// 2. Stagger - 자식 요소 순차 등장
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1, // 자식 요소 0.1초 간격
delayChildren: 0.2,
},
},
};
function ProductGrid({ products }: { products: Product[] }) {
return (
<motion.ul
variants={containerVariants}
initial="hidden"
animate="visible"
>
{products.map(product => (
<motion.li key={product.id} variants={cardVariants}>
<ProductCard product={product} />
</motion.li>
))}
</motion.ul>
);
}
// 3. AnimatePresence - 마운트/언마운트 전환
function Modal({ isOpen, onClose, children }: ModalProps) {
return (
<AnimatePresence>
{isOpen && (
<>
{/* 배경 오버레이 */}
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-black/50"
/>
{/* 모달 */}
<motion.div
key="modal"
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-xl p-6"
>
{children}
</motion.div>
</>
)}
</AnimatePresence>
);
}
// 4. 탭 전환 with AnimatePresence
function TabPanel({ activeTab }: { activeTab: string }) {
return (
<div className="relative overflow-hidden">
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.2 }}
>
<TabContent tab={activeTab} />
</motion.div>
</AnimatePresence>
</div>
);
}
// 5. 스크롤 연동 애니메이션
function ParallaxHero() {
const { scrollYProgress } = useScroll();
const y = useTransform(scrollYProgress, [0, 0.5], [0, -150]);
const opacity = useTransform(scrollYProgress, [0, 0.3], [1, 0]);
const scale = useTransform(scrollYProgress, [0, 0.3], [1, 1.1]);
// 스프링으로 부드럽게
const smoothY = useSpring(y, { damping: 20, stiffness: 100 });
return (
<div className="h-screen relative overflow-hidden">
<motion.div
style={{ y: smoothY, scale, opacity }}
className="absolute inset-0"
>
<img src="/hero.jpg" alt="Hero" className="w-full h-full object-cover" />
</motion.div>
<div className="relative z-10 flex items-center justify-center h-full">
<motion.h1
style={{ opacity }}
className="text-6xl font-bold text-white"
>
안녕하세요
</motion.h1>
</div>
</div>
);
}
// 섹션 스크롤 진입 애니메이션
function FadeInSection({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ['start end', 'end start'],
});
const opacity = useTransform(scrollYProgress, [0, 0.2, 0.8, 1], [0, 1, 1, 0]);
const y = useTransform(scrollYProgress, [0, 0.2], [60, 0]);
return (
<motion.div ref={ref} style={{ opacity, y }}>
{children}
</motion.div>
);
}
// 6. Layout 애니메이션 - 레이아웃 변경 자동 전환
function ExpandableCard({ id, title, content }: CardProps) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<motion.div
layout // 레이아웃 변경 자동 애니메이션
onClick={() => setIsExpanded(!isExpanded)}
className="bg-white rounded-lg p-4 cursor-pointer shadow"
>
<motion.h3 layout="position">{title}</motion.h3>
<AnimatePresence>
{isExpanded && (
<motion.p
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
{content}
</motion.p>
)}
</AnimatePresence>
</motion.div>
);
}
// 7. 제스처 - 드래그, 탭, 호버
function DraggableCard({ onDismiss }: { onDismiss: () => void }) {
return (
<motion.div
drag="x"
dragConstraints={{ left: -100, right: 100 }}
dragElastic={0.2}
onDragEnd={(_, info) => {
if (Math.abs(info.offset.x) > 80) onDismiss();
}}
whileDrag={{ scale: 1.05, cursor: 'grabbing' }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className="bg-white rounded-xl p-6 shadow-lg cursor-grab"
>
<p>좌우로 드래그해서 닫기</p>
</motion.div>
);
}
interface Product { id: string; name: string }
interface ModalProps { isOpen: boolean; onClose: () => void; children: React.ReactNode }
interface CardProps { id: string; title: string; content: string }
function ProductCard({ product }: { product: Product }) { return null; }
function TabContent({ tab }: { tab: string }) { return null; }
마무리
Framer Motion의 variants와 AnimatePresence만 잘 써도 대부분의 UI 애니메이션 요구사항을 충족할 수 있다. useTransform과 useScroll은 성능 최적화된 스크롤 애니메이션을 만든다. 성능 주의사항: transform과 opacity만 애니메이션하면 GPU 가속을 받는다. width, height, margin 등 레이아웃 트리거 속성은 layout prop으로 위임하고 직접 애니메이션하지 않는 것이 좋다.