Framer Motion 애니메이션 완벽 가이드 — React에서 매끄러운 UI 만들기

Framer Motion애니메이션ReactUI/UX프론트엔드인터랙션

Framer Motion은 React를 위한 가장 강력한 애니메이션 라이브러리입니다. 선언적 API로 복잡한 애니메이션도 쉽게 구현할 수 있습니다.


Framer Motion vs 다른 라이브러리

항목Framer MotionReact SpringCSS/GSAP
러닝 커브낮음중간다양
번들 크기~50KB~25KBCSS: 0 / GSAP: ~60KB
레이아웃 애니메이션⭐⭐⭐⭐⭐⭐⭐
제스처 지원⭐⭐⭐⭐⭐⭐⭐⭐⭐
Exit 애니메이션⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
물리 기반⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

설치 및 기본 사용

설치

npm install framer-motion

기본 애니메이션

import { motion } from 'framer-motion'

export function BasicAnimation() {
  return (
    <motion.div
      initial={{ opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.5 }}
    >
      안녕하세요!
    </motion.div>
  )
}

motion 컴포넌트

// 모든 HTML 요소에 motion 프리픽스
<motion.div />
<motion.span />
<motion.button />
<motion.ul />
<motion.svg />
<motion.path />

// 커스텀 컴포넌트
const MotionButton = motion(Button)
const MotionCard = motion.create(Card)

애니메이션 속성

initial, animate, exit

<motion.div
  initial={{ opacity: 0, scale: 0.8 }}  // 시작 상태
  animate={{ opacity: 1, scale: 1 }}     // 최종 상태
  exit={{ opacity: 0, scale: 0.8 }}      // 언마운트 시
  transition={{ duration: 0.3 }}
/>

variants (상태 정의)

const cardVariants = {
  hidden: {
    opacity: 0,
    y: 50,
  },
  visible: {
    opacity: 1,
    y: 0,
    transition: {
      duration: 0.5,
      ease: 'easeOut',
    },
  },
  hover: {
    scale: 1.05,
    boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
  },
}

export function Card() {
  return (
    <motion.div
      variants={cardVariants}
      initial="hidden"
      animate="visible"
      whileHover="hover"
    >
      카드 내용
    </motion.div>
  )
}

transition 옵션

<motion.div
  animate={{ x: 100 }}
  transition={{
    // 기본
    duration: 0.5,
    delay: 0.2,

    // 이징
    ease: 'easeInOut',
    // 또는 커스텀: [0.6, 0.01, -0.05, 0.95]

    // 스프링 (물리 기반)
    type: 'spring',
    stiffness: 300,
    damping: 20,
    mass: 1,

    // 또는 간단하게
    type: 'spring',
    bounce: 0.25,

    // 반복
    repeat: Infinity,
    repeatType: 'reverse',
    repeatDelay: 1,
  }}
/>

제스처 애니메이션

hover, tap, drag

export function InteractiveButton() {
  return (
    <motion.button
      whileHover={{ scale: 1.1 }}
      whileTap={{ scale: 0.95 }}
      transition={{ type: 'spring', stiffness: 400, damping: 17 }}
    >
      클릭하세요
    </motion.button>
  )
}

드래그

export function DraggableCard() {
  return (
    <motion.div
      drag
      dragConstraints={{ left: -100, right: 100, top: -100, bottom: 100 }}
      dragElastic={0.2}
      dragMomentum={false}
      whileDrag={{ scale: 1.1, cursor: 'grabbing' }}
    >
      드래그하세요
    </motion.div>
  )
}

// 드래그 영역 제한 (부모 기준)
export function ConstrainedDrag() {
  const constraintsRef = useRef(null)

  return (
    <motion.div ref={constraintsRef} className="w-80 h-80 bg-gray-100">
      <motion.div
        drag
        dragConstraints={constraintsRef}
        className="w-20 h-20 bg-blue-500"
      />
    </motion.div>
  )
}

AnimatePresence (Exit 애니메이션)

import { motion, AnimatePresence } from 'framer-motion'

export function Modal({ isOpen, onClose, children }) {
  return (
    <AnimatePresence>
      {isOpen && (
        <>
          {/* 오버레이 */}
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={onClose}
            className="fixed inset-0 bg-black/50"
          />

          {/* 모달 */}
          <motion.div
            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 inset-0 flex items-center justify-center"
          >
            <div className="bg-white rounded-lg p-6">
              {children}
            </div>
          </motion.div>
        </>
      )}
    </AnimatePresence>
  )
}

리스트 애니메이션

export function AnimatedList({ items }) {
  return (
    <AnimatePresence mode="popLayout">
      {items.map((item) => (
        <motion.li
          key={item.id}
          initial={{ opacity: 0, x: -50 }}
          animate={{ opacity: 1, x: 0 }}
          exit={{ opacity: 0, x: 50 }}
          layout
        >
          {item.text}
        </motion.li>
      ))}
    </AnimatePresence>
  )
}

레이아웃 애니메이션

기본 레이아웃 애니메이션

export function ExpandableCard() {
  const [isExpanded, setIsExpanded] = useState(false)

  return (
    <motion.div
      layout
      onClick={() => setIsExpanded(!isExpanded)}
      className={isExpanded ? 'w-full h-80' : 'w-40 h-40'}
      style={{ borderRadius: 12 }}
    >
      <motion.h2 layout="position">제목</motion.h2>
      {isExpanded && (
        <motion.p
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
        >
          상세 내용이 여기에 표시됩니다.
        </motion.p>
      )}
    </motion.div>
  )
}

공유 레이아웃 (layoutId)

export function SharedLayoutExample() {
  const [selectedId, setSelectedId] = useState(null)

  return (
    <>
      {/* 그리드 */}
      <div className="grid grid-cols-3 gap-4">
        {items.map((item) => (
          <motion.div
            key={item.id}
            layoutId={item.id}
            onClick={() => setSelectedId(item.id)}
          >
            <motion.h3 layoutId={`title-${item.id}`}>
              {item.title}
            </motion.h3>
          </motion.div>
        ))}
      </div>

      {/* 확장된 뷰 */}
      <AnimatePresence>
        {selectedId && (
          <motion.div
            layoutId={selectedId}
            className="fixed inset-0 flex items-center justify-center"
          >
            <motion.h3 layoutId={`title-${selectedId}`}>
              {items.find(i => i.id === selectedId)?.title}
            </motion.h3>
            <motion.button onClick={() => setSelectedId(null)}>
              닫기
            </motion.button>
          </motion.div>
        )}
      </AnimatePresence>
    </>
  )
}

스크롤 애니메이션

useScroll

import { motion, useScroll, useTransform } from 'framer-motion'

export function ScrollProgress() {
  const { scrollYProgress } = useScroll()

  return (
    <motion.div
      className="fixed top-0 left-0 right-0 h-1 bg-blue-500 origin-left"
      style={{ scaleX: scrollYProgress }}
    />
  )
}

스크롤 기반 애니메이션

export function ParallaxSection() {
  const ref = useRef(null)
  const { scrollYProgress } = useScroll({
    target: ref,
    offset: ['start end', 'end start'],
  })

  const y = useTransform(scrollYProgress, [0, 1], [100, -100])
  const opacity = useTransform(scrollYProgress, [0, 0.5, 1], [0, 1, 0])

  return (
    <section ref={ref} className="h-screen">
      <motion.div style={{ y, opacity }}>
        패럴랙스 효과
      </motion.div>
    </section>
  )
}

useInView

import { motion, useInView } from 'framer-motion'

export function FadeInWhenVisible({ children }) {
  const ref = useRef(null)
  const isInView = useInView(ref, { once: true, margin: '-100px' })

  return (
    <motion.div
      ref={ref}
      initial={{ opacity: 0, y: 50 }}
      animate={isInView ? { opacity: 1, y: 0 } : {}}
      transition={{ duration: 0.6 }}
    >
      {children}
    </motion.div>
  )
}

실전 컴포넌트 예제

탭 인디케이터

export function Tabs({ tabs, activeTab, setActiveTab }) {
  return (
    <div className="flex relative">
      {tabs.map((tab) => (
        <button
          key={tab.id}
          onClick={() => setActiveTab(tab.id)}
          className="px-4 py-2 relative"
        >
          {tab.label}
          {activeTab === tab.id && (
            <motion.div
              layoutId="activeTab"
              className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500"
            />
          )}
        </button>
      ))}
    </div>
  )
}

알림 토스트

const toastVariants = {
  initial: { opacity: 0, y: 50, scale: 0.3 },
  animate: { opacity: 1, y: 0, scale: 1 },
  exit: { opacity: 0, scale: 0.5, transition: { duration: 0.2 } },
}

export function Toast({ message, onClose }) {
  return (
    <motion.div
      variants={toastVariants}
      initial="initial"
      animate="animate"
      exit="exit"
      drag="x"
      dragConstraints={{ left: 0, right: 0 }}
      onDragEnd={(_, info) => {
        if (Math.abs(info.offset.x) > 100) {
          onClose()
        }
      }}
      className="bg-gray-800 text-white px-4 py-3 rounded-lg shadow-lg"
    >
      {message}
    </motion.div>
  )
}

스텝 인디케이터

export function StepIndicator({ currentStep, totalSteps }) {
  return (
    <div className="flex gap-2">
      {Array.from({ length: totalSteps }).map((_, index) => (
        <motion.div
          key={index}
          className="h-2 rounded-full bg-gray-200"
          animate={{
            width: index === currentStep ? 24 : 8,
            backgroundColor: index <= currentStep ? '#3B82F6' : '#E5E7EB',
          }}
          transition={{ type: 'spring', stiffness: 300, damping: 30 }}
        />
      ))}
    </div>
  )
}

성능 최적화

1. transform만 애니메이션

// ❌ 레이아웃 트리거
<motion.div animate={{ width: 200, height: 200 }} />

// ✅ transform만 (GPU 가속)
<motion.div animate={{ scale: 1.5 }} />

2. layout 최소화

// ❌ 모든 자식에 layout
<motion.div layout>
  <motion.div layout>
    <motion.div layout />
  </motion.div>
</motion.div>

// ✅ 필요한 곳만
<motion.div layout>
  <div>
    <div />
  </div>
</motion.div>

3. LazyMotion (번들 최적화)

import { LazyMotion, domAnimation, m } from 'framer-motion'

// 필요한 기능만 로드
export function App() {
  return (
    <LazyMotion features={domAnimation}>
      <m.div animate={{ opacity: 1 }} />
    </LazyMotion>
  )
}

4. useReducedMotion

import { useReducedMotion } from 'framer-motion'

export function AccessibleAnimation() {
  const shouldReduceMotion = useReducedMotion()

  return (
    <motion.div
      animate={{ x: 100 }}
      transition={{
        duration: shouldReduceMotion ? 0 : 0.5,
      }}
    />
  )
}

자주 쓰는 패턴

staggerChildren (자식 순차 애니메이션)

const containerVariants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.1,
    },
  },
}

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

export function StaggeredList({ items }) {
  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>
  )
}

Page Transitions (Next.js)

// components/PageTransition.tsx
export function PageTransition({ children }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, y: -20 }}
      transition={{ duration: 0.3 }}
    >
      {children}
    </motion.div>
  )
}

마치며

Framer Motion 핵심 포인트:

  1. 선언적: 상태만 정의하면 알아서 보간
  2. 레이아웃: 복잡한 레이아웃 변화도 자동 처리
  3. 제스처: hover, tap, drag 쉽게 구현
  4. AnimatePresence: exit 애니메이션 지원

프로덕션 앱에 폴리시된 애니메이션을 추가하고 싶다면 Framer Motion이 최고의 선택입니다.

궁금한 점이 있으신가요?

협업·의뢰는 아래로, 가벼운 소통은 인스타그램 @bluefox._.hi도 환영이에요.