Framer Motion으로 React 애니메이션: Variants, 제스처, 레이아웃 애니메이션, 스크롤

이 글의 핵심

CSS transition만으로는 요소가 사라질 때의 애니메이션이나 레이아웃 변화에 따른 이동을 자연스럽게 처리하기 어렵습니다. 이 글은 실무에서 자주 마주치는 이런 문제에서 출발해 Framer Motion이 선언형 API로 무엇을 대신해 주는지 보여 주고, 모달 예제로 앞의 기능을 한데 묶습니다. CSS 애니메이션과의 비교, 성능, Next.js 사용 여부는 FAQ에서 따로 답합니다.

이 글의 핵심

Framer Motion으로 React 애니메이션을 구현하는 방법을 정리한 글입니다. Variants, Gesture, Layout Animation, Scroll을 예제로 다룹니다.

실무에서 마주치는 문제들

CSS 애니메이션이 복잡해요

CSS transition은 “상태 A에서 B로” 바뀌는 단순한 전환에는 충분합니다. 하지만 요소가 DOM에서 사라질 때의 애니메이션은 CSS만으로 만들 수 없습니다. React가 요소를 제거하는 순간 애니메이션을 재생할 대상도 사라지기 때문입니다. 목록 항목이 삭제될 때 나머지가 부드럽게 자리를 채우는 효과도, 이동 전후 위치를 측정해 계산해야 해서 CSS만으로는 번거롭습니다. Framer Motion은 이 두 가지를 AnimatePresence와 layout 속성으로 선언만 하면 되게 해 줍니다.

인터랙션이 부족해요

드래그해서 옮기고 놓으면 제자리로 튕겨 돌아오는 카드, 누르는 동안 살짝 작아지는 버튼처럼 사용자 입력에 반응하는 애니메이션은 이벤트 처리와 애니메이션 상태를 함께 관리해야 합니다. Framer Motion은 제스처와 스프링 물리 기반 애니메이션을 컴포넌트 props로 제공합니다.

성능이 걱정돼요

width, top, margin 같은 속성을 애니메이션하면 매 프레임 레이아웃을 다시 계산해 버벅이기 쉽습니다. Framer Motion은 가능한 한 transform과 opacity로 애니메이션하고, 값이 바뀔 때 React 리렌더링 없이 DOM 스타일을 직접 갱신합니다.

다만 공짜는 아닙니다. 라이브러리 자체가 번들에 수십 KB를 더하고, 모든 애니메이션이 JavaScript로 계산되므로 메인 스레드가 바쁘면 함께 끊깁니다. 단순한 hover 색상 변경이나 로딩 스피너처럼 CSS로 충분한 곳까지 Framer Motion을 쓸 필요는 없습니다. 참고로 이 라이브러리는 2024년 말 motion이라는 이름의 독립 패키지로 바뀌어 npm install motion과 import { motion } from 'motion/react'로 쓰는 것이 새 권장 방식이며, API는 같아서 이 글의 예제는 import 경로만 바꾸면 그대로 동작합니다.


Framer Motion의 특징

핵심 특징

Framer Motion은 React 애니메이션 라이브러리입니다. 주요 장점:

  • 간단한 API: 선언적 문법
  • Variants: 재사용 가능한 애니메이션
  • Gesture: Drag, Hover, Tap
  • Layout Animation: 자동 레이아웃 애니메이션
  • 성능: GPU 가속

설치와 motion 컴포넌트 기본 애니메이션

설치

npm install framer-motion

기본 애니메이션

import { motion } from 'framer-motion';
export default function Box() {
  return (
    <motion.div
      initial={{ opacity: 0, y: 50 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.5 }}
    >
      Hello Framer Motion!
    </motion.div>
  );
}

motion.div는 일반 div와 똑같이 렌더링되지만 애니메이션 props를 추가로 받습니다. initial은 마운트 직후의 시작 상태, animate는 도달할 목표 상태이며, animate 값이 바뀔 때마다 현재 값에서 새 목표로 애니메이션합니다. 상태에 따라 animate={{ x: isOpen ? 100 : 0 }}처럼 쓰면 되므로 애니메이션을 “시작”하는 코드를 따로 쓸 필요가 없습니다. y: 50은 CSS transform: translateY(50px)로 적용되어 레이아웃에 영향을 주지 않습니다.

transition을 지정하지 않으면 Framer Motion은 속성 종류에 따라 기본값을 고릅니다. x, scale 같은 물리적 이동은 스프링, opacity나 색상은 트윈(ease)입니다. 여기서는 duration: 0.5만 줬으므로 두 속성 모두 0.5초 트윈으로 동작합니다. Next.js 서버 사이드 렌더링에서는 initial 상태(투명, 아래로 50px)로 HTML이 만들어지므로, JavaScript가 늦게 로드되면 콘텐츠가 한동안 보이지 않는다는 점도 알아 둘 만합니다. 중요한 첫 화면 콘텐츠라면 initial={false}로 첫 마운트 애니메이션을 끄는 것도 방법입니다.


Variants로 부모·자식 애니메이션 묶기

기본 Variants

const variants = {
  hidden: { opacity: 0, y: 50 },
  visible: { opacity: 1, y: 0 },
};
export default function Box() {
  return (
    <motion.div
      initial="hidden"
      animate="visible"
      variants={variants}
      transition={{ duration: 0.5 }}
    >
      Content
    </motion.div>
  );
}

Variants는 애니메이션 상태에 이름을 붙여 두는 방식입니다. 이 예제만 보면 객체를 직접 쓰는 것과 차이가 없지만, 진짜 가치는 다음 예제처럼 부모의 상태 이름이 자식에게 전파되는 데 있습니다.

자식 애니메이션

const container = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.1,
    },
  },
};
const itemVariants = {
  hidden: { opacity: 0, y: 20 },
  visible: { opacity: 1, y: 0 },
};
export default function List() {
  return (
    <motion.ul variants={container} initial="hidden" animate="visible">
      {items.map((item) => (
        <motion.li key={item.id} variants={itemVariants}>
          {item.name}
        </motion.li>
      ))}
    </motion.ul>
  );
}

부모 ul에만 initial과 animate를 주고 자식 li에는 variants만 줬습니다. 부모가 "visible" 상태가 되면 같은 이름의 variant를 가진 자식들도 자동으로 "visible"이 되고, 부모 transition의 staggerChildren: 0.1에 따라 자식이 0.1초 간격으로 차례차례 나타납니다. 자식마다 delay를 계산해 넣을 필요가 없다는 것이 이 패턴의 장점입니다. 부모의 페이드인이 끝난 뒤 자식을 시작하고 싶다면 when: "beforeChildren"을 함께 지정합니다.

변수 이름에 주의해야 합니다. variant 객체 이름을 item으로 짓고 items.map((item) => ...)의 매개변수도 item이면, map 안의 variants={item}은 variant가 아니라 데이터 항목을 가리키게 됩니다. 에러는 나지 않고 자식 애니메이션만 조용히 동작하지 않아 원인을 찾기 어려운데, 그래서 위 코드는 itemVariants로 이름을 구분했습니다. 또 자식에게 animate를 직접 주면 부모로부터의 전파가 끊기므로, 자식에는 variants만 두는 것이 원칙입니다.


Hover·Drag·Tap 제스처

Hover

<motion.button
  whileHover={{ scale: 1.1 }}
  whileTap={{ scale: 0.95 }}
>
  Click me
</motion.button>

whileHover와 whileTap은 제스처가 진행되는 동안만 적용되고, 끝나면 animate 상태로 자동으로 돌아갑니다. CSS :hover와 비슷하지만, 터치 기기에서 손가락을 뗀 뒤에도 hover 상태가 남는 문제를 Framer Motion이 처리해 준다는 차이가 있습니다. 확대에 scale을 쓰는 이유는 width와 달리 주변 레이아웃을 밀어내지 않기 때문입니다.

Drag

<motion.div
  drag
  dragConstraints={{ left: -100, right: 100, top: -100, bottom: 100 }}
  dragElastic={0.2}
>
  Drag me
</motion.div>

drag만 쓰면 어느 방향으로든 끌 수 있고, drag="x"처럼 축을 제한할 수 있습니다. dragConstraints는 원래 위치를 기준으로 한 이동 범위(픽셀)이고, dragElastic은 범위를 넘어 당길 때 얼마나 늘어나는지(0이면 딱 막히고 1이면 자유롭게)를 정합니다. 놓으면 관성으로 미끄러지다가 범위 안으로 튕겨 돌아옵니다. 픽셀 값 대신 부모 요소의 ref를 dragConstraints={containerRef}로 넘기면 부모 영역 안으로 제한되어 화면 크기가 달라도 맞게 동작합니다.

모바일에서 드래그를 쓸 때 흔히 겪는 문제는 드래그와 페이지 스크롤이 충돌하는 것입니다. 세로 스크롤되는 페이지에서 가로 스와이프 카드를 만들 때 drag="x"로 축을 제한하지 않으면, 사용자가 스크롤하려고 할 때마다 카드가 끌려옵니다. 드래그 결과 위치는 transform으로만 적용되므로, 놓은 위치를 저장하려면 onDragEnd의 info.offset으로 값을 받아 상태에 기록해야 합니다.

Tap

<motion.button
  whileTap={{ scale: 0.9 }}
  onTap={() => console.log('Tapped!')}
>
  Tap me
</motion.button>

onTap은 누른 지점과 같은 요소 위에서 손을 뗐을 때만 호출됩니다. 누른 채로 요소 밖으로 끌고 나가서 떼면 onTapCancel이 호출되어, 실수로 누른 버튼을 취소할 수 있다는 점이 onClick과 다릅니다. 다만 버튼의 실제 동작(폼 제출, 이동)은 키보드 접근성을 위해 여전히 onClick에 두는 편이 안전합니다. onTap은 포인터 제스처 기준이라 키보드 Enter 입력의 처리 방식이 버전에 따라 달랐기 때문입니다.


layout 애니메이션과 AnimatePresence

자동 레이아웃

import { motion } from 'framer-motion';
import { useState } from 'react';
export default function ExpandableCard() {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <motion.div
      layout
      onClick={() => setIsOpen(!isOpen)}
      style={{
        padding: '1rem',
        border: '1px solid #ccc',
        borderRadius: '8px',
      }}
    >
      <motion.h2 layout>Title</motion.h2>
      {isOpen && (
        <motion.p
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
        >
          Content goes here...
        </motion.p>
      )}
    </motion.div>
  );
}

layout 속성은 Framer Motion에서 가장 독특한 기능입니다. 렌더링 전후로 요소의 위치와 크기를 측정한 뒤, 그 차이를 transform으로 되돌렸다가 0으로 풀어 주는 FLIP 기법으로 이동을 애니메이션합니다. 여기서는 문단이 나타나 카드 높이가 바뀔 때 카드가 뚝 커지지 않고 부드럽게 늘어납니다. 제목에도 layout을 준 이유는, 부모가 scale로 크기를 바꾸는 동안 자식이 함께 찌그러져 보이는 것을 Framer Motion이 보정하게 하려는 것입니다. 모서리 둥글기가 찌그러지는 것도 borderRadius를 style로 지정하면 같은 방식으로 보정됩니다.

이 예제의 exit={{ opacity: 0 }}은 실제로는 동작하지 않습니다. isOpen이 false가 되면 React가 <motion.p>를 즉시 제거하기 때문에, 퇴장 애니메이션을 재생하려면 다음 절의 AnimatePresence로 감싸야 합니다. 또 layout은 레이아웃 측정을 동반해 비용이 있으므로, 수백 개의 목록 항목 모두에 붙이면 상태가 바뀔 때마다 전체를 측정하느라 프레임이 떨어질 수 있습니다.

AnimatePresence

import { AnimatePresence, motion } from 'framer-motion';
export default function List() {
  const [items, setItems] = useState([1, 2, 3]);
  return (
    <ul>
      <AnimatePresence>
        {items.map((item) => (
          <motion.li
            key={item}
            initial={{ opacity: 0, x: -50 }}
            animate={{ opacity: 1, x: 0 }}
            exit={{ opacity: 0, x: 50 }}
          >
            Item {item}
          </motion.li>
        ))}
      </AnimatePresence>
    </ul>
  );
}

AnimatePresence는 자식이 React 트리에서 제거될 때 곧바로 DOM에서 지우지 않고, exit 애니메이션이 끝날 때까지 붙잡아 둡니다. 어떤 자식이 사라졌는지 알아내는 기준이 key이므로 각 자식에는 안정적인 고유 key가 반드시 있어야 합니다. 배열 인덱스를 key로 쓰면 가운데 항목을 지웠을 때 React는 마지막 항목이 사라진 것으로 판단해, 엉뚱한 항목이 퇴장 애니메이션을 재생합니다. AnimatePresence 자체는 조건부 렌더링 바깥에 있어야 합니다. {isOpen && <AnimatePresence>...}처럼 안쪽에 두면 AnimatePresence까지 함께 사라져 아무 효과가 없습니다.

항목이 사라지면서 나머지 항목이 빈자리를 부드럽게 채우게 하려면 motion.li에 layout을 함께 주고, 사라지는 항목이 자리를 계속 차지하지 않도록 <AnimatePresence mode="popLayout">을 쓰면 됩니다. 모드를 지정하지 않으면 퇴장 중인 항목이 끝날 때까지 공간을 차지하고 있다가 한꺼번에 당겨집니다.


useScroll·useInView 스크롤 애니메이션

useScroll

import { motion, useScroll, useTransform } from 'framer-motion';
export default function ScrollAnimation() {
  const { scrollYProgress } = useScroll();
  const opacity = useTransform(scrollYProgress, [0, 1], [1, 0]);
  const scale = useTransform(scrollYProgress, [0, 1], [1, 0.5]);
  return (
    <motion.div style={{ opacity, scale }}>
      Scroll to see animation
    </motion.div>
  );
}

useScroll()은 페이지 스크롤 위치를 MotionValue로 돌려주고, scrollYProgress는 문서 맨 위에서 0, 맨 아래에서 1입니다. useTransform은 이 값을 다른 범위로 매핑해 새로운 MotionValue를 만듭니다. 핵심은 이 값들이 React 상태가 아니라는 점입니다. 스크롤할 때마다 컴포넌트가 다시 렌더링되지 않고, Framer Motion이 style에 연결된 DOM 속성만 직접 갱신합니다. useState와 scroll 이벤트로 같은 효과를 만들면 스크롤 한 번에 수십 번 렌더링이 일어나 끊기기 쉽습니다.

페이지 전체가 아니라 특정 요소가 화면을 지나가는 진행률이 필요하다면 useScroll({ target: ref, offset: ["start end", "end start"] })처럼 대상과 기준을 지정합니다. scrollYProgress 값을 확인하려고 console.log(scrollYProgress)를 찍으면 객체만 보이므로, useMotionValueEvent(scrollYProgress, "change", (v) => ...)로 변화를 구독해야 실제 숫자를 볼 수 있습니다.

useInView

import { motion, useInView } from 'framer-motion';
import { useRef } from 'react';
export default function FadeInWhenVisible() {
  const ref = useRef(null);
  const isInView = useInView(ref, { once: true });
  return (
    <motion.div
      ref={ref}
      initial={{ opacity: 0, y: 50 }}
      animate={isInView ? { opacity: 1, y: 0 } : {}}
      transition={{ duration: 0.5 }}
    >
      Fade in when visible
    </motion.div>
  );
}

useInView는 브라우저의 Intersection Observer로 요소가 화면에 들어왔는지 감지합니다. 스크롤 이벤트를 매번 검사하지 않아 가볍고, once: true면 한 번 보인 뒤에는 감시를 멈춰 다시 스크롤해도 애니메이션이 반복되지 않습니다. 사실 이 패턴은 whileInView prop으로 더 짧게 쓸 수 있습니다. <motion.div initial={{ opacity: 0, y: 50 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>처럼 쓰면 ref와 훅 없이 같은 동작을 합니다. useInView는 화면 진입 여부를 애니메이션 외의 로직(데이터 로딩 시작 등)에도 쓰고 싶을 때 유용합니다.

initial의 y: 50 때문에 요소가 원래 자리보다 아래에 그려지는데, 화면 맨 아래 경계에 걸친 요소는 이 50px 때문에 화면 밖으로 밀려 “보이지 않음”으로 판정되고 영원히 나타나지 않는 경우가 있습니다. viewport={{ amount: 0.2 }}나 margin 옵션으로 판정 기준을 조정하면 해결됩니다.


모달 열고 닫기 애니메이션

모달

import { motion, AnimatePresence } from 'framer-motion';
export default function Modal({ isOpen, onClose, children }) {
  return (
    <AnimatePresence>
      {isOpen && (
        <>
          <motion.div
            key="backdrop"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={onClose}
            style={{
              position: 'fixed',
              inset: 0,
              background: 'rgba(0, 0, 0, 0.5)',
            }}
          />
          <motion.div
            key="dialog"
            initial={{ opacity: 0, scale: 0.9 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.9 }}
            style={{
              position: 'fixed',
              top: '50%',
              left: '50%',
              x: '-50%',
              y: '-50%',
              background: 'white',
              padding: '2rem',
              borderRadius: '8px',
            }}
          >
            {children}
          </motion.div>
        </>
      )}
    </AnimatePresence>
  );
}

지금까지의 기능이 한데 모인 예제입니다. AnimatePresence가 조건부 렌더링 바깥에 있어서 isOpen이 false가 되어도 배경과 대화상자가 각자의 exit 애니메이션을 재생한 뒤 사라집니다. 두 요소에 서로 다른 key를 준 것도 AnimatePresence가 각 자식을 구분할 수 있게 하기 위해서입니다.

가운데 정렬에 style={{ transform: 'translate(-50%, -50%)' }}를 쓰지 않고 x, y를 쓴 데는 이유가 있습니다. Framer Motion은 scale 애니메이션을 위해 요소의 transform 속성 전체를 직접 관리하므로, style에 적은 transform 문자열은 덮어써져 사라집니다. 그 결과 대화상자의 왼쪽 위 모서리가 화면 중앙에 오는, 즉 오른쪽 아래로 치우친 모달이 됩니다. Framer Motion으로 모달을 처음 만들 때 거의 모두가 한 번씩 겪는 문제입니다. x: '-50%'처럼 Framer Motion이 아는 transform 값으로 넘기면 scale과 함께 하나의 transform으로 합쳐져 정상적으로 가운데 정렬됩니다. flexbox 컨테이너로 가운데 정렬하는 방법도 이 충돌을 피합니다.

실제 서비스용 모달이라면 애니메이션 외에도 ESC 키로 닫기, 열릴 때 포커스를 모달 안으로 옮기고 닫힐 때 원래 버튼으로 되돌리기, role="dialog"와 aria-modal="true", 배경 스크롤 막기가 필요합니다. 이 부분은 Radix UI의 Dialog처럼 접근성이 구현된 컴포넌트에 Framer Motion 애니메이션을 입히는 방식이 가장 적은 노력으로 완성도를 높이는 방법입니다. 또 운영체제에서 “동작 줄이기”를 켠 사용자를 위해 useReducedMotion()이 true일 때는 이동과 확대 없이 투명도만 바꾸는 식으로 애니메이션을 줄이는 것이 좋습니다.


Framer Motion 사용 요약과 점검 목록

핵심 요약

  • Framer Motion: React 애니메이션
  • 간단한 API: 선언적 문법
  • Variants: 재사용 가능
  • Gesture: Drag, Hover, Tap
  • Layout Animation: 자동
  • Scroll: useScroll, useInView

도입 점검 목록

  • Framer Motion 설치
  • 기본 애니메이션 구현
  • Variants 정의
  • Gesture 추가
  • Layout Animation 구현
  • Scroll Animation 구현
  • 성능 최적화

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. CSS 애니메이션과 비교하면 어떤가요?

A. 상태 전환, hover, 로딩 스피너처럼 단순한 애니메이션은 CSS가 더 가볍고 JavaScript 없이 동작합니다. 퇴장 애니메이션, 레이아웃 이동, 드래그와 스프링 물리, 스크롤 연동처럼 상태와 측정이 필요한 애니메이션은 Framer Motion 쪽이 코드가 훨씬 짧습니다. 둘을 함께 쓰는 것이 일반적입니다.

Q. 성능은 어떤가요?

A. transform과 opacity 위주로 애니메이션하고 MotionValue로 리렌더링을 피하면 대부분 부드럽습니다. 다만 width, height, top 같은 레이아웃 속성을 애니메이션하거나 layout을 많은 요소에 붙이면 매 프레임 레이아웃 계산이 생겨 느려질 수 있습니다.

Q. Next.js에서 사용할 수 있나요?

A. 쓸 수 있습니다. 다만 App Router에서는 motion 컴포넌트가 클라이언트 컴포넌트에서만 동작하므로 사용하는 파일에 'use client'를 붙이거나, motion/react-client에서 가져와야 합니다. 페이지 전환 퇴장 애니메이션은 App Router 구조상 까다로워 별도 처리가 필요합니다.

Q. 번들 크기가 걱정되면 어떻게 하나요?

A. LazyMotion과 m 컴포넌트를 쓰면 필요한 기능만 나중에 불러와 초기 번들을 줄일 수 있습니다. 애니메이션이 몇 개 없는 페이지라면 CSS로 대체하는 것도 좋은 선택입니다.