0ju-log
💻 Frontend

쌩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개 이상일 때, 선택 후 이동되는 태그의 위치

그 후, variantcustom을 사용하여 다음과 같이 코드를 짜주었어요.

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"
>

이를 통해 조건으로 엄청나게 복잡했던 전 코드가 variantcustom으로 굉장히 간단해졌어요!

⭐ 태그 위치 (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 사이트

🔗 참고

https://motion.dev/docs/react-animation