Framer Motion으로 프로덕션 애니메이션 구현하기

프론트엔드

Framer Motion애니메이션ReactUI/UX성능

이 글은 누구를 위한 것인가

  • CSS 트랜지션으로 구현하기 어려운 복잡한 애니메이션을 만들려는 팀
  • AnimatePresence로 라우트 전환 효과를 구현하려는 개발자
  • 스크롤에 연동된 패럴랙스 효과를 구현하고 싶은 프론트엔드 개발자

들어가며

CSS 애니메이션으로는 마운트/언마운트 전환 효과를 만들기 매우 어렵다. Framer Motion은 이를 AnimatePresence 하나로 해결한다. variants로 복잡한 상태 전환을 선언적으로 표현하고, useScroll로 스크롤 기반 애니메이션을 간단히 만든다.

이 글은 bluefoxdev.kr의 Framer Motion 실전 가이드 를 참고하여 작성했습니다.


1. Framer Motion 핵심 개념

[핵심 컴포넌트/훅]

motion.div, motion.button 등:
  animate: 목표 상태
  initial: 초기 상태
  exit: 제거 시 상태 (AnimatePresence 필요)
  transition: 애니메이션 설정

variants:
  여러 상태를 이름으로 관리
  부모 → 자식 전파 (stagger 효과)

AnimatePresence:
  언마운트 시 exit 애니메이션 실행
  조건부 렌더링, 라우트 전환에 활용

useScroll:
  scrollY, scrollX: 픽셀 단위 스크롤
  scrollYProgress: 0~1 정규화

useTransform:
  스크롤 값 → 다른 값으로 변환
  예: scrollYProgress(0→1) → opacity(1→0)

useSpring:
  스프링 물리 효과로 부드러운 팔로우

LayoutAnimation:
  layout prop으로 레이아웃 변경 자동 애니메이션
  AnimateSharedLayout 대체

2. Framer Motion 고급 구현

import { motion, AnimatePresence, useScroll, useTransform, useSpring } from 'framer-motion';
import { useState, useRef } from 'react';

// 1. 기본 variants - 상태 전환
const cardVariants = {
  hidden: {
    opacity: 0,
    y: 40,
    scale: 0.95,
  },
  visible: {
    opacity: 1,
    y: 0,
    scale: 1,
    transition: {
      duration: 0.4,
      ease: [0.25, 0.46, 0.45, 0.94],  // cubic-bezier
    },
  },
  exit: {
    opacity: 0,
    y: -20,
    transition: { duration: 0.2 },
  },
};

// 2. Stagger - 자식 요소 순차 등장
const containerVariants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.1,  // 자식 요소 0.1초 간격
      delayChildren: 0.2,
    },
  },
};

function ProductGrid({ products }: { products: Product[] }) {
  return (
    <motion.ul
      variants={containerVariants}
      initial="hidden"
      animate="visible"
    >
      {products.map(product => (
        <motion.li key={product.id} variants={cardVariants}>
          <ProductCard product={product} />
        </motion.li>
      ))}
    </motion.ul>
  );
}

// 3. AnimatePresence - 마운트/언마운트 전환
function Modal({ isOpen, onClose, children }: ModalProps) {
  return (
    <AnimatePresence>
      {isOpen && (
        <>
          {/* 배경 오버레이 */}
          <motion.div
            key="overlay"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={onClose}
            className="fixed inset-0 bg-black/50"
          />
          {/* 모달 */}
          <motion.div
            key="modal"
            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 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-xl p-6"
          >
            {children}
          </motion.div>
        </>
      )}
    </AnimatePresence>
  );
}

// 4. 탭 전환 with AnimatePresence
function TabPanel({ activeTab }: { activeTab: string }) {
  return (
    <div className="relative overflow-hidden">
      <AnimatePresence mode="wait">
        <motion.div
          key={activeTab}
          initial={{ opacity: 0, x: 20 }}
          animate={{ opacity: 1, x: 0 }}
          exit={{ opacity: 0, x: -20 }}
          transition={{ duration: 0.2 }}
        >
          <TabContent tab={activeTab} />
        </motion.div>
      </AnimatePresence>
    </div>
  );
}
// 5. 스크롤 연동 애니메이션
function ParallaxHero() {
  const { scrollYProgress } = useScroll();
  
  const y = useTransform(scrollYProgress, [0, 0.5], [0, -150]);
  const opacity = useTransform(scrollYProgress, [0, 0.3], [1, 0]);
  const scale = useTransform(scrollYProgress, [0, 0.3], [1, 1.1]);
  
  // 스프링으로 부드럽게
  const smoothY = useSpring(y, { damping: 20, stiffness: 100 });
  
  return (
    <div className="h-screen relative overflow-hidden">
      <motion.div
        style={{ y: smoothY, scale, opacity }}
        className="absolute inset-0"
      >
        <img src="/hero.jpg" alt="Hero" className="w-full h-full object-cover" />
      </motion.div>
      <div className="relative z-10 flex items-center justify-center h-full">
        <motion.h1
          style={{ opacity }}
          className="text-6xl font-bold text-white"
        >
          안녕하세요
        </motion.h1>
      </div>
    </div>
  );
}

// 섹션 스크롤 진입 애니메이션
function FadeInSection({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const { scrollYProgress } = useScroll({
    target: ref,
    offset: ['start end', 'end start'],
  });
  
  const opacity = useTransform(scrollYProgress, [0, 0.2, 0.8, 1], [0, 1, 1, 0]);
  const y = useTransform(scrollYProgress, [0, 0.2], [60, 0]);
  
  return (
    <motion.div ref={ref} style={{ opacity, y }}>
      {children}
    </motion.div>
  );
}

// 6. Layout 애니메이션 - 레이아웃 변경 자동 전환
function ExpandableCard({ id, title, content }: CardProps) {
  const [isExpanded, setIsExpanded] = useState(false);
  
  return (
    <motion.div
      layout  // 레이아웃 변경 자동 애니메이션
      onClick={() => setIsExpanded(!isExpanded)}
      className="bg-white rounded-lg p-4 cursor-pointer shadow"
    >
      <motion.h3 layout="position">{title}</motion.h3>
      
      <AnimatePresence>
        {isExpanded && (
          <motion.p
            initial={{ opacity: 0, height: 0 }}
            animate={{ opacity: 1, height: 'auto' }}
            exit={{ opacity: 0, height: 0 }}
            transition={{ duration: 0.3 }}
          >
            {content}
          </motion.p>
        )}
      </AnimatePresence>
    </motion.div>
  );
}

// 7. 제스처 - 드래그, 탭, 호버
function DraggableCard({ onDismiss }: { onDismiss: () => void }) {
  return (
    <motion.div
      drag="x"
      dragConstraints={{ left: -100, right: 100 }}
      dragElastic={0.2}
      onDragEnd={(_, info) => {
        if (Math.abs(info.offset.x) > 80) onDismiss();
      }}
      whileDrag={{ scale: 1.05, cursor: 'grabbing' }}
      whileHover={{ scale: 1.02 }}
      whileTap={{ scale: 0.98 }}
      className="bg-white rounded-xl p-6 shadow-lg cursor-grab"
    >
      <p>좌우로 드래그해서 닫기</p>
    </motion.div>
  );
}

interface Product { id: string; name: string }
interface ModalProps { isOpen: boolean; onClose: () => void; children: React.ReactNode }
interface CardProps { id: string; title: string; content: string }
function ProductCard({ product }: { product: Product }) { return null; }
function TabContent({ tab }: { tab: string }) { return null; }

마무리

Framer Motion의 variantsAnimatePresence만 잘 써도 대부분의 UI 애니메이션 요구사항을 충족할 수 있다. useTransformuseScroll은 성능 최적화된 스크롤 애니메이션을 만든다. 성능 주의사항: transformopacity만 애니메이션하면 GPU 가속을 받는다. width, height, margin 등 레이아웃 트리거 속성은 layout prop으로 위임하고 직접 애니메이션하지 않는 것이 좋다.