Motion: React에서 선언적으로 애니메이션하기
이전 포스트에서 Pure CSS만으로 글리치 텍스트를 만들어봤다. CSS의 한계도 확인했다 — 진짜 랜덤, 마우스 반응, 물리 기반 움직임은 불가능하다. 이번에는 나머지 22개 중 11개가 사용하는 Motion(구 Framer Motion)을 파헤친다.
왜 Motion인가?
react-bits의 텍스트 애니메이션 23개 중 11개가 Motion을 사용한다. 왜 하필 Motion일까?
1. React 통합 — motion.div는 일반 div처럼 사용한다. props로 애니메이션을 제어하고, state가 바뀌면 자동으로 반응한다. 별도의 ref 조작이나 DOM 직접 접근이 필요 없다.
2. 선언적 API — initial, animate, exit로 "상태"를 선언하면 중간 과정은 Motion이 알아서 채워준다. "어떻게 움직일지"가 아니라 "어디서 어디로 갈지"만 정의하면 된다.
3. Spring Physics — CSS는 시간 기반(duration + easing)이다. Motion은 물리 기반(mass, stiffness, damping)을 지원한다. 실제 물체의 움직임처럼 자연스럽다.
| 기준 | CSS Animation | Motion |
|---|---|---|
| 제어 방식 | 시간 기반 (duration, easing) | 물리 기반 (spring) + 시간 기반 |
| React 통합 | className 토글 | props 직접 바인딩 |
| Exit 애니메이션 | 불가능 (DOM 제거 시) | AnimatePresence |
| 스크롤 연동 | scroll-timeline (제한적) | useScroll + useVelocity |
| 번들 크기 | 0 | ~33KB (tree-shaken) |
이 포스트에서는 11개 컴포넌트를 기능별로 묶어서 살펴본다.
motion 컴포넌트 기초 — BlurText, DecryptedText, TrueFocus
Motion의 핵심은 motion.span, motion.div 같은 래퍼 컴포넌트다. 기존 HTML 요소에 애니메이션 props를 추가한 버전이다.
💡 initial → animate → exit
<motion.span
initial={{ opacity: 0, y: 30, scale: 0.5 }} // 마운트 시
animate={{ opacity: 1, y: 0, scale: 1 }} // 목표 상태
exit={{ opacity: 0, y: -30, scale: 0.5 }} // 언마운트 시
transition={{ type: 'spring', damping: 20, stiffness: 170 }} // 어떻게
>
Hello
</motion.span>
위 코드가 실제로 어떻게 동작하는지 확인해보자:
transition={{ type: "spring", damping: 20, stiffness: 170 }}initial → animate → exit는 컴포넌트의 생명주기별 목표값이다. 마운트되면initial에서animate로,AnimatePresence안에서 사라지면exit으로 전환된다.transition은 그 전환을 얼마나 걸려서, 어떤 곡선으로 수행할지를 결정한다.duration,ease,type: 'spring',repeat등이 여기에 들어간다.
Motion이 initial과 animate 사이의 중간 프레임을 자동으로 만들어준다. CSS transition과 비슷하지만, 조건부 렌더링({show && ...})과 자연스럽게 결합되고, filter, transform, opacity를 동시에 다루는 게 훨씬 간편하다.
BlurText — 세 가지 레이어로 만드는 등장 효과
위에서 흐릿하게 내려오며 하나씩 나타나는 효과다 — 텍스트와 방향을 바꿔가며 확인해보자.
안녕하세요 세상!
BlurText의 애니메이션은 세 가지 레이어가 겹쳐 있다:
1. 이동 — y: -50 → 5 → 0. 단순히 직행하지 않고, 5px만큼 지나쳤다가 돌아온다. 이 미세한 오버슈트가 자연스러운 움직임을 만든다.
2. 블러 해제 — blur(10px) → 5px → 0px, opacity: 0 → 0.5 → 1. times: [0, 0.5, 1]로 중간 상태를 넣는 2단계 전환이다.
시점 (times) | y | blur | opacity |
|---|---|---|---|
| 0 (시작) | -50 | 10px | 0 |
| 0.5 (중간) | 5 | 5px | 0.5 |
| 1 (끝) | 0 | 0px | 1 |
3. Stagger — 위 두 레이어를 글자마다 시간차를 두고 적용한다:
{elements.map((segment, index) => (
<motion.span
initial={fromSnapshot}
animate={inView ? animateKeyframes : fromSnapshot}
transition={{
duration: totalDuration,
times: [0, 0.5, 1],
delay: (index * delay) / 1000, // 👈 index에 비례하는 딜레이 = stagger
}}
>
{segment}
</motion.span>
))}
animateBy로 분할 단위를 words(단어)와 letters(글자)로 바꿀 수 있다. 이동 + 블러 해제가 동시에 진행되고, stagger로 시간차를 두면서 "위에서 흐릿하게 내려오며 하나씩 나타나는" 효과가 완성된다.
DecryptedText — motion.span은 래퍼일 뿐
Motion을 쓴다고 모든 애니메이션이 Motion의 트윈 엔진으로 움직이는 건 아니다. DecryptedText가 좋은 예시다 — 먼저 호버해서 동작을 확인해보자. 순차적 표시를 켜고 표시 방향을 바꿔보면 차이가 확연하다.
글자가 랜덤으로 뒤섞이다가 원본으로 돌아온다. opacity: 0 -> 1 같은 연속적인 값의 전환이 아니라, 글자 자체가 "A" → "X" → "Q" → 원래 글자로 바뀌어야 한다. 이건 CSS나 Motion의 트윈으로는 불가능하다.
핵심은 setInterval로 매 speedms마다 호출되는 shuffleText 함수다:
// DecryptedText.tsx — 실제 애니메이션 엔진은 setInterval
const shuffleText = (originalText, currentRevealed) => {
return originalText
.split('')
.map((char, i) => {
if (char === ' ') return ' ';
if (currentRevealed.has(i)) return originalText[i]; // 공개된 글자는 원본
return availableChars[Math.floor(Math.random() * availableChars.length)];
})
.join('');
};
일괄 모드(기본값)는 전체를 스크램블하다가 maxIterations 후 한번에 원본을 보여준다. 순차 모드(sequential=true)는 매 interval마다 한 글자씩 revealedIndices Set에 추가하면서 점진적으로 공개한다. revealDirection이 start면 앞에서부터, end면 뒤에서부터, center면 가운데서 양쪽으로 번갈아 확장한다:
// center: 짝수 호출 → 오른쪽, 홀수 호출 → 왼쪽
// "ABCDEFG" → D, C, E, B, F, A, G (파문이 퍼지듯)
const nextIndex = revealedSet.size % 2 === 0
? middle + offset
: middle - offset - 1;
데모에서 "원본 문자만 사용"을 켜보면 느낌이 달라지는데, 이때는 외부 문자 대신 원본 텍스트의 글자들만으로 Fisher-Yates 셔플을 한다. "암호 해독" 대신 "퍼즐 조각 맞추기" 느낌이 된다.
motion.span은 여기서 컨테이너 역할만 한다 — 호버 이벤트 핸들링과 HTMLMotionProps 타입 상속이 주 목적이다. 이처럼 "Motion을 사용한다"고 해서 항상 Motion의 애니메이션 엔진이 핵심은 아니다. 때로는 React의 state + setInterval이 본체이고, Motion은 편의 도구일 뿐이다.
TrueFocus — motion.div로 포커스 프레임 이동
TrueFocus는 단어들 사이를 이동하는 포커스 프레임을 구현한다 — 단어 사이를 부드럽게 이동하는 프레임을 확인해보자.
현재 단어의 위치와 크기를 getBoundingClientRect()로 계산하고, motion.div의 animate에 넘긴다.
// TrueFocus.tsx — motion.div가 박스를 부드럽게 이동
<motion.div
className="focus-frame"
animate={{
x: focusRect.x,
y: focusRect.y,
width: focusRect.width,
height: focusRect.height,
opacity: currentIndex >= 0 ? 1 : 0,
}}
transition={{
duration: animationDuration
}}
/>
animate에 새 값을 넘기기만 하면 x, y, width, height가 동시에 부드럽게 전환된다. 비활성 단어의 blur 전환은 CSS transition으로, 프레임 이동은 Motion으로 처리하는 하이브리드 구조다.
Spring Physics — CountUp
💡 스프링 물리란?
CSS의 ease-in-out은 "3초 동안 이 커브를 따라가라"는 시간 기반이다. 스프링은 "이 질량, 탄성, 감쇠를 가진 물체가 목표에 도달할 때까지"라는 물리 기반이다. 도착 시간이 고정되지 않고, 물리 법칙에 따라 결정된다.
3가지 핵심 파라미터:
- stiffness (강성): 스프링이 얼마나 세게 당기는지. 높으면 빠르게 도달한다.
- damping (감쇠): 진동을 얼마나 빨리 멈추는지. 낮으면 오래 흔들린다.
- mass (질량): 관성. 높으면 느리게 반응한다.
CSS의 cubic-bezier()로 스프링 느낌을 흉내 낼 수는 있지만, 진짜 물리 시뮬레이션이 아니라서 "탄성에 의해 목표를 지나쳤다가 되돌아오는" overshoot를 자연스럽게 표현하기 어렵다. 스프링은 파라미터만 바꾸면 overshoot의 크기와 횟수가 물리적으로 결정된다.
💡 useMotionValue — React 밖의 반응형 값
useState와 비슷하지만 값이 바뀌어도 리렌더가 일어나지 않는 값 컨테이너다.
const [count, setCount] = useState(0);
setCount(1000); // 컴포넌트 리렌더
const motionValue = useMotionValue(0);
motionValue.set(1000); // 리렌더 없음, .on('change') 리스너만 호출
.get()으로 현재 값을 읽고, .set()으로 변경하고, .on('change', callback)으로 변경을 구독한다. React는 이 값의 존재조차 모른다.
💡 useSpring — motionValue에 스프링 물리를 입히다
useSpring(motionValue, config)는 입력 motionValue에 스프링 물리를 적용한 새로운 motionValue를 반환한다. .set(1000) 하면 즉시 1000이 되지 않고 물리 궤적을 따른다:
const smoothValue = useSpring(motionValue, {
stiffness: 100, // 강성 — 높을수록 급격한 반응 (기본: 100)
damping: 10, // 감쇠 — 높을수록 진동 없이 빠르게 정지 (기본: 10)
mass: 1, // 관성 — 높을수록 느리고 무거운 움직임 (기본: 1)
restSpeed: 0.01, // 이 속도 이하면 정지 판정
restDelta: 0.01, // 목표와의 차이가 이 값 이하면 정지 판정
});
CountUp
이 스프링 물리가 어떤 느낌인지 확인해보자 — duration을 바꾸면 내부 spring 파라미터가 달라지면서 도달 속도가 변한다.
CountUp에서는 사용자에게 duration이라는 친숙한 인터페이스를 제공하면서, 내부적으로 spring 파라미터를 역산한다:
// duration=1 → damping=60, stiffness=100 (빠르고 단단)
// duration=4 → damping=30, stiffness=25 (느리고 부드러움)
const damping = 20 + 40 * (1 / duration);
const stiffness = 100 * (1 / duration);
const motionValue = useMotionValue(from);
const springValue = useSpring(motionValue, { damping, stiffness });
// 매 프레임 DOM 직접 업데이트 — setState 한 번도 없이
springValue.on('change', latest => {
ref.current.textContent = formatValue(latest);
});
motionValue.set(1000) 한 줄이 전체 애니메이션의 방아쇠다 — useSpring이 감지하고, springValue가 물리 궤적으로 이동하면, .on('change')가 매 프레임 DOM을 직접 수정한다. 중간에 .set(500)으로 목표를 바꾸면 현재 속도를 유지한 채 새 목표로 자연스럽게 방향 전환한다 — CSS transition은 진행 중 목표가 바뀌면 현재 위치에서 처음부터 다시 시작하므로 끊김이 생기지만, 스프링은 물리적으로 연속이다.
AnimatePresence — RotatingText, TextCursor
💡 AnimatePresence란?
React에서 컴포넌트를 조건부로 렌더링할 때({show && <Component />}), show가 false가 되면 즉시 DOM에서 제거된다. 사라지는 애니메이션을 넣을 틈이 없다.
AnimatePresence는 제거되는 컴포넌트에 exit 애니메이션을 적용한 후에 DOM에서 제거한다.
<AnimatePresence mode="wait">
{show && (
<motion.div
key="unique" // 👈 key가 있어야 AnimatePresence가 추적
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }} // 👈 이게 재생된 후 DOM에서 제거
/>
)}
</AnimatePresence>
mode 옵션에 따라 exit/enter 타이밍이 달라진다 — 왼쪽(sync)은 이전 요소와 새 요소가 동시에 겹쳐 보이고, 오른쪽(wait)은 이전 요소가 완전히 사라진 후 새 요소가 등장한다:
mode="sync"mode="wait"RotatingText처럼 같은 자리에서 글자가 교체되는 경우, mode="wait"이 자연스럽다 — sync로 하면 이전 글자와 새 글자가 겹쳐서 어수선해진다.
RotatingText: AnimatePresence + stagger + 캐릭터 분할
RotatingText는 텍스트 배열을 순환하면서, 각 전환마다 글자가 아래에서 올라오고 위로 사라지는 효과를 구현한다 — 글자가 순환하는 동작을 확인해보자.
JSX 구조를 바깥에서 안쪽으로 벗겨보면:
// RotatingText.tsx — 렌더 구조
<motion.span layout> // 너비 변경 시 보간 애니메이션
<span className="sr-only">{currentText}</span> // 스크린리더용 (글자 하나씩 읽기 방지)
<AnimatePresence mode="wait" initial={false}> // exit 관리 + 최초 마운트 시 애니메이션 스킵
<motion.span key={currentTextIndex}> // 👈 key 변경 = 전환 트리거
{elements.map((wordObj, wordIndex) => (
<span> // 단어 래퍼 (줄바꿈 단위)
{wordObj.characters.map((char, charIndex) => (
<motion.span // 각 글자 = 독립 애니메이션
initial={{ y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: '-120%', opacity: 0 }}
transition={{ delay: getStaggerDelay(globalIndex, total) }}
/>
))}
</span>
))}
</motion.span>
</AnimatePresence>
</motion.span>
핵심은 key={currentTextIndex} 다. 인덱스가 바뀌면 React가 이전 motion.span을 제거하려 하는데, AnimatePresence가 가로채서 각 글자에 exit를 재생한다. mode="wait"이므로 모든 글자가 위로 날아간 후에 새 글자들의 initial → animate가 시작된다.
getStaggerDelay는 Math.abs(기준점 - index) * staggerDuration 한 줄이다 — 기준점만 first(0), last(끝), center(중앙)로 바꾸면 물결 방향이 바뀐다. "Hello World"에서 W의 stagger 인덱스는 단어 내 0이 아닌 글로벌 5 — 이전 단어의 글자 수를 합산(previousCharsCount)해서 "Hello" 마지막 글자 다음에 "World" 첫 글자가 자연스럽게 이어진다.
텍스트 분할에는 Intl.Segmenter를 사용한다 — split('')으로는 이모지가 깨지지만, Intl.Segmenter는 grapheme cluster 단위로 쪼개서 통째로 보존한다.
참고: Intl.Segmenter와 이모지 분할
JavaScript 문자열은 UTF-16 인코딩이라, 이모지처럼 여러 코드포인트로 이루어진 문자는 split('')이나 Array.from()으로 쪼개면 깨진다:
"👨👩👧".split('') // → ['👨', '', '👩', '', '👧'] 5조각!
[..."🇰🇷"] // → ['🇰', '🇷'] 국기도 2조각!
Intl.Segmenter는 유니코드의 grapheme cluster 규칙을 따라서, 사람이 인식하는 "글자 1개" 단위로 정확하게 분리한다:
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
Array.from(segmenter.segment("👨👩👧"), s => s.segment); // → ['👨👩👧'] ✅
Array.from(segmenter.segment("🇰🇷"), s => s.segment); // → ['🇰🇷'] ✅
RotatingText에서 각 글자를 <motion.span>으로 감싸므로, 이모지가 깨지면 조각난 문자가 각각 별도 <span>에 들어간다. Intl.Segmenter 덕분에 이모지가 포함된 텍스트도 안전하게 애니메이션된다. 모든 모던 브라우저가 지원한다 (Chrome 87+, Safari 15.4+, Firefox 104+).
TextCursor: trail 아이템의 exit 애니메이션
TextCursor는 마우스를 따라다니는 텍스트 trail을 만든다 — 마우스를 움직여서 trail 효과를 확인해보자.
마우스가 일정 거리(spacing) 이상 이동하면 새 trail 아이템을 추가하고, 멈추면 오래된 아이템부터 제거한다. 각 trail 아이템이 앞서 본 initial → animate → exit 구조를 그대로 쓴다:
<motion.span
animate={{
// 키프레임 배열: 숫자 대신 [경유지]를 넘기면 순서대로 보간
x: [0, randomX, 0], // 원점 → 랜덤 위치 → 원점
y: [0, randomY, 0],
rotate: [angle, angle + rand, angle],
opacity: 1, // 단일 값: 그냥 1로 페이드인
}}
exit={{ opacity: 0, scale: 0 }} // 제거될 때: 사라지며 축소
transition={{
// 속성별 독립 transition: 속성 이름을 키로 넣으면 각각 다른 타이밍 적용
opacity: { duration: exitDuration },
x: { duration: 2, repeat: Infinity, repeatType: 'mirror' },
y: { duration: 2, repeat: Infinity, repeatType: 'mirror' },
}}
/>
opacity는 한 번 페이드인하고 끝나지만, x/y는 repeat: Infinity로 영원히 반복한다. repeatType: 'mirror'는 0 → 5 → 0 → 5 → ...처럼 방향을 뒤집어 부드럽게 왕복하고, 'loop'는 끝에서 처음으로 점프한다.
useAnimationFrame + useTransform — GradientText, ShinyText
앞서 본 animate/transition은 선언적 방식이다 — "여기서 저기로" 상태만 선언하면 Motion이 알아서 보간한다. 하지만 매 프레임 직접 값을 계산해야 하는 경우도 있다.
💡 useAnimationFrame — 매 프레임 직접 제어
useAnimationFrame((time, delta) => {
// time: 페이지 로드 후 경과 시간 (ms)
// delta: 이전 프레임과의 차이 (ms)
ref.current.style.transform = `rotate(${time * 0.1}deg)`;
});
브라우저의 requestAnimationFrame을 React에 맞게 감싼 훅이다. 매 프레임(~60fps) 콜백이 호출되어, 시간 기반 애니메이션을 직접 계산할 수 있다. 네이티브 rAF 대비 추가되는 점:
- 자동
cancelAnimationFrame— 네이티브 rAF는 한 번 시작하면cancelAnimationFrame(id)를 직접 호출해야 멈춘다. 안 하면 컴포넌트가 사라져도 콜백이 계속 실행되어 메모리 릭이 발생한다.useAnimationFrame은 언마운트 시 자동 정리. delta인자 —time은 페이지 로드 후 누적 시간이라 "현재 위치"를 계산하기 좋고,delta는 이전 프레임과의 시간 차이(보통 ~16.7ms)라 "이번 프레임에 얼마나 이동할지"를 계산하기 좋다. 프레임 드랍이 발생하면delta가 커져서, 그만큼 더 많이 이동시키면 끊김 없이 보인다.
참고: requestAnimationFrame이란?
requestAnimationFrame(callback)은 브라우저가 다음 화면을 그리기 직전에 callback을 실행해주는 Web API다.
// 기본 사용법
function animate(time) {
// time: 페이지 로드 후 경과한 밀리초 (고정밀 타임스탬프)
element.style.transform = `translateX(${time * 0.1}px)`;
requestAnimationFrame(animate); // 다음 프레임 예약
}
requestAnimationFrame(animate);
왜 setInterval 대신 쓰는가?
setInterval(fn, 16)은 브라우저의 화면 갱신 주기와 동기화되지 않는다. 모니터가 60Hz면 ~16.6ms마다 그리는데, setInterval의 타이밍이 어긋나면 프레임이 밀리거나 건너뛰어 끊김(jank)이 생긴다.requestAnimationFrame은 브라우저가 실제로 화면을 그릴 준비가 되었을 때 호출하므로 항상 디스플레이 주사율에 맞춰 실행된다.- 탭이 비활성화되면 자동으로 일시정지되어 불필요한 CPU/배터리 소모가 없다.
💡 useTransform — motionValue를 다른 값으로 변환
const scrollY = useScroll().scrollYProgress; // 0 ~ 1
const opacity = useTransform(scrollY, [0, 0.5], [0, 1]); // 0 ~ 1 → 0 ~ 1
const color = useTransform(scrollY, [0, 1], ['#f00', '#00f']); // 빨강 → 파랑
입력 motionValue가 바뀔 때마다 변환 결과를 계산한다. 소스는 useAnimationFrame이든, useScroll이든, useSpring이든 어떤 motionValue든 연결할 수 있다 — 이 포스트에서도 useScroll과 조합되는 예시가 곧 나온다.
💡 두 훅의 조합 — 리렌더 없는 파이프라인
ShinyText와 GradientText는 이 둘을 조합해 다음 파이프라인을 만든다:
useAnimationFrame ──→ motionValue ──→ useTransform ──→ CSS
(매 프레임 계산) (숫자: 0~100) (문자열 변환) (DOM 반영)
핵심은 이 파이프라인 전체가 React 렌더링을 거치지 않는다는 점이다. motionValue.set()은 setState와 달리 리렌더를 트리거하지 않고, useTransform의 결과도 motion.span의 style에 직접 바인딩되어 DOM만 업데이트한다. 60fps에서 매 프레임 렌더링이 돌면 버벅임이 생기기 때문에, 이 구조가 필수적이다.
CSS @keyframes와의 차이는 런타임 제어다. speed, direction, pause를 prop으로 바꾸면 다음 프레임부터 즉시 반영된다 — CSS 애니메이션은 이런 동적 변경이 어렵다.
ShinyText
1단계: 그라디언트 만들기
핵심 트릭은 **background-clip: text**다. 배경 그라디언트를 텍스트 글자 모양대로 잘라내고, 글자색을 투명하게 만들면 글자 안에 그라디언트가 채워진 것처럼 보인다.
const gradientStyle = {
backgroundImage: `linear-gradient(120deg,
${color} 0%, ${color} 35%, // 기본색
${shineColor} 50%, // 반짝이는 부분 (좁은 띠)
${color} 65%, ${color} 100% // 기본색
)`,
backgroundSize: '200% auto', // 텍스트보다 2배 넓은 배경
WebkitBackgroundClip: 'text', // 글자 모양으로 자르기
backgroundClip: 'text',
WebkitTextFillColor: 'transparent' // 원래 글자색 투명
};
backgroundSize: '200%'로 배경을 텍스트보다 2배 넓게 만들어서, 위치를 이동하면 빛이 지나가는 효과가 된다.
참고: background-clip이란?
background-clip은 배경이 요소의 어디까지 칠해질지 영역을 지정하는 CSS 속성이다.
| 값 | 배경이 칠해지는 범위 |
|---|---|
border-box | 테두리(border)까지 포함 (기본값) |
padding-box | 패딩 안쪽까지 |
content-box | 콘텐츠 영역만 |
text | 글자 모양대로 잘라냄 |
text 값을 쓰면 배경이 글자 픽셀에만 표시되고 나머지는 투명해진다. 이때 글자색(color)이 배경을 가리므로 반드시 color: transparent 또는 -webkit-text-fill-color: transparent로 글자를 투명하게 만들어야 배경이 보인다.
.gradient-text {
background: linear-gradient(90deg, red, blue);
-webkit-background-clip: text; /* Safari/Chrome 호환 접두사 */
background-clip: text;
-webkit-text-fill-color: transparent;
}
-webkit-background-clip은 원래 WebKit 전용이었지만, 현재는 모든 모던 브라우저가 지원한다. 다만 접두사 없는 background-clip: text는 비교적 최근에 표준화되었기 때문에, 호환성을 위해 둘 다 선언하는 것이 안전하다.
2단계: useAnimationFrame으로 진행도 계산
매 프레임 0~100 사이의 진행도(progress)를 계산한다.
const progress = useMotionValue(0); // 0~100 진행도
const elapsedRef = useRef(0); // 누적 경과 시간
const lastTimeRef = useRef<number | null>(null);
useAnimationFrame(time => {
// 정지 상태면 시간 추적 초기화
if (disabled || isPaused) {
lastTimeRef.current = null;
return;
}
// delta 직접 계산 (재개 시 점프 방지)
const deltaTime = time - lastTimeRef.current;
lastTimeRef.current = time;
elapsedRef.current += deltaTime;
// 경과 시간 → 0~100 진행도로 변환
const cycleTime = elapsedRef.current % cycleDuration;
const p = (cycleTime / animationDuration) * 100;
progress.set(p);
// 예: elapsed=1500, cycleDuration=2000 → cycleTime=1500 → p=75
});
3단계: useTransform으로 CSS 값 변환
const backgroundPosition = useTransform(
progress,
p => `${150 - p * 2}% center`
);
progress (0~100)를 실제 CSS background-position 값으로 매핑한다:
| progress | backgroundPosition | 빛 위치 |
|---|---|---|
| 0 | 150% center | 오른쪽 바깥 (안 보임) |
| 50 | 50% center | 정중앙 |
| 100 | -50% center | 왼쪽 바깥 (안 보임) |
빛이 오른쪽에서 왼쪽으로 지나간다. direction='right'이면 2단계에서 100 - p를 사용해 반대로 움직인다.
4단계: motion.span에 연결
<motion.span
style={{ ...gradientStyle, backgroundPosition }}
>
{text}
</motion.span>
motion.span의 style에 MotionValue를 바인딩하면 값이 바뀔 때 DOM이 직접 업데이트된다.
이 4단계를 3D 레이어 분해도로 보면 구조가 한눈에 들어온다. 상단이 텍스트 마스크(background-clip: text), 하단이 200% 그라디언트 스트립이 슬라이드하는 모습이다:
GradientText
GradientText도 ShinyText와 동일한 useAnimationFrame → motionValue → useTransform → backgroundPosition 파이프라인이다. 차이점 두 가지를 보자.
direction에 따른 이동 축 분기
useTransform 안에서 direction에 따라 backgroundPosition이 달라진다:
const backgroundPosition = useTransform(progress, p => {
if (direction === 'horizontal') return `${p}% 50%`; // x축 이동
if (direction === 'vertical') return `50% ${p}%`; // y축 이동
return `${p}% 50%`; // 대각선
});
| direction | backgroundSize | backgroundPosition | 이동 |
|---|---|---|---|
horizontal | 300% 100% | {p}% 50% | 좌우 |
vertical | 100% 300% | 50% {p}% | 상하 |
diagonal | 300% 300% | {p}% 50% | 대각선 |
ShinyText는 200%로 "빛 한 줄"만 지나가면 되지만, GradientText는 여러 색이 번갈아 보여야 하므로 300%로 더 넓게 잡는다.
끊김 없는 루프 — 첫 색 복제
const gradientColors = [...colors, colors[0]].join(', ');
// ['#5227FF', '#FF9FFC', '#B19EEF']
// → '#5227FF, #FF9FFC, #B19EEF, #5227FF'
그라디언트 끝에 첫 색을 복제한다. 이렇게 하면 backgroundPosition이 100%에 도달했을 때 보이는 색이 0%일 때와 동일해서, 루프가 이어질 때 색이 점프하지 않는다.
스크롤 연동 — useScroll + useVelocity — ScrollVelocity
💡 useScroll — 스크롤 위치를 MotionValue로
useScroll은 스크롤 위치를 MotionValue로 반환한다. window.addEventListener('scroll')을 직접 쓰는 대신 이 훅을 쓰면 React 렌더 없이 스크롤 값을 추적할 수 있다.
const { scrollY, scrollYProgress } = useScroll({
container: ref, // 스크롤 추적 대상 (기본: window)
target: elementRef, // container 안에서 추적할 요소
axis: 'y', // 추적 축: 'x' | 'y' (기본: 'y')
offset: ['start start', 'end end'], // target–container 교차 지점
});
// scrollY: 절대 위치 (px) — 200px 스크롤 → 200
// scrollYProgress: 0~1 비율 — 전체의 절반 → 0.5
offset은 "start end", "0.5 0.5", "100px 0px" 등으로 교차 시점을 세밀하게 지정할 수 있다.
💡 useVelocity — MotionValue의 변화율(미분)
useVelocity는 MotionValue의 시간당 변화율을 계산한다. 수학의 미분과 같은 개념이다 — 위치의 미분이 속도이고, 속도의 미분이 가속도다.
// useVelocity(value: MotionValue<number>): MotionValue<number>
// 인자: 숫자형 MotionValue 하나
const scrollVelocity = useVelocity(scrollY);
// scrollY가 1초에 500px 변하면 → scrollVelocity ≈ 500
// scrollY가 멈추면 → scrollVelocity ≈ 0
// 이중 적용 → 가속도(속도의 미분)
const acceleration = useVelocity(scrollVelocity);
어떤 MotionValue든 입력으로 받을 수 있다 — useVelocity(x), useVelocity(opacity) 등 값이 얼마나 빠르게 변하는지를 새 MotionValue로 출력한다.
ScrollVelocity
스크롤 속도에 따라 텍스트가 빠르게/느리게 이동하는 무한 루프를 구현한다 — 스크롤해서 텍스트 속도가 변하는 것을 확인해보자.
스크롤해보세요 ↕
1단계: 훅 체인으로 스크롤 속도 → 배율 변환
위에서 설명한 훅들이 체인처럼 연결된다. 각 훅은 이전 훅의 MotionValue를 입력으로 받아 새 MotionValue를 출력한다.
const { scrollY } = useScroll(scrollOptions); // 스크롤 위치 (px)
const scrollVelocity = useVelocity(scrollY); // 변화율 (px/s)
const smoothVelocity = useSpring(scrollVelocity, { // 스프링으로 부드럽게
damping: 50, // 높은 감쇠 → 진동 없이 빠르게 안정
stiffness: 400, // 높은 강성 → 스크롤에 민감하게 반응
});
const velocityFactor = useTransform( // 속도 → 배율 매핑
smoothVelocity, [0, 1000], [0, 5], { clamp: false }
);
// 스크롤 안 함 → velocityFactor ≈ 0 (기본 속도만)
// 1000px/s 스크롤 → velocityFactor ≈ 5 (6배속)
// clamp: false → 1000px/s 이상이면 5를 초과할 수도 있음
2단계: 프레임당 이동량 계산
useAnimationFrame((t, delta) => {
// 기본 이동량 = 방향 × 속도 × 시간(초)
let moveBy = directionFactor.current * baseVelocity * (delta / 1000);
// 예: 1 × 100 × (16/1000) = 1.6px (60fps 기준)
// 스크롤 속도에 따른 가속
moveBy += directionFactor.current * moveBy * velocityFactor.get();
// 빠르게 스크롤 중(velocityFactor=3): 1.6 + 1 × 1.6 × 3 = 6.4px
// 스크롤 안 함(velocityFactor=0): 1.6 + 1 × 1.6 × 0 = 1.6px
baseX.set(baseX.get() + moveBy); // 누적
});
delta / 1000으로 밀리초를 초 단위로 변환해서 프레임 레이트에 무관한 일정 속도를 보장한다. 60fps면 delta≈16ms, 30fps면 delta≈33ms — 한 프레임당 이동량이 달라져도 1초간 총 이동 거리는 동일하다. 스크롤 방향이 바뀌면 velocityFactor가 음수가 되어 directionFactor가 반전된다.
3단계: wrap()으로 무한 루프
function wrap(min: number, max: number, v: number): number {
const range = max - min;
const mod = (((v - min) % range) + range) % range;
return mod + min;
}
// 텍스트 6개 복제, 한 카피 너비 = 500px 가정
const x = useTransform(baseX, v => `${wrap(-500, 0, v)}px`);
// baseX = -200 → wrap(-500, 0, -200) = -200 (그대로)
// baseX = -600 → wrap(-500, 0, -600) = -100 (되감기!)
// baseX = 100 → wrap(-500, 0, 100) = -400 (되감기!)
wrap()은 baseX를 -copyWidth ~ 0 범위에서 순환시킨다. 텍스트 6개가 나란히 배치되어 있으므로 1카피 너비만큼 되감아도 시각적으로는 동일한 위치에 동일한 텍스트가 보인다 — 이음새를 눈치챌 수 없는 무한 루프가 된다.
마우스 추적과 Proximity — VariableProximity, CircularText
VariableProximity는 Motion의 useAnimationFrame 대신 자체 rAF 루프를 사용한다 — (time, delta) 없이 매 프레임 마우스-글자 거리만 계산하면 되기 때문이다.
참고: Variable Font란?
일반 폰트는 wght: 400(Regular), wght: 700(Bold)처럼 미리 정해진 굵기만 사용할 수 있다. Variable Font는 하나의 폰트 파일에 축(axis) 값의 연속 범위가 포함되어, wght: 423처럼 중간값도 자유롭게 지정할 수 있다. CSS fontVariationSettings 속성으로 축 값을 제어한다:
font-variation-settings: 'wght' 423, 'wdth' 75;
VariableProximity는 이 축 값을 프레임마다 동적으로 보간해서 마우스 거리에 따라 굵기가 부드럽게 변하는 효과를 만든다.
VariableProximity
마우스를 텍스트 위에서 움직여서 글자 굵기가 변하는 것을 확인해보자.
1단계: 거리 계산
// 매 프레임, 모든 글자에 대해 마우스와의 유클리드 거리 계산
const letterCenterX = rect.left + rect.width / 2 - containerRect.left;
const letterCenterY = rect.top + rect.height / 2 - containerRect.top;
const distance = Math.sqrt(
(mouseX - letterCenterX) ** 2 + (mouseY - letterCenterY) ** 2
);
// 예: 마우스(150, 80), 글자 중심(120, 80) → distance = 30px
2단계: falloff 함수로 영향력 결정
거리(px)를 0~1 사이의 영향력으로 변환한다. radius 바깥이면 0, 중심이면 1.
const calculateFalloff = (distance: number) => {
const norm = Math.min(Math.max(1 - distance / radius, 0), 1);
switch (falloff) {
case 'linear': return norm; // 균등 감소
case 'exponential': return norm ** 2; // 가까울수록 급격히 강해짐
case 'gaussian': return Math.exp(-((distance / (radius / 2)) ** 2) / 2);
}
};
// radius=50, distance=30 → norm=0.4
// linear: 0.4 | exponential: 0.16 | gaussian: 0.24
3단계: fontVariationSettings 보간
// falloffValue(0~1)로 from→to 사이를 보간
const interpolatedValue = fromValue + (toValue - fromValue) * falloffValue;
// from='wght' 400, to='wght' 900, falloff=0.4
// → 400 + (900 - 400) × 0.4 = 600
letterRef.style.fontVariationSettings = `'wght' 600`;
참고: CSS font-variation-settings 문법
font-variation-settings는 Variable Font의 축(axis)을 제어하는 CSS 속성이다. 값은 '축태그' 숫자 쌍을 쉼표로 나열한다:
font-variation-settings: 'wght' 600, 'opsz' 72;
| 축 태그 | 이름 | 범위 (예시) | 설명 |
|---|---|---|---|
wght | Weight | 100~1000 | 굵기 (400=Regular, 700=Bold) |
wdth | Width | 75~125 | 장평 (100=Normal) |
opsz | Optical Size | 8~144 | 본문/제목 최적화 |
slnt | Slant | -12~0 | 기울기 |
ital | Italic | 0~1 | 이탤릭 on/off |
VariableProximity는 props로 받은 "'wght' 400, 'opsz' 9" 문자열을 파싱해서 축별 fromValue/toValue 쌍으로 분리한다. 이렇게 해야 falloffValue로 축마다 독립적으로 보간할 수 있다.
letterRef.style을 직접 조작한다. lastPositionRef로 마우스가 움직이지 않았으면 계산 자체를 건너뛰는 최적화도 포함되어 있다.
💡 useAnimation — 명령형 애니메이션 컨트롤러
지금까지 본 컴포넌트들은 선언적(animate prop)으로 "이 상태로 가라"만 정의했다. useAnimation()은 명령형 API다 — controls.start()로 원하는 시점에 애니메이션을 트리거하고, MotionValue의 .get()으로 현재 값을 읽어서 그 지점부터 새 애니메이션을 시작할 수 있다.
const controls = useAnimation();
// 명령형으로 시작
controls.start({ rotate: 360, transition: { duration: 2 } });
// 현재 값에서 이어서
const current = rotation.get(); // 예: 현재 135도
controls.start({ rotate: current + 360 }); // 135 → 495도
// 즉시 정지
controls.stop();
"현재 각도에서 이어서 속도만 바꾸기"처럼 런타임 동적 제어가 필요하면 명령형 API가 적합하다.
CircularText
호버해서 속도가 변하는 것을 확인해보자.
1단계: 원형 글자 배치
{letters.map((letter, i) => {
const rotationDeg = (360 / letters.length) * i;
// 12글자면: 0°, 30°, 60°, 90°, ... 330°
const factor = Math.PI / letters.length;
const x = factor * i;
const y = factor * i;
const transform = `rotateZ(${rotationDeg}deg) translate3d(${x}px, ${y}px, 0)`;
return <span style={{ transform }}>{letter}</span>;
})}
각 글자를 rotateZ로 원형 배치한다. 12글자면 360/12 = 30° 간격이다. translate3d로 중심에서 바깥으로 밀어낸다. CSS의 .circular-text span에서 position: absolute + transform-origin: 0 50%로 회전 중심을 잡는다.
2단계: useAnimation으로 연속 회전
const controls = useAnimation();
const rotation: MotionValue<number> = useMotionValue(0);
useEffect(() => {
const start = rotation.get(); // 현재 각도 (예: 135°)
controls.start({
rotate: start + 360, // 135° → 495° (한 바퀴)
transition: getTransition(spinDuration, start)
});
}, [spinDuration]);
rotation.get()으로 현재 각도를 읽고 거기서 +360°를 목표로 설정한다. getTransition은 ease: 'linear', repeat: Infinity를 반환해서 등속 무한 회전이 된다. spinDuration이 바뀌면 effect가 재실행되어 현재 위치에서 새 속도로 이어서 회전한다.
3단계: 호버 시 속도 전환
const handleHoverStart = () => {
const start = rotation.get();
switch (onHover) {
case 'slowDown': // 2배 느리게
controls.start({ rotate: start + 360,
transition: getTransition(spinDuration * 2, start) });
break;
case 'speedUp': // 4배 빠르게
controls.start({ rotate: start + 360,
transition: getTransition(spinDuration / 4, start) });
break;
case 'pause': // 스프링으로 부드럽게 정지
controls.start({
transition: { rotate: { type: 'spring', damping: 20, stiffness: 300 } }
});
break;
case 'goBonkers': // 20배속 + 축소
controls.start({ rotate: start + 360, scale: 0.8,
transition: getTransition(spinDuration / 20, start) });
break;
}
};
핵심은 매번 rotation.get()으로 현재 각도에서 시작하는 것이다. 선언적 animate prop은 목표 상태만 정의하므로 "현재 위치에서 이어서 속도만 변경"이 어렵다. pause에서 type: 'spring'을 쓰면 급정지 대신 관성으로 감쇠하며 멈춘다. 호버 해제 시 handleHoverEnd가 다시 원래 속도로 복귀시킨다.
Motion이 어울리지 않는 경우
11개 컴포넌트를 살펴봤다. Motion은 React 생태계에서 가장 널리 쓰이는 애니메이션 도구이지만, 만능은 아니다.
프레임 단위 타임라인 제어
"0.3초에 페이드 시작, 0.5초에 스케일 시작, 0.8초에 위치 변경"처럼 정밀한 타임라인이 필요하면 GSAP Timeline이 더 직관적이다. Motion의 transition.delay로 비슷하게 할 수 있지만, 10개 이상의 속성을 다른 시점에 시작해야 하면 관리가 힘들어진다.
복잡한 시퀀싱
10개 이상의 요소가 정교한 순서로 움직여야 할 때, Motion의 stagger는 "등차 딜레이" 수준이다. GSAP의 .to(), .from(), .fromTo()를 타임라인에 순서대로 배치하는 게 훨씬 관리하기 쉽다.
수백 개 요소 동시 애니메이션
Motion은 각 요소가 React 컴포넌트다. motion.span 500개가 동시에 움직이면 리렌더링 부담이 있다. 이 경우 Canvas/WebGL이 더 적합하다.
다음 포스트에서는
정밀한 타임라인 제어가 필요할 때 — GSAP와 ScrollTrigger를 다룬다. react-bits에서 GSAP를 사용하는 컴포넌트들의 구현을 파헤친다.