GSAP + ScrollTrigger: 타임라인으로 정밀하게 제어하기
이전 포스트에서 Motion의 선언적 API, spring physics, AnimatePresence를 살펴봤다. 11개 컴포넌트를 다루면서 Motion이 React와 얼마나 잘 맞는지 확인했다. 하지만 마지막에 한 가지 언급했다 — "정밀한 타임라인 제어가 필요할 때". 이번 포스트는 그 이야기다.
Motion vs GSAP — 언제 뭘 쓰는가?
react-bits 텍스트 애니메이션 23개 중 11개가 Motion, 6개가 GSAP이다. 둘 다 쓰는 이유가 있다.
| 기준 | Motion | GSAP |
|---|---|---|
| 패러다임 | 선언적 — props로 목표 상태 선언 | 명령적 — 메서드 체이닝으로 시퀀스 구성 |
| React 통합 | 네이티브 (motion.div) | useGSAP 훅 + ref 기반 |
| 스크롤 연동 | useScroll (진행률 값) | ScrollTrigger (scrub, pin, snap) |
| 텍스트 분할 | 직접 split('') + map() | SplitText 플러그인 (자동 분할 + 리사이즈 대응) |
| 시퀀싱 | transition.delay 수동 계산 | timeline.to() + position parameter |
| 번들 크기 | ~33KB | ~27KB (core) + 플러그인 |
핵심 차이는 "누가 시간을 관리하느냐"다.
Motion은 "A에서 B로" 전환을 선언하면 프레임워크가 알아서 보간한다. 간단하고 React스럽지만, "0.3초에 A를 시작하고 0.5초에 B를 시작하고 0.8초에 A가 끝나면 C를 시작"처럼 시간축 위에 여러 트윈을 배치하려면 delay 수동 계산이 필요하다.
GSAP은 Timeline이라는 시간축을 명시적으로 만들고, 그 위에 트윈을 원하는 위치에 배치한다. 시퀀싱이 본질이다.
TextType — gsap.to() 하나면 충분할 때
TextType은 타이핑 애니메이션이다. 글자가 하나씩 타이핑되고, 다 쓰면 지우고, 다음 문장으로 넘어간다 — 데모에서 속도와 루프를 조절해보자.
흥미롭게도 타이핑 로직 자체에는 GSAP을 쓰지 않는다. 핵심 엔진은 React의 useState + setTimeout이다.
상태 기반 타이핑
// TextType.tsx — 핵심 상태
const [displayedText, setDisplayedText] = useState('');
const [currentCharIndex, setCurrentCharIndex] = useState(0);
const [isDeleting, setIsDeleting] = useState(false);
const [currentTextIndex, setCurrentTextIndex] = useState(0);
매 setTimeout 호출마다 currentCharIndex를 1씩 증가시키고, displayedText에 글자를 하나 추가한다. isDeleting이 true면 반대로 slice(0, -1)로 한 글자씩 제거한다. 텍스트 배열이 끝나면 currentTextIndex를 순환시킨다.
GSAP이 하는 유일한 일 — 커서 깜빡임
GSAP의 기본 단위는 트윈(tween) — 하나의 값을 A에서 B로 변화시키는 것이다. TextType은 이 트윈을 딱 하나만 쓴다:
// 커서 깜빡임만 GSAP으로 처리
gsap.to(cursorRef.current, {
opacity: 0,
duration: cursorBlinkDuration, // 기본값 0.5초
repeat: -1, // 무한 반복
yoyo: true, // 0→1→0→1 왕복
ease: 'power2.inOut'
});
repeat: -1과 yoyo: true의 조합으로 커서가 부드럽게 깜빡인다. CSS @keyframes로도 가능하지만, GSAP으로 하면 cursorBlinkDuration prop 변경에 즉시 반응한다.
가변 속도
// variableSpeed가 설정되면 글자마다 다른 타이핑 속도
const getRandomSpeed = () => {
if (!variableSpeed) return typingSpeed;
const { min, max } = variableSpeed;
return Math.random() * (max - min) + min;
// min=30, max=100이면: 30~100ms 사이 랜덤
};
사람이 타이핑하면 글자마다 속도가 다르다. variableSpeed: { min: 30, max: 100 }으로 설정하면 각 글자의 타이핑 간격이 30~100ms 사이에서 랜덤하게 결정된다.
TextType의 cleanup
트윈이 1개뿐이므로 useEffect + gsap.kill()이면 충분하다. 복잡한 context 관리가 필요 없다.
Shuffle — Timeline 없이는 불가능
Shuffle은 react-bits 텍스트 애니메이션 중 가장 복잡한 컴포넌트(413줄)다. 각 글자가 슬롯머신처럼 돌아간다 — 방향과 모드를 바꿔가며 확인해보자.
SHUFFLE
이걸 CSS나 Motion으로 만들 수 있을까? 각 글자마다 동적으로 DOM을 래핑하고, 스트립 안에 스크램블 문자를 배치하고, 홀수/짝수 그룹으로 나눠서 타이밍을 어긋나게 해야 한다. Timeline 없이는 관리가 불가능하다.
플러그인 등록
TextType은 GSAP core만으로 충분했다. Shuffle부터는 플러그인이 필요하다:
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText as GSAPSplitText } from 'gsap/SplitText';
import { useGSAP } from '@gsap/react';
// 사용 전 반드시 등록 — 미등록 시 해당 옵션이 조용히 무시됨
gsap.registerPlugin(ScrollTrigger, GSAPSplitText, useGSAP);
GSAP core는 gsap.to(), gsap.timeline() 같은 기본 기능만 제공한다. 스크롤 연동, 텍스트 분할 같은 확장 기능은 플러그인으로 분리되어 있고, registerPlugin()으로 등록해야 동작한다. 등록하지 않으면 에러 없이 그냥 무시된다.
useGSAP — 왜 useEffect로는 안 되는가
TextType은 useEffect로 충분했다. Shuffle은 다르다 — DOM을 동적으로 생성하고, Timeline을 만들고, SplitText로 분할한다. cleanup이 3단계다:
// Shuffle은 useGSAP을 쓴다
useGSAP(() => {
// 여기서 만든 모든 트윈/타임라인이
// scope ref 내부에서만 동작하고
// 언마운트 시 자동으로 정리된다
}, { scope: containerRef, dependencies: [text, direction, ...] });
useGSAP은 GSAP 공식 React 훅(@gsap/react)으로, useEffect를 감싸면서 세 가지를 자동 처리한다:
gsap.context()자동 생성 — 내부에서 만든 모든 GSAP 인스턴스를 추적scoperef 격리 — 셀렉터가 scope 안에서만 매칭되어 다른 컴포넌트에 영향을 주지 않음- 자동 cleanup — 언마운트나 deps 변경 시
context.revert()호출
SplitText로 글자 분할 + 동적 DOM 래핑
// SplitText 플러그인으로 텍스트를 개별 char로 분할
splitRef.current = new GSAPSplitText(el, {
type: 'chars',
charsClass: 'shuffle-char',
smartWrap: true,
reduceWhiteSpace: false
});
분할 후 각 글자에 대해 래퍼 구조를 동적으로 생성한다:
래퍼 (overflow: hidden) — 보이는 영역을 글자 1개 크기로 제한
└── 스트립 (inline-block) — 가로 또는 세로로 나열
├── 원본 복제 ← 시작 위치
├── 스크램블 문자 1 ← 셔플 중 보이는 랜덤 글자
├── 스크램블 문자 2
└── 원본 ← 최종 위치
overflow: hidden인 래퍼가 "창" 역할을 하고, 스트립이 그 안에서 이동하면서 마치 슬롯머신처럼 글자가 돌아가는 효과를 만든다.
Timeline — 여러 트윈을 시간축 위에 배치
TextType의 gsap.to()는 트윈 하나를 실행했다. 여러 트윈을 순서대로 실행하려면 gsap.timeline()을 쓴다:
const tl = gsap.timeline();
tl.to(a, { x: 100, duration: 0.5 }) // 0초에 시작
.to(b, { y: 50, duration: 0.3 }) // 0.5초에 시작 (a 끝난 후)
.to(c, { opacity: 0, duration: 0.2 }, "-=0.1"); // 0.7초에 시작 (b 끝나기 0.1초 전)
세 번째 인자가 position parameter다 — "+=0.5"(0.5초 후), "-=0.2"(0.2초 전 겹침), "<"(이전 트윈과 동시 시작), 0.8(절대 시간) 등으로 트윈의 시작 시점을 정밀하게 제어한다.
Shuffle은 이 Timeline 위에 홀수/짝수 그룹의 트윈을 배치한다:
if (animationMode === 'evenodd') {
const odd = strips.filter((_, i) => i % 2 === 1); // 홀수 인덱스
const even = strips.filter((_, i) => i % 2 === 0); // 짝수 인덱스
const oddTotal = duration + Math.max(0, odd.length - 1) * stagger;
const evenStart = odd.length ? oddTotal * 0.7 : 0; // 👈 70% 오버랩
if (odd.length) addTween(odd, 0); // 홀수: 0초에 시작
if (even.length) addTween(even, evenStart); // 짝수: 홀수의 70% 지점에 시작
}
"SHUFFLE"(7글자)이면 — H, F, L(인덱스 1,3,5)이 먼저 셔플을 시작하고, S, U, F, E(인덱스 0,2,4,6)는 홀수 그룹이 70% 진행된 시점에 시작한다. 이 시간차가 물결치는 느낌을 만든다.
참고: stagger — 여러 요소에 시간차 적용
stagger는 배열의 각 요소에 순차적 딜레이를 주는 옵션이다:
// 5개 .char 요소가 0.03초 간격으로 차례차례 등장
gsap.to('.char', {
opacity: 1,
y: 0,
stagger: 0.03, // 0초, 0.03초, 0.06초, 0.09초, 0.12초
duration: 0.5
});
5개 요소에 stagger: 0.03이면, 마지막 요소는 0.03 × 4 = 0.12초 후에 시작한다. 각 요소의 애니메이션 자체는 duration: 0.5이므로, 전체 완료 시간은 0.12 + 0.5 = 0.62초다.
Shuffle의 cleanup — DOM 복원
const teardown = () => {
// 1. Timeline 정리
if (tlRef.current) { tlRef.current.kill(); tlRef.current = null; }
// 2. 래퍼 구조 해체 → 원본 글자로 복원
wrappersRef.current.forEach(wrap => {
const orig = wrap.querySelector('[data-orig="1"]');
if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);
});
// 3. SplitText 인스턴스 해제
splitRef.current?.revert();
};
Shuffle이 동적으로 만든 래퍼/스트립 구조를 원래 DOM으로 복원한다. useGSAP의 cleanup에서 호출되므로 컴포넌트 언마운트나 deps 변경 시 자동 실행된다.
ScrollFloat — 스크롤이 타임라인이 된다
아래로 스크롤하면 글자들이 찌그러진 상태에서 하나씩 복원된다 — 스크롤 속도에 따라 애니메이션이 자연스럽게 따라온다.
스크롤해보세요 ↕
ScrollFloat
87줄의 간결한 컴포넌트지만, 여기서 새로운 개념이 등장한다 — ScrollTrigger와 scrub.
ScrollTrigger의 scrub — 스크롤바가 재생 헤드
지금까지의 컴포넌트는 시간이 애니메이션을 구동했다. ScrollTrigger의 scrub: true는 스크롤 위치를 애니메이션 진행률로 변환한다:
gsap.to(element, {
x: 500,
scrollTrigger: {
trigger: element,
start: 'top bottom', // 요소 상단이 뷰포트 하단에 닿으면 시작 (progress: 0)
end: 'bottom bottom', // 요소 하단이 뷰포트 하단에 닿으면 끝 (progress: 1)
scrub: true // 👈 스크롤 = 타임라인
}
});
scrub: true가 없으면 ScrollTrigger는 "요소가 보이면 재생" 하는 트리거일 뿐이다. scrub: true를 켜면 스크롤바가 재생 헤드가 된다 — 위로 스크롤하면 애니메이션이 되감기고, 중간에 멈추면 애니메이션도 멈춘다.
참고: scrub 숫자값과 smooth scrub
scrub: true는 스크롤에 1:1로 즉시 반응한다. scrub: 0.5처럼 숫자를 넣으면 0.5초의 지연을 두고 부드럽게 따라간다. 스크롤이 급격히 움직여도 애니메이션은 부드럽게 보간된다. 일반적으로 scrub: true(정밀 제어)와 scrub: 1(부드러운 연출) 중 상황에 맞게 선택한다.
문자 분할 — React split으로 직접
// useMemo로 한 번만 분할
const splitText = useMemo(() => {
const text = typeof children === 'string' ? children : '';
return text.split('').map((char, index) => (
<span className="char" key={index}>
{char === ' ' ? '\u00A0' : char}
</span>
));
}, [children]);
Shuffle은 GSAP의 SplitText 플러그인을 썼지만, ScrollFloat는 React의 split + map으로 직접 분할한다. 플러그인 없이도 문자 단위 제어가 가능하다.
fromTo + scrub — 찌그러진 상태에서 복원
gsap.fromTo(
charElements,
{
// from: 초기 상태
opacity: 0,
yPercent: 120, // 120% 아래로 밀어냄
scaleY: 2.3, // 세로로 2.3배 늘어남
scaleX: 0.7, // 가로로 70%로 압축
transformOrigin: '50% 0%' // 상단 중앙 기준
},
{
// to: 목표 상태
opacity: 1,
yPercent: 0,
scaleY: 1,
scaleX: 1,
stagger: stagger, // 글자별 시간차 (기본 0.03)
ease: 'back.inOut(2)',
scrollTrigger: {
trigger: el,
scrub: true // 👈 스크롤에 연동
}
}
);
back.inOut(2) easing은 오버슈트가 있다 — 목표를 지나쳤다가 되돌아온다. 글자가 살짝 튕기며 제자리를 찾는 탄성이 이 easing에서 나온다.
ScrollFloat의 cleanup
ScrollTrigger 1개, 플러그인도 ScrollTrigger뿐이므로 useEffect + 직접 kill로 충분하다. useGSAP까지 쓸 필요 없다.
ScrollReveal — 레이어 쌓기
스크롤하면 텍스트가 살짝 회전하며, 단어가 하나씩 밝아지고, 흐림이 사라진다 — blur를 끄고 비교해보자.
스크롤해보세요 ↕
GSAP ScrollTrigger는 스크롤 위치를 애니메이션 진행률로 변환한다.
ScrollFloat는 ScrollTrigger 1개로 하나의 속성 변화를 제어했다. ScrollReveal은 하나의 텍스트에 3개의 독립적인 ScrollTrigger를 걸어서 각각 다른 속성을 제어한다.
단어 분할 — 공백 보존
const splitText = useMemo(() => {
const text = typeof children === 'string' ? children : '';
return text.split(/(\s+)/).map((word, index) => {
if (word.match(/^\s+$/)) return word; // 공백은 그대로
return <span className="word" key={index}>{word}</span>;
});
}, [children]);
split(/(\s+)/)에서 캡처 그룹 () 이 핵심이다. 캡처 그룹이 있으면 구분자(공백)도 결과 배열에 포함된다. "Hello World"가 ['Hello', ' ', 'World']로 분할되어 원래 공백이 유지된다.
3개 ScrollTrigger — 각각 다른 start 시점
// ScrollTrigger #1: 전체 컨테이너의 회전
gsap.fromTo(el,
{ transformOrigin: '0% 50%', rotate: baseRotation },
{
rotate: 0,
scrollTrigger: {
trigger: el,
start: 'top bottom', // 일찍 시작
end: rotationEnd,
scrub: true
}
}
);
// ScrollTrigger #2: 각 단어의 opacity
gsap.fromTo(wordElements,
{ opacity: baseOpacity },
{
opacity: 1,
stagger: 0.05,
scrollTrigger: {
trigger: el,
start: 'top bottom-=20%', // 회전보다 늦게 시작
end: wordAnimationEnd,
scrub: true
}
}
);
// ScrollTrigger #3: blur (enableBlur=true일 때만)
if (enableBlur) {
gsap.fromTo(wordElements,
{ filter: `blur(${blurStrength}px)` },
{
filter: 'blur(0px)',
stagger: 0.05,
scrollTrigger: { /* 동일한 start/end */ }
}
);
}
3개 ScrollTrigger의 start가 다르다 — 회전은 top bottom(일찍 시작), opacity/blur는 top bottom-=20%(조금 늦게 시작). 텍스트가 먼저 수평으로 회전하고, 그 다음 단어들이 순차적으로 밝아지고 선명해지는 레이어드 효과를 만든다.
ScrollReveal의 cleanup
ScrollTrigger 3개지만 패턴이 동일하므로 useEffect + 선택적 kill로 충분하다.
SplitText — 플러그인이 DOM을 만들 때
텍스트가 화면에 들어오면 글자/단어/줄 단위로 등장한다 — 분할 단위를 바꿔보면 차이가 명확하다.
ScrollFloat와 ScrollReveal은 React의 split + map으로 직접 분할했다. GSAP의 SplitText 플러그인은 이보다 훨씬 강력하다:
const split = new GSAPSplitText(element, {
type: 'chars, words, lines', // 원하는 분할 단위
charsClass: 'split-char',
wordsClass: 'split-word',
linesClass: 'split-line',
smartWrap: true, // 자동 줄바꿈 대응
autoSplit: true // 리사이즈 시 재분할
});
직접 split + map하는 것과 뭐가 다른가?
- 줄 단위 분할: CSS가 결정하는 줄바꿈 위치를 정확히 감지한다. React에서는 렌더링 전에 줄바꿈 위치를 알 수 없다.
- 리사이즈 대응:
autoSplit: true면 창 크기가 바뀌어 줄바꿈이 달라질 때 자동으로 재분할한다. - cleanup:
split.revert()으로 DOM을 원래 상태로 복원한다.
참고: document.fonts.ready — 왜 폰트 로딩을 기다려야 하는가?
SplitText는 각 글자의 렌더링된 크기를 기반으로 래퍼를 생성한다. 웹 폰트가 아직 로딩되지 않았으면 fallback 폰트의 크기로 분할이 일어나고, 이후 웹 폰트가 로딩되면 글자 크기가 바뀌어 레이아웃이 어긋난다.
// 폰트 로딩 대기 패턴
useEffect(() => {
if (document.fonts.status === 'loaded') {
setFontsLoaded(true);
} else {
document.fonts.ready.then(() => setFontsLoaded(true));
}
}, []);
SplitText와 Shuffle 모두 이 패턴을 사용한다. fontsLoaded가 true가 될 때까지 분할을 지연시킨다.
onSplit 콜백 — 분할 즉시 애니메이션 설정
const splitInstance = new GSAPSplitText(el, {
type: splitType,
smartWrap: true,
onSplit: (self) => {
// 분할 완료 시점에 타겟 결정
if (splitType.includes('chars') && self.chars.length) targets = self.chars;
else if (splitType.includes('words') && self.words.length) targets = self.words;
else if (splitType.includes('lines') && self.lines.length) targets = self.lines;
// 타겟에 애니메이션 적용
return gsap.fromTo(targets,
{ ...from }, // 기본: { opacity: 0, y: 40 }
{
...to, // 기본: { opacity: 1, y: 0 }
duration,
stagger: delay / 1000,
scrollTrigger: {
trigger: el,
once: true, // 👈 한 번만 재생
},
force3D: true,
willChange: 'transform, opacity'
}
);
}
});
onSplit 콜백에서 타겟을 결정하고 즉시 애니메이션을 설정하는 것이 핵심이다. once: true이므로 ScrollTrigger가 한 번 트리거되면 끝 — animationCompletedRef로 추가 재생을 막는다.
SplitText의 cleanup — useGSAP이 필요한 이유
onSplit 콜백 안에서 애니메이션이 생성되므로, 외부에서 참조를 잡기 어렵다. useGSAP의 gsap.context()가 내부에서 만들어진 모든 인스턴스를 자동 추적한다:
return () => {
// trigger가 이 요소인 ScrollTrigger만 선택적으로 kill
ScrollTrigger.getAll().forEach(st => {
if (st.trigger === el) st.kill();
});
// SplitText가 만든 래퍼 DOM을 원래 텍스트로 복원
splitInstance.revert();
};
ScrollTrigger.getAll().forEach(st => st.kill())처럼 전체를 날리면 다른 컴포넌트의 ScrollTrigger까지 죽일 수 있다. st.trigger === el로 자기 것만 정리한다.
ScrambledText — 마우스가 코드를 건드릴 때
마우스를 텍스트 위로 움직이면 근처 글자들이 스크램블된다 — 반응 반경을 넓혀보면 영향 범위가 달라진다.
마우스를 텍스트 위로 움직여보세요
Hover over this text to see the scramble effect in action
이 컴포넌트는 ScrollTrigger를 쓰지 않는다. 대신 ScrambleTextPlugin이라는 새 플러그인과 포인터 위치 기반 반응을 조합한다.
gsap.registerPlugin(SplitText, ScrambleTextPlugin);
ScrambleTextPlugin은 글자를 랜덤 문자로 스크램블했다가 원본으로 복원하는 트윈을 제공한다. scrambleText: { text, chars, speed } 옵션으로 제어한다.
글자 분할 + 원본 저장
const split = SplitText.create(rootRef.current.querySelector('p'), {
type: 'chars',
charsClass: 'char'
});
// 각 글자의 원본 텍스트를 data-content에 저장
charsRef.current.forEach(c => {
gsap.set(c, {
display: 'inline-block',
attr: { 'data-content': c.innerHTML }
});
});
스크램블 후 원본으로 돌아가려면 원본을 어딘가에 저장해야 한다. data-content 속성이 그 역할을 한다.
거리 계산 + 거리 비례 duration
const handleMove = (e: PointerEvent) => {
charsRef.current.forEach(c => {
const { left, top, width, height } = c.getBoundingClientRect();
const dx = e.clientX - (left + width / 2); // 글자 중심까지의 X 거리
const dy = e.clientY - (top + height / 2); // 글자 중심까지의 Y 거리
const dist = Math.hypot(dx, dy); // 유클리드 거리
if (dist < radius) {
gsap.to(c, {
overwrite: true,
duration: duration * (1 - dist / radius),
// dist=0 (정확히 위) → duration * 1 = 전체
// dist=50, radius=100 → duration * 0.5 = 절반
// dist=99 → duration * 0.01 = 거의 즉시
scrambleText: {
text: c.dataset.content || '',
chars: scrambleChars,
speed
}
});
}
});
};
duration * (1 - dist / radius) — 가까울수록 오래 스크램블되고, 멀수록 빠르게 복원된다. 마우스 바로 아래 글자는 1 - 0/100 = 1(전체 duration), 반경 가장자리의 글자는 1 - 99/100 = 0.01(거의 즉시 원본으로)이다.
overwrite: true는 같은 글자에 새 애니메이션이 시작되면 이전 것을 즉시 취소한다. 마우스가 빠르게 움직일 때 애니메이션이 중첩되는 걸 방지한다.
ScrambledText의 cleanup
pointermove 이벤트 리스너 제거가 핵심이므로 useEffect로 충분하다. GSAP context보다 리스너 정리가 중요한 컴포넌트다.
다음 포스트에서는
DOM을 벗어나서 — Canvas 2D로 텍스트를 픽셀 단위로 다룬다. FuzzyText의 Perlin noise 기반 떨림과 FallingText의 Matter.js 물리 시뮬레이션을 살펴본다.