당프소 클론 코딩을 하다가 발견하게된
새로운 라이브러리인 Framer-motion
공식 문서
Framer-motion은 리액트에서 다양한 애니메이션을 만들 때 쓰면 정말정말 유용해 보인다.
오늘은 위 라이브러리의 사용법을 정리해보려 한다.
npm i Framer-motion
yarn add Framer-motion
import {motion} from 'framer-motion'
motion.div와 같이 HTML태그 앞에 motion 키워드를 붙여줍니다.
이렇게 motion 키워드가 붙은 요소를 motion component
라 합니다.
초기 상태를 initial 속성에 객체 형태로 넣고,
애니메이션 할 상태를 animate 속성에 객체로 넣습니다.
<motion.div
initial={{ scale:0}}
animate={{scale:1,rotateZ:360}}
기본적으로 Motion은 애니메이션 되는 값의 유형에 따라 적절한 애니메이션을 만들어 준다.
예를 들어 x/y 변경, scale 변경 등 물리적인 변경은 스프링 시뮬레이션을 통해 애니매이션되고, opacity 나 color가 바뀔 때는 자연스러운 Tween 효과를 적용한다.
사용자는 transition 속성으로 원하는 유형의 애니메이션을 정의할 수 있다.
<motion.div
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.8,
delay: 0.5,
ease: [0, 0.71, 0.2, 1.01]
}}
/>
motion 컴포넌트가 처음 생성될 때, animate 속성에 적용된 값이 style 또는 inital에 정의된 값과 다르다면 animate속성에 적용된 값으로 자동으로 애니메이션을 적용해 줍니다.
자동으로 적용하길 원치 않는다면 inital 값을 false
로 설정해주세요.
<motion.div animate={{ x: 100 }} initial={false} />
리엑트에서는 컴포넌트가 트리에서 삭제될 경우 '즉시' 사라져버리기 때문에 사라지는 애니메이션을 적용하기 어렵다는 문제가 있습니다.
하지만 AnimatePresence
컴포넌트를 사용하면 사라지는 애니메이션이 보여지는 동안 \
DOM에 유지 되도록 할 수 있습니다.
AnimatePresence 동작원리 파악하기
import { motion, AnimatePresence } from "framer-motion";
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
)}
</AnimatePresence>
Animate의 값을 배열로 설정하면 motion이 각 값을 차례로 처리합니다.
현재 값을 초기 키프레임으로 사용하고 싶다면 null 값을 주면 됩니다.
이렇게 하면 애니메이션 되는 도중에 애니메이션이 시작되더라도 전환이 자연스러워집니다.
또한 코드의 중복을 줄입니다.
각 키프레임은 애니메이션 전체에 걸쳐 균등하게 배치됩니다.
하지만 times를 통해 원하는 타이밍을 지정할 수 있습니다.
times 는 0과 1사이에 숫자로 정의되어있습니다.
<motion.circle
cx={500}
animate={{ cx: [null, 100, 200] }}
transition={{ duration: 3, times: [0, 0.2, 1] }}
/>
Framer motion 에는 hover,tap,drag,focus,inView 와 같이 제스처가 시작될 때 값 집합에 애니메이션을 적용할 수 있습니다.
<motion.button
initial={{ opacity: 0.6 }}
whileHover={{
scale: 1.2,
transition: { duration: 1 },
}}
whileTap={{ scale: 0.9 }}
whileInView={{ opacity: 1 }}
/>
이러한 제스처가 끝나면 다시 애니메이션할 값을 자동으로 파악합니다.
단일 개체에 애니메이션을 설정하는 것은 쉽습니다.
그러나 때로는 DOM 전체에 파생되는 애니메이션이나, 차례로 이뤄지는 애니메이션을 설정하고 싶을 때는 어떻게 할까요??
그럴때는 variants를 써봅시다.
const variants = {
hidden: { opacity: 0 },
visible: { opacity: 1 }
}
motion 컴포넌트에 variants 속성으로 사전에 정의한 내용을 넘겨줍니다.
<motion.div variants={variants} />
초기 상태 initial, 적용할 애니메이션 animate 속성을 variants 객체에 있는 속성 이름으로 지정하면 끝!
<motion.div
initial="hidden"
animate="visible"
variants={variants}
/>
만약 motion 컴포넌트에 자식 요소가 있다면, 자식 요소가 자체 animate속성을 정의하기 전까지 variants의 변화를 상속 받도록 할 수 있습니다.
쉽게 말해, variants에 정의하 속성명을 자식에게 그대로 물려줄 수 있습니다.
const list = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
}
const item = {
hidden: { opacity: 0, y: -100 },
visible: { opacity: 1, y: 0 },
}
return (
<motion.ul
initial="hidden"
animate="visible"
variants={list}
>
<motion.li variants={item} />
<motion.li variants={item} />
<motion.li variants={item} />
</motion.ul>
)
이 경우 li에 달린 variants={item}은 initial="hidden"과 animate="visible"가 자동으로 적용됩니다.
위의 예제에서 볼 수 있는 것 처럼, item에 달린 애니메이션은 모두 동시에 시작됩니다.
하지만 transition에 추가적인 속성을 더해 자식 애니메이션의 실행을 조정할 수 있습니다.
const list = {
visible: {
opacity: 1,
transition: {
when: "beforeChildren",
staggerChildren: 0.3,
},
},
hidden: {
opacity: 0,
transition: {
when: "afterChildren",
},
},
}
함수를 정의해서 각 variant에 동적으로 애니메이션을 설정할 수도 있습니다.
이러한 variant함수들은 컴포넌트의 custom 속성으로 넘어오는 값을 인자로 받습니다.
const variants = {
visible: i => ({
opacity: 1,
transition: {
delay: i * 0.3,
},
}),
hidden: { opacity: 0 },
}
return items.map((item, i) => (
<motion.li
custom={i}
animate="visible"
variants={variants}
/>
))
대부분의 UI 인터렉션에 맞춰 애니메이션이 실행되지만,
좀더 복잡한 시퀀스를 구현하고 싶다면 useAnimationControls
훅으로 애니메이션을 수동 시작/중지할 수 있습니다.
import { useEffect, useState } from "react";
import { motion, useAnimationControls } from "framer-motion";
export default function App() {
const [show, setShow] = useState(false);
const controls = useAnimationControls();
useEffect(() => {
if (show) {
controls.start({ scale: 6 });
}
}, [controls, show]);
return (
<div className="wrap">
<motion.h1 animate={controls}>{show ? "Wow!" : "..."}</motion.h1>
<button onClick={() => setShow(true)}>setShow(true)</button>
</div>
);
}
motion 컴포넌트는 60fps 애니메이션에 최적하된 DOM 요소 입니다.
숫자, 숫자를 포함한 문자열, 16진수 또는 RGB 등 원하는 값만 넣으면 motion이 알아서 다 해줍니다.
일반적으로 같은 유형끼리 애니메이션이 가능하지만,
HTML의 x-y,width-hieght,top,left,right,bottom 은 다른 값 유형이더라도 자유롭게 애니메이션될 수 있습니다.
<motion.div
initial={{ x: "100%" }}
animate={{ x: "calc(100vw - 50%)" }}
/>
css transform 속성은 GPU에 의해 가속화 되므로 애니메이션 하기 좋은 속성이죠.
여러 값이 있을 때 translate => scale => rotate => skew 순대로 적용되지만,
원한다면 transform Tmeplate 속성으로 사용자 정의도 가능합니다.
const transform = ({ rotate, x }) => `rotate(${rotate}) translateX(${x})`
<motion.div transformTemplate={transform} />
motion은 css 변수값도 애니메이션 처리할 수 있습니다.
(타입스크립트를 쓰고 있다면 as any로 타입 처리를 해주세요)
<motion.ul
initial={{ '--rotate': '0deg' } as any}
animate={{ '--rotate': '360deg' } as any}
>
<li style={{ transform: 'rotate(var(--rotate))' }} />
</motion.ul>
pathLength,pathSpacing,pathOffset 속성을 사용하여
SVG 애니메이션을 만들 수 있습니다.
0~1사이의 값으로 설정되며, 여기서 1은 path의 측정된 길이입니다.
패스 에니메이션은 circle,ellipse,line,path,polygon,polyline,rect 와 호환됩니다.
const variants = {
start: { pathLength: 0, fill: "rgba(255, 255, 255,0)" },
end: { pathLength: 1, fill: "rgba(255, 255, 255, 1)" }
};
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 500">
<motion.path
variants={variants}
initial="start"
animate="end"
transition={{
default: { duration: 1.8 },
fill: { duration: 1, delay: 1.1 }
}}
d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"
/>
</svg>
Framer motion의 강력한 강점 중 하나는 손쉽게 레이아웃 애니메이션을 설정할 수 있다는 점입니다.
css 레이아웃은 사실 힘들고 어려운 일입니다.
예를 들어, height를 100px에서 500px로 만들고 트랜지션을 적용하면 리플로우(Reflow)가 발생하면서 성능이 저하됩니다.
게다가 트랜지션으로는 한계가 있습니다.
justify-content 상태일때 flex-start와 flex-end를 트랜지션할 수 있을까요??
motion을 사용한다면 가능합니다.
<motion.div layout />
재렌더링의 결과로 발생하느 모든 레이아웃 변경사항을 감지해 애니메이션을 실행합니다.
다음과 같은 조합이 될 수 있습니다.
모든 레이아웃 애니메이션은 transform 속성을 사용하므로 부드럽게 구현됩니다.
transform을 사용하는 레이아웃 애니메이션은 때때로 자식 요소가 시각적으로 왜곡되곤 하는데요, 이를 수정하기 위해 요소의 첫 번째 자식에도 레이아웃 속성을 지정할 수 있습니다.
box-shadow나 border-raius 속성이 왜곡될 수도 있습니다만,
값이 설정되어있는 motion컴포넌트는 이를 자동으로 수정합니다.
만약 이런 값들을 애니메이션화 하지 않을거라면 그냥 style로 지정하는것이 좋습니다.
<motion.div layout style={{ borderRadius: 20 }} />
레이아웃 애니메이션도 transition 속성으로 커스터마이징 가능합니다.
<motion.div layout transition={{ duration: 0.3 }} />
특별히 레이아웃 애니메이션에만 트랜지션을 지정하고 싶다면 layout속성을 설정해주세요.
<motion.div
layout
animate={{ opacity: 0.5 }}
transition={{
opacity: { ease: "linear" },
layout: { duration: 0.3 }
}}
/>
스크롤 가능한 요소 내에서 레이아웃 애니메이션을 실행하고 싶다면
layoutScroll 속성을 꼭 넣어주세요
그럼 라이브러리가 자식을 측정할 때 요소의 스크롤 오프셋을 고려할 수 있습니다.
<motion.div layoutScroll style={{ overflow: "scroll" }}/>
컴포넌트가 리렌더링 되면서 레이아웃이 변경되면 애니메이션이 자동으로 트리거 됩니다.
그런데 만약 동시에 리렌더링 되는 일은 없지만 서로의 레이아웃에 영향을 주는 컴포넌트가 함께 있다면 어떨까요?
예를 들어 FAQ등에서 자주 쓰는 아코디언 메뉴가 있겟죠
메뉴 하나를 클릭해 펼치는 경우를 생각해봅시다.
클릭된 메뉴는 리렌더링 되지만 그 옆에 있는 다른 메뉴는 레이아웃 변경을 감지하지 못합니다.
이럴때는 LayoutGroup
컴포넌트를 사용해 함께 묶어주세요
import { LayoutGroup } from "framer-motion"
function List() {
return (
<LayoutGroup>
<Accordion />
<Accordion />
</LayoutGroup>
)
}
이렇게 그룹화된 컴포넌트 중 하나에서 레이아웃 변경이 감지되면 부가적인 리렌더링 없이 다른 모든 컴포넌트들에게도 레이아웃 애니메이션이 발생합니다.
layoudId 속성을 가진 기존 컴포넌트와 일치하는 새로운 컴포넌트가 추가될 경우, 이전 컴포넌트에서 자동으로 애니메이션이 적용됩니다.
이를 통해 이전 컴포넌트에서 새 컴포넌트를 보여줄 때 자연스러운 처리가 가능합니다.
<ul>
{tabs.map((item) => (
<li
key={item.label}
className={item === selectedTab ? "selected" : ""}
onClick={() => setSelectedTab(item)}
>
{`${item.icon} ${item.label}`}
{item === selectedTab ? (
<motion.div className="underline" layoutId="underline" />
) : null}
</li>
))}
</ul>
Motion은 리엑트에서 제공하는 기본 이벤트 리스너를 확장해 제스처 애니메이셔늘 제공합니다.
motion 컴포넌트에 hover,tap,pan,viewport,drag등의 제스처 이벤트를 붙일 수 있습니다.
motion 컴포넌트는 다양한 애니메이션 제스쳐 속성을 제공합니다.
whileHover,whileTap,whileFocus,whileDrag,whileInview
<motion.button
whileHover={{
scale: 1.2,
transition: { duration: 1 },
}}
whileTap={{ scale: 0.9 }}
/>
애니메이션할 값을 직접 입력해도 되고, variant속성을 통해 정의된 이름으로 설정할 수도 있습니다.
이 경우, variants에 정의된 속성명을 자식 요소에서 그대로 사용할 수 있습니다.
const buttonVariants = {
hover: { scale: 2, rotzteZ: 90 }
};
<motion.button
whileHover="hover"
variants={buttonVariants}
/>
호버 제스처는 포인터가 컴포넌트 위로 이동하거나 컴포넌트를 떠날 때 감지합니다.
onMouseEnter, onMouseLeave와 다르게 실제 마우스 이벤트 결과가 있을때만 실행됩니다.
<motion.a
whileHover={{ scale: 1.1 }}
onHoverStart={e => {}} // 컴포넌트 위로 포인터를 가져갈 때 실행되는 콜백 함수
onHoverEnd={e => {}} // 컴포넌트에서 포인터가 떠날 때 실행되는 콜백 함수
/>
tap 제스쳐는 포인터가 동일한 컴포넌트를 눌렀다 뗄 때 감지됩니다.
어떤 구성요소에 탭핑이 성공적으로 완료되면 tap이벤트를 실행하고, 컴포넌트 외부에서 탭핑이 종료되면 tapCancel이벤트를 실행합니다.
만약 컴포넌트가 드래그 가능한 컴포넌트의 자식인 경우 제스처 중 포인터가 3픽셀 이상 이동하면 탭 제스처가 자동으로 취소됩니다.
<motion.div
whileTap={{ scale: 0.9 }}
onTap={e => {}} // 탭 제스처가 성공적으로 종료될 때 실행되는 콜백 함수
onTapStart={e => {}} // 탭 제스처가 시작될 때 실행되는 콜백 함수
onTapCancel={e => {}} // 탭 제스처가 컴포넌트 외부에서 끝날 때 실행되는 콜백 함수
/>
포인터가 구성요소를 누르고 3픽셀 이상 이동할 때를 인식합니디ㅏ.
포인터를 놓으면 제스처가 종료됩니다.
drag='x'를 통해 x방향 또는 y방향으로만 드래그 되도록 설정할 수 있습니다.
<motion.div
drag
dragConstraints={{top:100}} // 드래그 허용 영역 (useRef hook으로 생성된 컴포넌트도 지정 가능)
dragSnapToOrigin // 드래그한 요소를 놓으면 원점으로 되돌아감
dragElastic // constraints 밖으로 허용되는 움직임의 정도 (0=없음, 1=전체)
dragTransition={} // 드래그 요소의 관성(inertia) 애니메이션을 지정
/>
motion에는 2가지의 스크롤 애니메이션 유형이 있습니다.
모션 값 (Motion Vlaues)와 useScroll 훅을 사용해 구현 할 수 있습니다.
useScroll
기본적으로 페이지 스크롤을 추적하며, 4개의 모션 값을 반화합니다.
- scrollX,scrollY, : 절대 위치 값
- scrollXProgress,scrollYProgress : 정의된 오프셋 사이의 스크롤 값 (0 -1)
import { motion, useScroll } from "framer-motion"
function Component() {
const { scrollYProgress } = useScroll();
return (
<motion.div style={{ scaleX: scrollYProgress }} />
)
}
whileInview 속성 세트(&트랜지션) 를 정의하여 요소가 뷰에 있을 때 애니메이션을 적용할 수 있습니다.
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={} // 뷰포트가 감지되는 방식을 정의하는 뷰포트 옵션 개체
onViewportEnter={()=>{}} // 뷰포트에 진입할 때 호출 (단, 브라우저가 IntersectionObserver를 지원해야 함)
onViewportLeave={()=>{}} // 뷰포트에서 떠날 때 호출 (단, 브라우저가 IntersectionObserver를 지원해야 함)
/>
Viewport Options
function Component() {
const scrollRef = useRef(null)
return (
<div ref={scrollRef}>
<motion.div
variants={emojiVariants}
initial="hidden"
whileInView="visible"
viewport={{ root: scrollRef, once: true, amount: 0.3 }}
/>
</div>
)
}
두 값 사이에 애니메이션을 적용할 때 사용합니다.
<motion.div
animate={}
transition={{ duration: 1 }}
/>
이렇게 정의한 트랜지션은 모든 속성에 적용되지만, 값에 각각 다른 트랜지션을 적용하는 것도 가능합니다.
<motion.div
transition={{
default: { duration: 1 },
opacity: {
delay: 0.3
},
y: {
type: "spring",
damping: 3,
stiffness: 50,
restDelta: 0.01,
duration: 0.3
}
}}
/>
다음과 같은 속성을 통해 트랜지션을 보다 심미적으로 만들 수 있습니다.
motion의 좋은 점은 보다 사실적인 애니메이션을 표현해준다는 것입니다.
모든 모션 컴포넌트는 내부적으로 MotionValue를 사용해 애니메이션 값의 상태와 속도를 추적합니다.
이는 자동으로 생성되지만 사용자가 원한다면 수동으로 생성해 넣을 수 있습니다.
수동으로 MotionValue를 설정하는 경우,
MotionValue는 useMotionValue 훅으로 생성할 수 있습니다.
문자열 또는 숫자로된 초기값을 지정할 수 있습니다.
사용자는 set()메서드로 업데이트 할 수 있고, get()으로 값을 읽어올 수 있습니다.
getVelocity()로 초당 계산된 속도를 받아 올 수도 있습니다.
세부 메서느는 여기를 참고하세요.
재밌는 점은 MotionValue가 바뀌더라도 컴포넌트는 리렌더링되지 않는다는 것입니다.
<motion.div/>
가 움직인다고 한들 이 컴포넌트는 다시 랜더링되지 않기 때문에 여기 붙어있는 api가 계속해서 호출된다거나 하는 불상사는 없겠죠
import { motion, useMotionValue } from "framer-motion"
function App() {
const x = useMotionValue(0)
x.set(100);
x.get();
x.getVelocity();
}
일단 motionValue가 생성되면, 시각적 속성을 줬던 것 처럼 모션 컴포넌트에 연결할 수 있습니다.
HTML이라면 style 속성을 통해, SVG라면 SVG 속성을 통해 넣어주면 됩니다.
const x = useMotionValue(0);
const cx = useMotionValue(0);
<motion.div style={{ x }} /> // HTML
<motion.circle cx={cx} /> // SVG
하나의 MotionValue를 여러 개의 컴포넌트에 넣는 것도 가능합니다. 그럼 그 MotionValue를 바라보는 모든 컴포넌트에 영향이 가겠죠
onChange 메서드로 MotionValue에 리스너를 추가할 수도 있습니다.
리엑트 컴포넌트 안에서 onChange를 호출할 때는 useEffect 훅으로 꼭 감싸주세요.
onChange는 unsubscribe 함수를 반환하므로 subscriber 중복을 막기 위해 useEffect() 안에서 쓰여야 합니다.
useEffect(() => x.onChange(latest => {}), [])
useTransform 등의 훅을 써서 MotionValue값을 구성할 수 있습니다.
useTransform : 값 범위를 다른 값 범위로 매핑할 수 있습니다.
박스를 왼쪽으로 드래그하면 scale(3)로, 오른쪽으로 드래그하면 scale(0.1)로 바뀌도록 하는 예제입니다.
import { useEffect } from "react";
import { motion, useMotionValue, useTransform } from "framer-motion";
import "./styles.css";
export default function App() {
const x = useMotionValue(0);
const scale = useTransform(
x,
// Map x from these values:
[-400, 400],
// Into these values:
[3, 0.1]
);
useEffect(() => {
x.onChange(() => {
console.log(x.get());
});
}, [x]);
return (
<div className="app">
<motion.div
className="box"
drag="x"
dragSnapToOrigin
style={{ x, scale }}
/>
</div>
);
}