CSS 애니메이션만으로는 처리하기 어려운 경우가 있습니다. 컴포넌트가 DOM에서 제거될 때 exit 애니메이션을 실행하거나, 레이아웃이 변경될 때 위치 이동을 자연스럽게 보간하거나, 드래그와 스크롤에 반응하는 물리 기반 애니메이션을 구현하려면 JavaScript의 도움이 필요합니다.
Motion(구 Framer Motion)은 이런 요구를 선언적 API로 해결합니다. 시작 상태와 끝 상태를 props로 정의하면 나머지는 Motion이 처리합니다. React의 선언적 스타일과 잘 어울립니다.
npm install motion
motion.div, motion.span, motion.button 등 모든 HTML 요소에 대응하는 Motion 컴포넌트가 있습니다. 일반 HTML 요소처럼 사용하되, animate, initial, exit 같은 애니메이션 props를 추가로 지원합니다.
import { motion } from "motion/react";
// 기본 애니메이션
function FadeIn() {
return (
<motion.div
initial={{ opacity: 0, y: 20 }} // 시작 상태
animate={{ opacity: 1, y: 0 }} // 목표 상태
transition={{ duration: 0.4 }} // 타이밍
>
콘텐츠
</motion.div>
);
}
// 상태에 따른 애니메이션
function ExpandablePanel({ isOpen }: { isOpen: boolean }) {
return (
<motion.div
animate={{
height: isOpen ? "auto" : 0,
opacity: isOpen ? 1 : 0,
}}
transition={{ duration: 0.3, ease: "easeInOut" }}
style={{ overflow: "hidden" }}
>
패널 내용
</motion.div>
);
}
initial은 컴포넌트가 처음 마운트될 때의 상태, animate는 목표 상태입니다. Motion은 두 상태 사이를 자동으로 보간합니다.
React는 컴포넌트를 조건부로 렌더링할 때 즉시 DOM에서 제거합니다. exit 애니메이션이 실행될 틈이 없습니다. AnimatePresence는 자식 컴포넌트의 언마운트를 가로채고, exit 애니메이션이 완료된 후 DOM에서 제거합니다.
import { motion, AnimatePresence } from "motion/react";
function Notification({ show, message }: { show: boolean; message: string }) {
return (
<AnimatePresence>
{show && (
<motion.div
key="notification" // key 필수 — Motion이 요소를 추적함
initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 100 }} // 언마운트 시 실행
transition={{ duration: 0.3 }}
className="fixed top-4 right-4 bg-green-500 text-white p-4 rounded-lg"
>
{message}
</motion.div>
)}
</AnimatePresence>
);
}
mode prop으로 여러 자식이 전환될 때의 동작을 제어합니다.
// mode="wait" — 이전 요소 exit 완료 후 다음 요소 enter
// mode="sync" — 동시에 실행 (기본값)
// mode="popLayout" — 이전 요소를 레이아웃에서 즉시 제거
<AnimatePresence mode="wait">
{currentPage === "home" && <motion.div key="home" ...>홈</motion.div>}
{currentPage === "about" && <motion.div key="about" ...>소개</motion.div>}
</AnimatePresence>
Transition: 애니메이션의 지속 시간, 지연, 이징 함수, 물리 파라미터 등 타이밍을 제어하는 옵션 객체
transition prop으로 애니메이션이 어떻게 재생될지 제어합니다. Motion은 tween, spring, inertia 세 가지 애니메이션 타입을 지원합니다.
// tween — CSS transition과 유사, duration과 ease로 제어
<motion.div
animate={{ x: 100 }}
transition={{
type: "tween",
duration: 0.5,
ease: "easeOut", // "linear" | "easeIn" | "easeOut" | "easeInOut" | [x1, y1, x2, y2]
delay: 0.2,
}}
/>
// spring — 물리 기반 스프링 애니메이션, 자연스러운 움직임
<motion.div
animate={{ scale: 1.2 }}
transition={{
type: "spring",
stiffness: 300, // 스프링 강도 (높을수록 빠름)
damping: 20, // 감쇠 (낮을수록 통통 튐)
mass: 1, // 질량
}}
/>
// 속성별 다른 transition 적용
<motion.div
animate={{ opacity: 1, x: 0 }}
transition={{
opacity: { duration: 0.2 },
x: { type: "spring", stiffness: 400 },
}}
/>
Motion의 기본 애니메이션 타입은 spring입니다. 숫자 값에는 spring을, 색상과 불투명도에는 tween을 자동으로 선택합니다.
레이아웃 변경 애니메이션은 CSS만으로는 구현하기 까다롭습니다. 요소가 다른 위치로 이동하거나 크기가 바뀔 때 CSS transition은 절대 위치 변화를 추적하지 못합니다. Motion의 layout prop은 이를 해결합니다.
// 목록 정렬 애니메이션
function SortableList({ items }: { items: string[] }) {
return (
<ul>
{items.map((item) => (
<motion.li
key={item}
layout // 위치 변경 시 자동 애니메이션
layoutId={item} // 다른 트리 간 이동 추적
className="p-3 mb-2 bg-white rounded shadow"
>
{item}
</motion.li>
))}
</ul>
);
}
// 크기 변경 애니메이션
function Card({ isExpanded }: { isExpanded: boolean }) {
return (
<motion.div
layout // 높이 변경 시 자동 보간
className="bg-white rounded-lg p-4 shadow"
>
<h2>제목</h2>
{isExpanded && (
<motion.p layout="position"> {/* 위치만 추적 */}
확장된 내용
</motion.p>
)}
</motion.div>
);
}
layout 값으로 true(전체), "position"(위치만), "size"(크기만)를 선택할 수 있습니다.
layoutId를 사용하면 컴포넌트가 다른 위치에 마운트되어도 이전 위치에서 부드럽게 이동하는 공유 레이아웃 애니메이션을 구현할 수 있습니다.
// 탭 인디케이터 공유 레이아웃
function Tabs({ tabs, active, onChange }: TabsProps) {
return (
<div className="flex">
{tabs.map((tab) => (
<button key={tab} onClick={() => onChange(tab)} className="relative px-4 py-2">
{tab}
{active === tab && (
<motion.div
layoutId="tab-indicator" // 같은 layoutId끼리 공유 애니메이션
className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"
/>
)}
</button>
))}
</div>
);
}
Gesture: whileHover, whileTap, whileDrag 등 사용자 인터랙션에 반응하여 애니메이션을 실행하는 props
Motion은 마우스, 터치, 드래그 등의 제스처를 props로 선언합니다. 이벤트 핸들러를 직접 작성하지 않아도 됩니다.
// 호버와 클릭 애니메이션
<motion.button
whileHover={{ scale: 1.05, backgroundColor: "#2563eb" }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
className="px-6 py-3 bg-blue-600 text-white rounded-lg"
>
클릭
</motion.button>
// 드래그 가능한 요소
<motion.div
drag // x, y 방향 모두 드래그
dragConstraints={{ left: -100, right: 100, top: -50, bottom: 50 }}
dragElastic={0.1} // 경계 밖으로 나가는 탄성
whileDrag={{ scale: 1.1 }}
className="w-20 h-20 bg-blue-500 rounded-full cursor-grab"
/>
// 스크롤 연동 (useScroll + useTransform)
import { useScroll, useTransform } from "motion/react";
function ParallaxHeader() {
const { scrollY } = useScroll();
const y = useTransform(scrollY, [0, 300], [0, -150]);
const opacity = useTransform(scrollY, [0, 200], [1, 0]);
return (
<motion.div style={{ y, opacity }} className="fixed top-0 w-full h-64">
배경 이미지
</motion.div>
);
}
Variants는 애니메이션 상태를 이름으로 추상화하고, 복잡한 컴포넌트 트리에서 애니메이션을 조율합니다. 부모의 animate 변경이 자식에 자동으로 전파되어 순차적 애니메이션을 선언적으로 표현할 수 있습니다.
// Variant 정의
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1, // 자식 간 딜레이
delayChildren: 0.2, // 첫 자식 딜레이
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { type: "spring", stiffness: 300 },
},
};
function AnimatedList({ items }: { items: string[] }) {
return (
// 부모의 animate="visible" 이 자식에 전파됨
<motion.ul
variants={containerVariants}
initial="hidden"
animate="visible"
>
{items.map((item) => (
<motion.li
key={item}
variants={itemVariants} // animate prop 없어도 부모에서 전파받음
className="p-3 mb-2 bg-white rounded shadow"
>
{item}
</motion.li>
))}
</motion.ul>
);
}
Container: animate="visible"
|
| 전파 (staggerChildren: 0.1)
v
Item 1: hidden -> visible (0.2s 후 시작)
Item 2: hidden -> visible (0.3s 후 시작)
Item 3: hidden -> visible (0.4s 후 시작)
Variants는 useAnimation 훅과 결합하여 명령형으로 제어할 수도 있습니다.
import { useAnimation } from "motion/react";
function ControlledAnimation() {
const controls = useAnimation();
const handleClick = async () => {
await controls.start("highlight");
await controls.start("normal");
};
return (
<motion.div
animate={controls}
variants={{
highlight: { backgroundColor: "#fbbf24", scale: 1.05 },
normal: { backgroundColor: "#ffffff", scale: 1 },
}}
onClick={handleClick}
/>
);
}
// PageTransition.tsx
import { motion, AnimatePresence } from "motion/react";
import { useLocation } from "react-router-dom";
const pageVariants = {
initial: { opacity: 0, x: -20 },
enter: { opacity: 1, x: 0 },
exit: { opacity: 0, x: 20 },
};
function PageTransition({ children }: { children: React.ReactNode }) {
const location = useLocation();
return (
<AnimatePresence mode="wait">
<motion.div
key={location.pathname}
variants={pageVariants}
initial="initial"
animate="enter"
exit="exit"
transition={{ duration: 0.25 }}
>
{children}
</motion.div>
</AnimatePresence>
);
}
Motion은 React의 선언적 패러다임을 애니메이션 영역으로 확장합니다.
| 개념 | 역할 |
|---|---|
| Motion Component | motion.div 등 애니메이션 props를 가진 HTML 래퍼 |
| AnimatePresence | 언마운트 시 exit 애니메이션 실행 |
| Transition | 속도, 지연, 이징, 스프링 파라미터 제어 |
| Layout Animation | layout prop으로 크기/위치 변경 자동 보간 |
| Gesture | hover, tap, drag 인터랙션 선언적 처리 |
| Variants | 상태를 이름으로 정의하고 자식에 전파 |
CSS 애니메이션으로 처리하기 어려운 언마운트 효과, 레이아웃 변경 보간, 목록 재정렬 같은 시나리오에서 Motion이 특히 강점을 발휘합니다.