junyeokk
Blog
React·2024. 11. 22

Framer Motion

React에서 애니메이션을 구현하는 방법은 여러 가지가 있다. CSS transition이나 @keyframes를 직접 쓰는 방법, react-transition-group 같은 라이브러리를 쓰는 방법, 그리고 Web Animations API를 사용하는 방법 등이 있다. 그런데 이 방법들은 React의 선언적 패러다임과 잘 맞지 않거나, 복잡한 애니메이션을 다루기 어렵다는 공통적인 한계가 있다.

CSS 애니메이션은 간단한 hover 효과나 트랜지션에는 충분하다. 하지만 컴포넌트가 마운트/언마운트될 때의 애니메이션, 레이아웃이 변경될 때의 자연스러운 전환, 제스처 기반 인터랙션 같은 것들을 CSS만으로 처리하려면 코드가 급격히 복잡해진다. 특히 React에서 컴포넌트가 DOM에서 제거될 때 exit 애니메이션을 넣으려면, 실제 DOM 제거 시점을 지연시키는 로직을 직접 구현해야 한다.

Framer Motion은 이런 문제들을 React의 선언적 방식으로 해결한다. motion 컴포넌트와 props만으로 애니메이션을 정의하고, 마운트/언마운트 애니메이션, 레이아웃 애니메이션, 제스처 등을 자연스럽게 처리할 수 있다.


기본 개념: motion 컴포넌트

Framer Motion의 핵심은 motion 컴포넌트다. HTML 요소나 SVG 요소 앞에 motion.을 붙이면 애니메이션이 가능한 컴포넌트가 된다.

tsx
import { motion } from "framer-motion";

function FadeInBox() {
  return (
    <motion.div
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      transition={{ duration: 0.5 }}
    >
      안녕하세요
    </motion.div>
  );
}

motion.div는 일반 div와 동일한 기능을 가지면서, 추가로 애니메이션 관련 props를 받는다.

  • initial: 컴포넌트가 처음 렌더링될 때의 상태
  • animate: 최종적으로 도달할 상태
  • transition: 어떻게 전환할지 (duration, ease, delay 등)

motion.div 외에도 motion.span, motion.button, motion.svg, motion.path 등 거의 모든 HTML/SVG 요소를 사용할 수 있다.


애니메이션 가능한 속성들

Framer Motion은 CSS 속성 중 숫자 값을 가지는 대부분의 속성을 애니메이션할 수 있다. 자주 사용하는 것들을 정리하면:

속성설명예시
opacity투명도{ opacity: 0 }
x, y이동 (translateX/Y){ x: 100, y: -50 }
scale크기{ scale: 1.2 }
rotate회전 (degree){ rotate: 90 }
width, height크기 변경{ width: "200px" }
backgroundColor배경색{ backgroundColor: "#ff0000" }
borderRadius모서리 둥글기{ borderRadius: "50%" }
skew, skewX, skewY기울이기{ skewX: 10 }

x, y, scale, rotate는 CSS transform에 매핑된다. Framer Motion이 내부적으로 transform 문자열을 생성해주기 때문에 개별 속성으로 편하게 제어할 수 있다.


transition 옵션

transition prop으로 애니메이션의 타이밍과 물리적 특성을 제어한다.

tween (기본)

CSS 트랜지션처럼 시작 값에서 끝 값까지 보간한다.

tsx
<motion.div
  animate={{ x: 100 }}
  transition={{
    type: "tween",
    duration: 0.3,
    ease: "easeInOut"
  }}
/>

ease 옵션은 CSS와 동일한 값들("linear", "easeIn", "easeOut", "easeInOut")을 지원하고, 커스텀 베지어 커브([0.42, 0, 0.58, 1])도 가능하다.

spring (스프링 물리)

실제 물리적인 스프링 모션을 시뮬레이션한다. duration 대신 물리 파라미터로 제어한다.

tsx
<motion.div
  animate={{ x: 100 }}
  transition={{
    type: "spring",
    stiffness: 300,  // 스프링 강도 (높을수록 빠르게 도달)
    damping: 20,     // 감쇠 (높을수록 진동 줄어듦)
    mass: 1          // 질량 (높을수록 느리게 반응)
  }}
/>

스프링 애니메이션은 tween보다 자연스러운 느낌을 준다. UI 요소의 이동이나 크기 변경에 특히 잘 어울린다. stiffness를 높이면 딱딱하게 튕기고, damping을 낮추면 오래 진동한다.

파라미터기본값효과
stiffness100높을수록 빠르게 도달
damping10높을수록 진동 적음
mass1높을수록 무겁게 반응
bounce-0~1 사이, 튕김 정도
restDelta0.01이 값 이하로 움직이면 멈춤

속성별 개별 transition

속성마다 다른 트랜지션을 적용할 수도 있다.

tsx
<motion.div
  animate={{ x: 100, opacity: 1 }}
  transition={{
    x: { type: "spring", stiffness: 300 },
    opacity: { duration: 0.2, ease: "easeOut" }
  }}
/>

variants: 애니메이션 상태 관리

여러 요소에 같은 애니메이션 패턴을 적용하거나, 부모-자식 간에 애니메이션을 연동하려면 variants를 사용한다.

tsx
const containerVariants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.1  // 자식 요소들이 0.1초 간격으로 순차 등장
    }
  }
};

const itemVariants = {
  hidden: { opacity: 0, y: 20 },
  visible: { opacity: 1, y: 0 }
};

function List() {
  return (
    <motion.ul variants={containerVariants} initial="hidden" animate="visible">
      {items.map((item) => (
        <motion.li key={item.id} variants={itemVariants}>
          {item.text}
        </motion.li>
      ))}
    </motion.ul>
  );
}

핵심은 부모에서 initialanimate에 variant 이름(문자열)을 지정하면, 자식 motion 컴포넌트들이 자동으로 같은 variant 이름을 상속받는다는 것이다. 자식에게 일일이 initial, animate를 쓸 필요가 없다.

staggerChildren

staggerChildren은 자식 요소들의 애니메이션 시작 시간을 순차적으로 지연시킨다. 리스트 아이템이 하나씩 차례로 나타나는 효과를 만들 때 유용하다. 값은 초 단위이며, staggerDirection: -1로 설정하면 역순으로 등장한다.

tsx
const container = {
  visible: {
    transition: {
      staggerChildren: 0.05,
      delayChildren: 0.2,     // 전체 자식 애니메이션 시작 전 딜레이
      staggerDirection: 1     // 1이면 정방향, -1이면 역방향
    }
  }
};

AnimatePresence: 언마운트 애니메이션

React에서 컴포넌트가 조건부 렌더링으로 DOM에서 제거될 때, 기본적으로 즉시 사라진다. AnimatePresence는 자식 컴포넌트가 제거되기 전에 exit 애니메이션을 실행하고, 애니메이션이 완료된 후에 실제로 DOM에서 제거한다.

tsx
import { AnimatePresence, motion } from "framer-motion";

function Notification({ isVisible, message }: Props) {
  return (
    <AnimatePresence>
      {isVisible && (
        <motion.div
          key="notification"
          initial={{ opacity: 0, y: -50 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: -50 }}
          transition={{ duration: 0.3 }}
        >
          {message}
        </motion.div>
      )}
    </AnimatePresence>
  );
}

AnimatePresence 안에 있는 motion 컴포넌트에 exit prop을 설정하면, 해당 컴포넌트가 React 트리에서 제거될 때 exit에 정의된 애니메이션이 먼저 실행된다.

주의사항

  • AnimatePresence의 직접 자식에는 반드시 고유한 key가 필요하다. React가 어떤 컴포넌트가 추가/제거되었는지 판단하기 위해서다.
  • 리스트에서 사용할 때는 각 아이템의 key가 안정적이어야 한다. 인덱스를 key로 쓰면 exit 애니메이션이 제대로 동작하지 않는다.

mode 옵션

여러 자식이 교체될 때의 동작을 제어한다.

tsx
<AnimatePresence mode="wait">
  {currentTab === "home" && <HomeTab key="home" />}
  {currentTab === "about" && <AboutTab key="about" />}
</AnimatePresence>
mode동작
"sync" (기본)exit와 enter가 동시에 실행
"wait"이전 요소의 exit 완료 후 다음 요소 enter
"popLayout"exit 중인 요소를 레이아웃에서 즉시 제거

탭 전환이나 페이지 트랜지션에서는 mode="wait"이 자연스럽다. 이전 컨텐츠가 완전히 사라진 후 새 컨텐츠가 나타나기 때문이다.


layout 애니메이션

layout prop을 추가하면 요소의 레이아웃(위치, 크기)이 변경될 때 자동으로 애니메이션이 적용된다. CSS의 레이아웃 변경은 원래 즉시 반영되지만, layout을 쓰면 이전 위치에서 새 위치로 부드럽게 이동한다.

tsx
function SortableGrid({ items }: { items: Item[] }) {
  return (
    <div className="grid grid-cols-4 gap-4">
      <AnimatePresence initial={false}>
        {items.map((item, index) => {
          return (
            <motion.div
              key={item.id}
              layout
              initial={{ opacity: 0, scale: 0.8 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0, scale: 0.8 }}
              transition={{
                layout: { type: "spring", stiffness: 300, damping: 30 },
                opacity: { duration: 0.2 }
              }}
            >
              <Card data={item} />
            </motion.div>
          );
        })}
      </AnimatePresence>
    </div>
  );
}

이 코드에서 items 배열의 순서가 변경되면(예: 정렬, 필터링), 각 카드가 이전 위치에서 새 위치로 부드럽게 슬라이드한다. 새로 추가된 아이템은 scale 애니메이션으로 나타나고, 제거된 아이템은 fade out된다.

layout 동작 원리

layout 애니메이션은 내부적으로 FLIP(First, Last, Invert, Play) 기법을 사용한다.

  1. First: 레이아웃 변경 전 위치를 기록
  2. Last: 레이아웃 변경 후 위치를 기록
  3. Invert: transform으로 변경 전 위치에 돌려놓음
  4. Play: transform을 제거하면서 애니메이션 실행

이 방식의 장점은 실제 레이아웃 속성(top, left, width 등)을 애니메이션하는 것이 아니라 transform을 사용하기 때문에 reflow를 유발하지 않는다는 것이다. GPU 가속을 받으므로 성능이 좋다.

layoutId: 공유 레이아웃 트랜지션

같은 layoutId를 가진 두 motion 컴포넌트 사이에서 자연스러운 전환 애니메이션이 가능하다. 한 컴포넌트가 사라지고 다른 컴포넌트가 나타날 때, 마치 같은 요소가 이동하는 것처럼 보인다.

tsx
function ExpandableCard({ selectedId, item, onClick }: Props) {
  return (
    <>
      <motion.div layoutId={`card-${item.id}`} onClick={() => onClick(item.id)}>
        <motion.h2 layoutId={`title-${item.id}`}>{item.title}</motion.h2>
      </motion.div>

      <AnimatePresence>
        {selectedId === item.id && (
          <motion.div layoutId={`card-${item.id}`} className="expanded">
            <motion.h2 layoutId={`title-${item.id}`}>{item.title}</motion.h2>
            <p>{item.description}</p>
          </motion.div>
        )}
      </AnimatePresence>
    </>
  );
}

카드를 클릭하면 작은 카드가 확대된 카드로 자연스럽게 트랜지션한다. layoutId가 같은 요소끼리 위치와 크기가 보간되기 때문이다.


제스처

Framer Motion은 마우스/터치 제스처를 위한 편리한 props를 제공한다.

tsx
<motion.button
  whileHover={{ scale: 1.05, backgroundColor: "#f0f0f0" }}
  whileTap={{ scale: 0.95 }}
  whileFocus={{ boxShadow: "0 0 0 3px rgba(66, 153, 225, 0.6)" }}
>
  클릭하세요
</motion.button>
prop트리거 시점
whileHover마우스 호버 중
whileTap클릭/터치 중
whileFocus포커스 중
whileDrag드래그 중
whileInView뷰포트 진입 시

whileInView는 IntersectionObserver를 내부적으로 사용한다. 스크롤 애니메이션을 간단하게 구현할 수 있다.

tsx
<motion.div
  initial={{ opacity: 0, y: 50 }}
  whileInView={{ opacity: 1, y: 0 }}
  viewport={{ once: true, amount: 0.3 }}
  transition={{ duration: 0.6 }}
>
  스크롤하면 나타남
</motion.div>

viewport.oncetrue로 설정하면 한 번만 애니메이션이 실행되고, amount는 요소가 얼마나 보여야 트리거되는지를 결정한다(0~1).

드래그

tsx
<motion.div
  drag            // 드래그 활성화 ("x" 또는 "y"로 축 제한 가능)
  dragConstraints={{ left: -100, right: 100, top: -100, bottom: 100 }}
  dragElastic={0.2}     // 제한 영역 밖으로 얼마나 탄성 있게 나갈 수 있는지
  dragSnapToOrigin      // 놓으면 원래 위치로 돌아감
  whileDrag={{ scale: 1.1 }}
>
  드래그 가능한 요소
</motion.div>

dragConstraints는 드래그 가능한 영역을 제한한다. ref로 다른 요소를 지정할 수도 있어서, 특정 컨테이너 안에서만 드래그되도록 할 수 있다.


useAnimation: 프로그래밍 방식 제어

선언적 방식 대신 명령적으로 애니메이션을 제어해야 할 때 useAnimation 훅을 사용한다.

tsx
import { motion, useAnimation } from "framer-motion";

function SequentialAnimation() {
  const controls = useAnimation();

  async function handleClick() {
    await controls.start({ x: 100, transition: { duration: 0.5 } });
    await controls.start({ y: 100, transition: { duration: 0.3 } });
    await controls.start({ rotate: 180, transition: { duration: 0.4 } });
  }

  return (
    <motion.div animate={controls} onClick={handleClick}>
      순차 애니메이션
    </motion.div>
  );
}

controls.start()는 Promise를 반환하기 때문에 await으로 순차적 애니메이션 체인을 만들 수 있다. controls.stop()으로 진행 중인 애니메이션을 즉시 중지할 수도 있다.


useMotionValue & useTransform

성능이 중요한 애니메이션에서는 React의 state 대신 MotionValue를 사용한다. MotionValue는 값이 변경되어도 React 리렌더를 트리거하지 않기 때문에 60fps 애니메이션에서 성능 이점이 크다.

tsx
import { motion, useMotionValue, useTransform } from "framer-motion";

function ParallaxCard() {
  const x = useMotionValue(0);
  const y = useMotionValue(0);

  // x 값에 따라 rotateY를 -15~15도로 매핑
  const rotateY = useTransform(x, [-200, 200], [-15, 15]);
  // y 값에 따라 rotateX를 15~-15도로 매핑
  const rotateX = useTransform(y, [-200, 200], [15, -15]);

  function handleMouseMove(event: React.MouseEvent) {
    const rect = event.currentTarget.getBoundingClientRect();
    x.set(event.clientX - rect.left - rect.width / 2);
    y.set(event.clientY - rect.top - rect.height / 2);
  }

  function handleMouseLeave() {
    x.set(0);
    y.set(0);
  }

  return (
    <motion.div
      style={{ rotateX, rotateY, perspective: 600 }}
      onMouseMove={handleMouseMove}
      onMouseLeave={handleMouseLeave}
    >
      3D 틸트 카드
    </motion.div>
  );
}

useTransform은 하나의 MotionValue를 다른 범위의 값으로 변환한다. 입력 범위 [-200, 200]을 출력 범위 [-15, 15]로 매핑하면, x 위치에 따라 회전 각도가 자동으로 계산된다. 이 과정에서 React 리렌더가 한 번도 발생하지 않는다.

useScroll

스크롤 위치를 MotionValue로 추적한다. 스크롤 기반 애니메이션에 유용하다.

tsx
import { motion, useScroll, useTransform } from "framer-motion";

function ProgressBar() {
  const { scrollYProgress } = useScroll();
  // 스크롤 진행률(0~1)을 width(0%~100%)로 변환
  const width = useTransform(scrollYProgress, [0, 1], ["0%", "100%"]);

  return (
    <motion.div
      style={{ width, height: 4, background: "#0070f3" }}
      className="fixed top-0 left-0 z-50"
    />
  );
}

useScrollscrollX, scrollY(절대 픽셀 값)와 scrollXProgress, scrollYProgress(0~1 비율)를 반환한다. 특정 요소의 스크롤을 추적하려면 container 옵션에 ref를 전달한다.


CSS 애니메이션 vs Framer Motion

어떤 상황에서 어떤 걸 쓸지 판단하는 기준:

기준CSSFramer Motion
간단한 hover 효과✅ 충분과함
mount/unmount 애니메이션❌ 어려움✅ AnimatePresence
레이아웃 변경 애니메이션❌ 불가✅ layout prop
순차 애니메이션 체인가능하나 복잡✅ async controls
제스처 기반 인터랙션가능하나 JS 필요✅ whileHover/Tap/Drag
스크롤 기반 애니메이션가능 (scroll-driven)✅ useScroll
번들 크기 영향없음~32KB (gzipped)
물리 기반 스프링✅ spring

원칙: CSS로 충분한 건 CSS로. 컴포넌트 라이프사이클과 연동되거나 복잡한 인터랙션이 필요할 때 Framer Motion을 쓴다. 번들 크기가 민감한 프로젝트라면 꼭 필요한 곳에서만 사용하는 것이 좋다.


성능 팁

transform 속성 우선 사용

width, height, top, left 같은 레이아웃 속성을 직접 애니메이션하면 reflow가 발생한다. 가능하면 x, y, scale, rotate, opacity를 사용해서 GPU 가속을 활용하자.

tsx
// ❌ reflow 유발
<motion.div animate={{ width: 200, left: 100 }} />

// ✅ GPU 가속
<motion.div animate={{ scaleX: 2, x: 100 }} />

MotionValue로 리렌더 방지

빈번하게 값이 변하는 애니메이션(마우스 추적, 스크롤 등)에서는 React state 대신 MotionValue를 사용한다. MotionValue는 React 렌더링 사이클 바깥에서 동작하기 때문에 프레임 드랍 없이 부드러운 애니메이션이 가능하다.

layout 애니메이션 범위 제한

layout prop은 강력하지만, 많은 요소에 무분별하게 적용하면 레이아웃 계산 비용이 증가한다. LayoutGroup으로 관련 요소들을 그룹화하면 레이아웃 변경 감지 범위를 제한할 수 있다.

tsx
import { LayoutGroup } from "framer-motion";

<LayoutGroup>
  {/* 이 그룹 안에서만 layout 변경을 감지 */}
  <motion.div layout>A</motion.div>
  <motion.div layout>B</motion.div>
</LayoutGroup>

정리

  • motion 컴포넌트와 선언적 props(initial/animate/exit)로 React 라이프사이클과 자연스럽게 연동되는 애니메이션을 구현한다
  • AnimatePresence로 언마운트 애니메이션, layout/layoutId로 레이아웃 트랜지션을 처리하며, 이 두 가지가 CSS만으로는 어려운 핵심 기능이다
  • 성능이 중요한 곳에서는 MotionValue + useTransform으로 React 리렌더 없이 60fps 애니메이션을 유지하고, CSS로 충분한 곳은 CSS를 쓴다

관련 문서