들어가며
여러 인터랙션이 맞물린 애니메이션 구현은 늘 어려운데요, 얼마 전 투입되었던 프로젝트에서 Framer Motion이라는 라이브러리를 통해 애니메이션 쉽게 구현하는 경험을 하게 되어 이번에 소개해보려고 합니다.
Framer Motion은 Framer에서 만든 React 용 모션 라이브러리로, 빠르고 쉽게 애니메이션을 구현할 수 있도록 도와줍니다. TypeScript를 지원하고 있으며, 다양한 API가 있어 복잡한 애니메이션 구현에도 큰 도움이 됩니다. 이번 글에서는 Framer Motion 사용 설치 및 사용 방법, 몇 가지 API를 소개하고 실제 프로젝트에 어떻게 적용하였는지 보여드리겠습니다.
Framer Motion 설치 및 사용하기
설치하기
Framer Motion은 React18 이상에서 사용할 수 있으며, 아래 명령어를 사용하여 설치합니다.
// yarn
yarn add framer-motion
// npm
npm i framer-motion
불러오기
설치한 후에는 framer-motion
에서 motion
을 import하여 사용합니다.
import { motion } from 'framer-motion';
사용하기
Framer Motion 공식 문서에는 API 설명이 아주 자세하고 방대하게 설명되어있는데요, 본 글에서는 몇 가지 API만 소개하고자 합니다. 자세한 설명은 공식 문서를 참고해주세요!
1. <motion />
component
공식 문서에는 ’Motion의 핵심은 모션 컴포넌트‘라고 설명하고 있습니다. Motion 컴포넌트는 애니메이션 기능이 추가된 일반 HTML 또는 SVG 요소라고 생각하고 사용하면 됩니다.
<motion.div />
2. Animations
Motion
컴포넌트에 애니메이션을 적용하려면 animate prop에 값을 전달하여 적용할 수 있습니다. 기본적으로 transition 값이 설정되어 있지만, 사용자가 구현하고자 하는 transition 값 또한 추가하여 적용할 수 있습니다.
<motion.div animate={{ x: 100 }} transition={{ delay: 1 }} />
3. Gestures
<motion />
은 제스처를 인식하여 React 이벤트 리스너를 확장합니다. drag, hover, tap, pan, viewport를 감지해 애니메이션 지원합니다. 제스처를 사용하려면 whileHover
, whileTap
과 같은 prop을 사용하여 적용할 수 있습니다.
<motion.div
whileHover={{ scale: 1.2 }}
whileTap={{ scale: 1.1 }}
drag="x"
dragConstraints={{ left: -100, right: 100 }}
/>
4.Variants
Variants는 애니메이션 객체를 생성하고, 자식 요소의 애니메이션도 제어할 때 사용합니다. Variants는 애니메이션 객체를 생성하여 코드를 깔끔하게 만들어줄 뿐만 아니라 부모 요소에 Variants를 등록했을 때 안의 자식 요소들에까지 그 Variants를 전달합니다.
const list = { hidden: { opacity: 0 } };
const item = { hidden: { x: -10, opacity: 0 } };
return (
<motion.ul animate="hidden" variants={list}>
<motion.li variants={item} />
<motion.li variants={item} />
<motion.li variants={item} />
</motion.ul>
);
5. Animate Presence
AnimatePresence
는 언마운트 되는 컴포넌트에 애니메이션 효과를 줄 수 있습니다. React에서는 언마운트 되는 컴포넌트에는 어떤 효과도 줄 수 없지만 AnimatePresence
를 사용하면 언마운트 되는 컴포넌트에 애니메이션 효과를 줄 수 있습니다.
import { motion, AnimatePresence } from 'framer-motion';
export const MyComponent = ({ isVisible }) => (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }} // 이 prop 값을 통해 언마운트 될 때 애니메이션 적용
/>
)}
</AnimatePresence>
);
Framer Motion으로 Motion Chip 만들기
이번 프로젝트에서 Framer Motion을 활용하여 만들었던 컴포넌트는 Motion Chip이었습니다. 여러 개의 텍스트와 컬러가 주어지면 Chip 내부의 텍스트와 컬러가 계속 변경되면서 텍스트 길이에 맞춰 Chip의 너비가 넓어지거나 좁아지는 형태로 구현되어야 했습니다.
구현 코드는 다음과 같습니다.
import { useEffect, useState, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import useMediaQuery from '@hooks/common/useMediaQuery';
import type { MotionWordProps } from '@type/atoms';
import classNames from 'classnames/bind';
import style from './MotionChip.module.scss';
const cx = classNames.bind(style);
const MotionChip = ({ words }: MotionWordProps) => {
const [wordIndex, setWordIndex] = useState < number > 0;
const [width, setWidth] = useState < number > 0;
const spanRef = useRef < HTMLSpanElement > null;
const isDesktop = useMediaQuery('desktop');
const isMobile = useMediaQuery('mobile');
useEffect(() => {
const duration = 3000;
let startTimestamp: number | null = null;
let animationFrameId: number | null = null;
const animate = (timestamp: number) => {
if (startTimestamp === null) {
startTimestamp = timestamp;
}
const elapsed = timestamp - startTimestamp;
if (elapsed > duration) {
setWordIndex((oldIndex) => (oldIndex + 1) % words.length);
startTimestamp = null;
}
animationFrameId = requestAnimationFrame(animate);
};
animationFrameId = requestAnimationFrame(animate);
return () => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
};
}, [words.length]);
useEffect(() => {
document.fonts.ready.then(() => {
if (spanRef.current) {
setWidth(spanRef.current?.offsetWidth);
}
});
}, [wordIndex, isDesktop, isMobile]);
return (
<div className={cx('motion-chip-wrap')}>
{spanRef.current && width !== 0 && (
<motion.div
className={cx('morph')}
style={{
backgroundColor: words[wordIndex].backgroundColor,
color: words[wordIndex].color,
}}
animate={{
backgroundColor: words[wordIndex].backgroundColor,
color: words[wordIndex].color,
}}
transition={{ duration: 0.5, ease: 'easeOut' }}
>
<AnimatePresence mode="popLayout">
<motion.div
key={wordIndex}
initial={{ display: 'none', width, color: words[wordIndex].color }}
animate={{
opacity: 1,
display: 'block',
width,
color: words[wordIndex].color,
transition: { duration: 0.5, ease: 'easeOut' },
}}
exit={{
display: 'none',
width,
color: words[wordIndex].color,
transition: { duration: 0.5, ease: 'easeOut' },
}}
>
<span className={cx('label')}>{words[wordIndex].word}</span>
</motion.div>
</AnimatePresence>
</motion.div>
)}
<span ref={spanRef} className={cx('hidden-text')} aria-hidden>
{words[wordIndex].word}
</span>
</div>
);
};
export { MotionChip };
이 작업을 진행하면서 디자이너분과 모션을 어떻게 넣을 것인지 함께 보면서 진행했는데요! 너비는 자연스럽게 늘어나고 줄어나면서도, 텍스트는 이전 컬러와 오버랩되지 않게 하기 위하여 background-color가 들어가는 부모 요소와 텍스트가 들어가는 자식 요소를 분리하여 각각 애니메이션을 적용했습니다.
텍스트와 컬러를 스위칭하는 방식으로는 requestAnimationFrame()
API를 활용해보았습니다. setInterval
은 브라우저의 다른 탭 화면을 보거나 브라우저가 최소화되어 있을 때 계속 타이머가 돌아 콜백을 호출하기 때문에 시스템 리소스 낭비를 초래하고 불필요한 전력을 소모하게 만드는 반면, requestAnimationFrame
은 페이지가 비활성화된 상태이면 애니메이션이 브라우저에 의해 일시 중지되어 CPU 리소스나 배터리 수명 낭비하지 않을 수 있게 해준다고 합니다.
그동안 자주 사용하는 setInterval
로도 충분히 구현할 수 있지만 이 컴포넌트가 메인 화면에 사용되는 컴포넌트임을 고려하여 웹 에니메이션에 최적화 되어있는 requestAnimationFrame
을 사용해보았습니다.
아래 영상은 최종 구현된 모습이며, 이렇게 완성 된 컴포넌트는 지금도 해당 서비스 메인에서 열심히 돌아가고 있습니다.😎
마치며
오늘은 애니메이션 구현이 어려운 분들에게 큰 도움이 될 수 있는 Framer Motion 라이브러리를 소개해 드렸는데요. Framer Motion은 다양한 API를 제공하고 있어 복잡한 애니메이션 구현에도 큰 도움이 됩니다. 애니메이션 구현이 어려운 분들은 한번 써보시는 것도 좋을 것 같습니다.
오늘도 읽어주셔서 감사합니다.🙇♀️