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

발행 읽는 시간 약 2 분

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

글 소개

Framer Motion으로 React 앱에 프로페셔널한 애니메이션을 추가하는 방법. 기본 개념부터 고급 테크닉, 성능 최적화까지 다룹니다.

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


Framer Motion vs 다른 라이브러리#

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

설치 및 기본 사용#

설치#

Bash
npm install framer-motion

기본 애니메이션#

TSX
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 컴포넌트#

TSX
// 모든 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#

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

variants (상태 정의)#

TSX
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 옵션#

TSX
<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#

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

드래그#

TSX
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 애니메이션)#

TSX
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>
  )
}

리스트 애니메이션#

TSX
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>
  )
}

레이아웃 애니메이션#

Diagram · Mermaid

기본 레이아웃 애니메이션#

TSX
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)#

TSX
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#

TSX
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 }}
    />
  )
}

스크롤 기반 애니메이션#

TSX
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#

TSX
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>
  )
}

실전 컴포넌트 예제#

탭 인디케이터#

TSX
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>
  )
}

알림 토스트#

TSX
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>
  )
}

스텝 인디케이터#

TSX
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만 애니메이션#

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

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

2. layout 최소화#

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

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

3. LazyMotion (번들 최적화)#

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

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

4. useReducedMotion#

TSX
import { useReducedMotion } from 'framer-motion'

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

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

자주 쓰는 패턴#

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

TSX
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)#

TSX
// 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이 최고의 선택입니다.

Follow · Ask · Work

이 글이 도움이 됐다면

짧은 작업 기록과 데모는 인스타그램에 올립니다. 만들고 싶은 것이 있다면 프로젝트 의뢰나 협업 제안도 편하게 보내 주세요.

글쓴이

파란여우

직접 만든 AI 앱·제품의 개발·배포를 공개하는 실전형 테크 크리에이터

파란여우 소개 Instagram @bluefox._.hi

시리즈 · 개발 노트

시리즈 전체 보기

이어서 보기

만들고 싶은 것이 있다면 프로젝트 의뢰로 알려 주세요.