쌩CSS에서 framer-motion으로 갈아타기
1️⃣ 시작하기 전에
안녕하세요! IT연합동아리 디프만 16기에서 이븐하게 팀 소속 장영주입니다!
저는 말랑말랑한 디지털 명함 서비스 TOOK의 프론트엔드 개발자로 활동하고 있어요. 제가 맡은 파트에서 의미 있었던 기술들과 경험인 CSS에서 framer-motion으로의 리팩토링을 글로 담아보려고 해요!! 😊
2️⃣ 구현해야 할 화면
아래의 화면은 제가 구현해야 했었던 화면이에요.
해당 화면에서 고려해야 할 부분은 다음과 같아요.
☑️ 각 태그는 지정된 위치에 있어야 한다.
☑️ 각 태그는 가운데의 볼을 중심으로 퍼져있어야 하며, 이는 반응형이어야 한다.
☑️ 선택된 태그의 개수에 따라 각 선택된 태그의 위치가 다르다.
☑️ 이 개수가 일정 개수 이상이 되면, 각 선택 후의 태그는 지정 위치가 생긴다.
3️⃣ 우선 CSS로 구현 시작
⭐ 이동 애니메이션 (쌩CSS)
태그를 선택하면 tagArray에 들어가게 되고, 해당 배열에 있는지 없는지에 따라 위치를 구별시켰어요.
태그를 선택할 시 볼 안으로 이동되는 애니메이션이 발생하기 때문에, transition을 사용했어요.
transition
transition : 요소의 상태 변화를 부드럽게 하는 애니메이션 속성
원래 위치를 기준으로 상대적으로 이동하며, position, margin, top/right/bottom/left 없이도 부드럽고 깔끔하게 위치를 조정할 수 있어요.
속성 표
| 속성 | 설명 | 예시 |
|---|---|---|
transition-property | 애니메이션 적용 대상 | transition-property: background-color, transform; |
transition-duration | 애니메이션 지속 시간 | transition-duration: 0.5s; |
transition-timing-function | 애니메이션 속도 조절 | transition-timing-function: cubic-bezier(0.68, -0.55, 0.27, 1.55); |
transition-delay | 애니메이션 시작 전 지연 시간 | transition-delay: 2s; |
transition은 다음과 같은 방식으로 여러 속성을 한 번에 작성할 수 있어요transition: [property] [duration] [timing-function] [delay];ex)transition: all 0.5s ease-in-out 2s;
<Tag
message={tag.message} // 화면에 표시할 텍스트는 label 사용
size="lg"
className={cn(
'transition-all duration-500 ease-in-out',
!tagArray.includes(tag.value) && tag.animation,
tag.className,
tagArray.includes(tag.value) ? getTagPositions(tag.position, tag) : tag.position,
)}
onClick={() => handleTagClick(tag.value, formMethod)} // 클릭 시 value 전달
/>
</div>
⭐ 태그 위치 (쌩CSS)
예전에 진행했던 프로젝트에서, 반응형에서 지정된 위치에 올바르게 들어가야 하는 것을 구현했었어요. 그래서 그 방식을 그대로 사용하려고 했어요.
우선 각 태그의 형식은 다음과 같아요.
{
id: 1,
message: '대표 프로젝트',
className: tagStyle,
// position: 기본 태그의 위치
position: 'bottom-28 right-0',
// fixedPosition: 선택된 태그가 4개 이상일 때, 선택 후 이동되는 태그의 위치
fixedPosition: 'right-[calc(50%-96px)] bottom-[calc(50%-5px)]',
title: '프로젝트 제목',
description: '김디퍼님의 프로젝트 링크',
animation: 'downandup',
},
처음에 반응형에 대응하고자 top, bottom, left, right / calc 속성을 사용하여 위치를 구성하였어요.
calc()가 반응형에서 중요한 이유
calc : 상대적인 크기와 절대적인 크기를 조합하여 계산하는 방식
부모 요소가 width: 100vw 같은 방식으로 설정되어 있으면, calc(50% - 3px)는 뷰포트 크기에 따라 자동으로 변화해요
.parent {
width: 100vw;
}
.child {
position: absolute;
left: calc(50% - 3px);
}
- 화면 크기가
- 1200px일 때, → 600px - 3px = 597px
- 800px일 때, → 400px - 3px = 397px
⇒ 부모 크기가 변해도 요소는 정중앙에서 3px 왼쪽으로 이동한 위치를 유지!
이렇게 구성하면, 화면 크기가 바뀌어도 태그의 위치가 바뀌지 않고 원 안에 고대로 있게 되었어요!
하지만 이로 인해 많은 조건이 나눠져요.
- 이동 애니메이션이 선형적으로 진행되기 위해선 각 태그의 선택 전 위치, 선택 후 이동된 위치가 (
top||bottom), (left||right)의 조합이 일치해야 돼요.
아래 코드를 열어보면 bottom-right 조합, bottom-left 조합 등 각 태그마다 이 조합이 다 다른 것을 알 수 있어요.
{
message: '대표 프로젝트',
position: 'bottom-28 right-0',
fixedPosition: 'right-[calc(50%-96px)] bottom-[calc(50%-5px)]',
},
{
message: '작성한 글',
position: 'bottom-16 left-12',
fixedPosition: 'left-[calc(50%-96px)] bottom-[calc(50%-5px)]',
},
{
message: 'SNS',
position: `right-0 top-16`,
fixedPosition: 'right-[calc(50%)] top-[calc(50%+9px)]',,
},
{
message: '취미',
position: 'bottom-8 right-20',
fixedPosition: 'right-[calc(50%-60px)] bottom-[calc(50%-50px)]',
},
{
message: '최근 소식',
position: 'bottom-40 left-0',
fixedPosition: 'left-[calc(50%+4px)] bottom-[calc(50%+39px)]',
},
{
message: '활동 지역',
position: `top-20 left-0`,
fixedPosition: 'left-[calc(50%-88px)] top-[calc(50%-80px)]',
},
{
message: '소속 정보',
position: 'left-40 top-0',
fixedPosition: 'left-[calc(50%-40px)] top-[calc(50%-124px)]',
},
];
또한, 선택된 태그의 개수에 따라 선택 후 이동되는 태그의 위치가 달라지는 조건으로 인해 개수에 따른 조건이 또 나눠지게 돼요.
그 결과…
짜잔… 어마어마하게 많은 조건문과 그에 따른 위치 지정 코드가 완성이 되었답니다..
- 우선적으로 태그의 개수를 먼저 조건으로 둔 후,
- 각 태그가 어떤 위치에 있는지에 따라 이동 위치를 일치하는 top, bottom, left, right 조합으로 따로 나타내었어요.
이 코드를 반드시 리팩해야겠다고 다짐했어요.. 하 근데 이 많은 걸 어떻게 간단하게 하지..
라고 생각했을 때, 우리 팀원분의 한마디
“framer-motion 써서 리팩해봐"
그렇게 framer-motion을 도입하게 되었어요 ㅎ
4️⃣ 근데 framer-motion 너 누군데..?
근데 저는.. framer-motion이 정확히 모르는 사람이었고, 한 번도 안 써봤던 사람인지라, 우선 공식 문서부터 읽어봐야겠다고 생각했아요.
⭐ framer-motion
framer-motion ? React에서 애니메이션을 만들 때 아주 쉽게 쓸 수 있는 라이브러리
- css의 복잡한 transition을 더욱 간편하게 사용할 수 있도록 해줌
- 직관적인 코드로 손쉽게 애니메이션을 제작하게 해줌
framer-motion의 기본적인 3가지 state
- initial state : 애니메이션 시작 전의 상태예요. (위치, 스타일 등)
- target state : 애니메이션 진행 후의 상태예요. (위치, 스타일 등)
- transition state : 애니메이션이 진행될 때의 상태예요. (위치, 스타일 등)
framer-motion의 기본적인 사용 방법
<motion /> component에 props를 전달하는 형식을 통해 애니메이션을 구현할 수 있어요.
- ex)
<motion.div animate={{ x: 0 }} />
예시 코드
import { motion } from 'framer-motion';
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
</motion.div>
⭐ 이동 애니메이션 (framer-motion)
variants로 상태 관리하기
처음엔 단순히 motion.div에 직접 initial, animate, transition props를 줬는데요,
컴포넌트가 많아지거나 상태가 복잡해질수록 prop으로 계속 넘겨주는 방식이 정신없어지더라구요…
그래서 등장한 게 바로 👉 variants!
variants ? 애니메이션 상태들을 이름 붙여서 따로 정리해두고, 컴포넌트에서는 그 이름만 불러서 사용하는 방식이에요.
custom ? variants 안에 정의된 값들에 동적으로 값을 넘기고 싶을 때 사용해요
우선 variant에서 각 경우마다 position을 다르게 해주기 위해, 각 태그에 position을 넣어줬어요
{
id: 1,
message: '대표 프로젝트',
className: tagStyle,
value: 'project',
title: '프로젝트 제목',
description: '김디퍼님의 프로젝트 링크',
animation: 'downandup',
firstEndPosition: { x: -50, y: -60 },
secondEndPosition: { x: -50, y: -100 },
thirdEndPosition: { x: -50, y: -20 },
fixedEndPosition: { x: -10, y: -50 },
},
firstEndPosition: 해당 태그가 첫 번째로 선택된 태그일 때의 위치secondEndPosition: 해당 태그가 두 번째로 선택된 태그일 때의 위치thirdEndPosition: 해당 태그가 세 번째로 선택된 태그일 때의 위치fixedEndPosition: 선택된 태그가 4개 이상일 때, 선택 후 이동되는 태그의 위치
그 후, variant와 custom을 사용하여 다음과 같이 코드를 짜주었어요.
const getVariants = {
// 선택되지 않은 태그 -> 기본 태그 위치
unselected: (tag: TagConfigItem) => ({
x: tag.initialPosition.x,
y: tag.initialPosition.y,
transition: { duration: 0.5 },
}),
// 선택된 태그 -> 이동 후의 위치
selected: (tag: TagConfigItem & { index: number }) => {
// 만약 선택 태그의 개수가 일정 이상일 시, 볼 내 지정된 위치로 이동
if (tagArray.length >= MAX_DYNAMIC_TAGS) {
return {
x: tag.fixedEndPosition.x,
y: tag.fixedEndPosition.y,
transition: { duration: 0.5 },
};
}
// 인덱스에 따라 다른 endPosition 사용
const positions = [tag.firstEndPosition, tag.secondEndPosition, tag.thirdEndPosition];
const pos = positions[tag.index] || tag.firstEndPosition; // 디폴트는 first
return {
x: pos.x,
y: pos.y,
transition: { duration: 0.5 },
};
},
};
...
<motion.div
key={tag.id}
custom={{ ...tag, index }} {/* variant에 정의된 값들에 tag, index를 넘김 */}
variants={getVariants} {/* 지정한 variant 넘김 */}
initial="unselected"
animate={isSelected ? 'selected' : 'unselected'}
transition={{
ease: 'easeInOut',
duration: 0.5,
}}
className="absolute transform"
whileTap="tap"
>
이를 통해 조건으로 엄청나게 복잡했던 전 코드가 variant와 custom으로 굉장히 간단해졌어요!
⭐ 태그 위치 (framer-motion)
가장 중요한 반응형에도 달라지지 않는 태그의 지정 좌표를 어떻게 하면 바꿀 수 있을까 하고 생각해봤어요.
이는 단순하게, motion.div의 className에 absolute left-1/2 top-1/2 z-tag -translate-x-1/2 -translate-y-1/2 transform 만 넣어줘도 해결이 완료돼요!
- 이 className 조합은 기준점을 화면 중앙으로 맞춰주는 역할을 해요.
- 결과적으로, 이 조합은 motion.div의 기준 위치를 정확히 중앙으로 맞추고 그 상태에서
variants로 x, y 값을 조절해 위치를 자유롭게 이동시키는 구조가 됩니다!
5️⃣ 최종 구현 화면
이렇게 해서 애니메이션을 모두 구현하는 데 성공하였어요!!
6️⃣ 마치며
솔직히 너무너무 힘들었던 만큼, 너무너무 뿌듯한 태스크인 것 같아요 ㅎㅅㅎ
디프만 해커톤 때도 5시간 동안 코드 삽질했는데 안되고, 3일 내내 방법 찾다 겨우겨우 쌩CSS로 구현했는데 이렇게 framer-motion으로 다시 한 번 리팩하며 그때의 고생이 보람으로 돌아오는 느낌이었어요.
처음엔 animation 속성을 그냥 framer-motion으로 치환하면 되겠지~ 싶었는데, 막상 하다 보니 기존 CSS에서는 자연스럽게 되던 디테일한 transition들이 framer-motion에선 따로 처리해줘야 하는 경우도 있었고, variants로 바꾸면서 동작 흐름을 다시 설계해야 했어요. 그래서 마냥 “라이브러리 쓰니까 편하다!“는 느낌은 아니었고, 직접 스타일을 짜봤기에 더 잘 이해하고 다룰 수 있었던 것 같아요.
다시 돌아보면 힘들고 눈물 날 뻔했던 순간이었지만 😢 그만큼 실력도, 자신감도 성장할 수 있었던 진짜 값진 경험이었어요.
마지막으로, 저희 팀이 열심히 만든 디지털 명함 서비스 TOOK에도 많은 관심 부탁드려요!! (곧 앱 출시도 된답니다 ㅎㅅㅎ)
🔗 Github
🔗 TOOK 사이트