랜딩 페이지에서 가장 먼저 시선을 붙드는 요소 중 하나가 자연스럽게 전환되는 텍스트 애니메이션입니다. 단순한 fade in/out이 아니라, 글자 하나하나가 순서대로 솟아오르며 문구가 바뀌는 효과 말이죠. 이 글에서는 그 효과를 Framer Motion으로 구현한 과정을, 설계 의도부터 코드까지 차근차근 풀어봅니다.
1. 완성된 모습
code
Building [accessible UIs] with care.
Building [scalable systems] with care.
Building [beautiful interfaces] with care.
대괄호 안의 문구가 2초마다 자동으로 바뀌고, 그때마다 각 글자가 아래에서 위로 솟아오르는 spring 애니메이션이 붙습니다. 정적인 한 줄이 살아 움직이는 한 줄로 바뀌는, 작지만 인상적인 차이입니다.
2. 설계 방향
코드를 짜기 전에 요구사항부터 못 박았습니다. 글자(character) 단위로 쪼개 stagger 애니메이션을 걸 것, 자동 로테이션과 수동 제어(next/previous/jumpTo)를 모두 지원할 것, forwardRef로 외부에서 애니메이션을 제어할 수 있게 할 것, splitBy 옵션으로 글자·단어·줄 단위를 고를 수 있게 할 것, 그리고 TypeScript로 타입 안전성을 확보할 것.
요구사항을 먼저 정리해두니, 컴포넌트가 가져야 할 prop과 ref API의 윤곽이 자연스럽게 잡혔습니다. "재사용 가능한 컴포넌트"를 목표로 둔 순간, 단순한 데모와는 설계가 달라졌습니다.
3. 핵심 구현
3-1. 텍스트를 글자 단위로 분리
가장 먼저 풀어야 할 문제는 "글자를 어떻게 정확히 쪼개느냐"였습니다. 단순히 문자열을 쪼개면 이모지나 한글 같은 다중 바이트 문자가 깨지기 때문에, Intl.Segmenter를 우선 쓰고 지원하지 않는 환경에서는 Array.from으로 폴백했습니다.
staggerFrom 옵션으로 애니메이션이 퍼져나가는 기점을 바꿀 수 있습니다. first는 첫 글자부터 순서대로, last는 마지막 글자부터 역순으로, center는 가운데에서 바깥으로 퍼지듯, random은 무작위 순서로 등장합니다. 같은 문구라도 기점 하나로 완전히 다른 인상을 줄 수 있다는 점이 재미있습니다.
AnimatePresence가 key 변화를 감지해, 이전 문구는 위로 빠져나가고 새 문구는 아래에서 올라오게 만듭니다. 여기서 각 글자를 감싼 overflow: hidden 래퍼가 중요한데, 애니메이션 도중 글자가 영역 밖으로 삐져나와 보이는 걸 잘라줍니다. 이 한 겹 덕분에 "글자가 칸 안에서 깔끔하게 교체되는" 느낌이 완성됩니다.
auto prop으로 자동 로테이션을 켜고 끌 수 있고, rotationInterval로 전환 간격을 밀리초 단위로 조절합니다. cleanup에서 clearInterval을 잊지 않는 것이 포인트입니다. 빠뜨리면 인터벌이 쌓여 전환이 점점 빨라지는 버그를 만나게 됩니다.
5. 외부 제어: useImperativeHandle
forwardRef와 useImperativeHandle을 엮으면, 부모 컴포넌트에서 애니메이션을 직접 조종할 수 있습니다.
typescript
useImperativeHandle(ref, () => ({ next, // 다음 텍스트로 previous, // 이전 텍스트로 jumpTo, // 특정 인덱스로 이동 reset, // 첫 번째 텍스트로 리셋}));