사용 방법
npm i gsap
ScrollTrigger란?
ScrollTrigger는 스크롤 위치를 기준으로 애니메이션을 제어할 수 있게 해주는 GSAP 플러그인입니다.
스크롤에 따라 애니메이션을 시작·종료하거나, 특정 지점에서 트리거를 발생시키고, 고정(pin), 스냅(snap) 등의 기능을 제공합니다.
ScrollTrigger 사용법
import gsap from "gsap";
import ScrollTrigger from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
ScrollTrigger는 보통 gsap.to, gsap.from, gsap.timeline과 함께 사용합니다.
1. gsap.to
현재 상태 → 목표 상태로 애니메이션합니다.
- 가장 직관적이고 많이 사용됨
- "어디까지 움직일지"가 명확할 때 적합
gsap.to('.box', {
x: 300,
duration: 1,
ease: 'power2.out',
});
- .box가 현재 위치에서 x축으로 300px 이동
2. gsap.from
지정한 상태 → 현재 상태로 애니메이션합니다.
- 진입 애니메이션에 적합
- 초기 상태를 따로 세팅하지 않아도 됨
gsap.from('.box', {
opacity: 0,
y: 50,
duration: 1,
});
- 처음엔 투명하고 아래에 있다가 제자리로 올라오며 나타남
3. gsap.timeline
여러 애니메이션을 하나의 흐름으로 제어합니다.
- 순차 애니메이션 관리
- 하나의 애니메이션처럼 재생/정지/되감기 가능
- ScrollTrigger, Observer와 주로 사용
const tl = gsap.timeline();
tl.to('.box1', { x: 200, duration: 0.6 })
.to('.box2', { opacity: 1, duration: 0.6 }, '-=0.3')
.to('.box3', { scale: 1.2, duration: 0.6 });
- box1 → box2 → box3 순서로 애니메이션 진행
- -=0.3으로 타이밍 겹침 제어
ScrollTrigger를 언제 사용하는지
다음과 같은 상황에서 적합합니다.
- 스크롤에 따라 요소가 자연스럽게 등장 / 사라져야 할 때
- 특정 스크롤 위치에서 애니메이션을 시작·종료해야 할 때
- 섹션을 스크롤에 따라 고정(pin)시키고 콘텐츠를 연출할 때
- 패럴랙스(Parallax) 효과 구현
- 스크롤 진행도에 따라 애니메이션을 동기화(scrub)해야 할 때
👉 "스크롤 위치가 기준" 이 되는 모든 애니메이션에 적합합니다.
ScrollTrigger 간단한 사용 예시
gsap.to('.box', {
x: 300,
scrollTrigger: {
trigger: '.box',
start: 'top 80%',
end: 'top 20%',
scrub: true,
},
});
- .box가 화면에 들어오면 스크롤에 따라 x축으로 이동
- scrub: true → 스크롤과 애니메이션이 동기화됨
Observer란?
Observer는 사용자의 입력 행위 자체를 감지하기 위한 GSAP 플러그인입니다. 스크롤 값이 아니라, 휠, 터치, 드래그, 키보드 입력 등을 이벤트 단위로 감지합니다. 즉, Observer는 "사용자가 무엇을 했는가" 에 초점을 둡니다.
Observer 사용법
import gsap from "gsap";
import { Observer } from "gsap/all";
gsap.registerPlugin(Observer);
Observer를 언제 사용하는지
다음과 같은 경우에 적합합니다.
- 스크롤을 "연속값"이 아닌 "이벤트"로 다루고 싶을 때
- 풀페이지 스크롤 (한 번에 한 섹션씩 이동)
- 스크롤을 막고 직접 제어하고 싶을 때
- 휠/터치/키 입력을 동일한 로직으로 처리하고 싶을 때
- 모바일과 데스크톱 입력을 통합 처리할 때
👉 "스크롤 행위 자체를 제어" 해야 할 때 적합합니다.
Observer 간단한 사용 예시
Observer.create({
type: 'wheel,touch',
onUp: () => {
console.log('위로 스크롤');
},
onDown: () => {
console.log('아래로 스크롤');
},
preventDefault: true,
});
- 사용자의 휠 / 터치 입력을 감지
- 스크롤 기본 동작을 막고(preventDefault) 직접 제어 가능
풀페이지 스크롤 구현 시 사용자가 스크롤 제어 가능할 때
어떤 방법으로 구현하는 게 좋을까?
풀페이지 스크롤을 구현할 때 가장 먼저 고려해야 할 요소는 사용자에게 스크롤 제어권이 있는지 여부입니다. 이번 프로젝트에서는 사용자가 스크롤을 직접 제어할 수 있는 상태에서 풀페이지 스크롤을 구현해야 했습니다. 처음에는 GSAP에서 공식적으로 권장하는 ScrollTrigger + snap 방식을 사용했지만, 실제 UX 관점에서 몇 가지 문제가 발생했습니다.
ScrollTrigger snap의 한계
ScrollTrigger의 snap 기능은 구현이 간단하고 안정적이지만, 사용자에게 스크롤 제어권이 있는 상황에서는 다음과 같은 한계가 있었습니다.
- 스냅 이동 후 한 템포 멈춘 뒤 다음 스크롤이 가능
- 연속적인 휠 입력 시 반응이 즉각적이지 않음
- 사용자가 "스크롤이 먹히지 않는다"고 느낄 수 있음
- 섹션 전환 타이밍을 세밀하게 제어하기 어려움
이러한 특성 때문에, 스크롤을 적극적으로 사용하는 사용자에게는 UX 저하로 이어질 수 있다고 판단했습니다.
해결 방식: Observer + 직접 섹션 제어
위 문제를 해결하기 위해 Observer를 사용한 방식으로 전환했습니다.구현 방식은 다음과 같습니다.
- Observer로 휠/터치 입력을 감지하여 섹션 이동 트리거 처리
- gsap.to를 사용해 섹션 이동 애니메이션을 직접 제어
- 이동 중에는 추가 입력을 막아 중복 애니메이션 방지
- 별도의 scroll 이벤트를 통해 현재 스크롤 위치를 계산
- 현재 위치를 기준으로 섹션 인덱스를 산출하고, 해당 섹션으로 이동
이를 통해 사용자는 스크롤을 자유롭게 사용할 수 있으면서도, 풀페이지 전환 UX를 유지할 수 있었습니다.
Observer를 선택한 이유
Observer를 사용함으로써 다음과 같은 이점을 얻을 수 있었습니다.
- 스크롤 입력을 감지하는 즉시 섹션 이동 트리거 가능
- gsap.to를 통해 섹션 이동 타이밍을 완전히 제어
- 이동 중 추가 스크롤 입력을 차단하여 UX 안정성 확보
- 데스크톱과 모바일에서 동일한 인터랙션 UX 제공
정리
- 사용자에게 스크롤 제어의 자유도가 없는 경우
→ ScrollTrigger + snap 방식이 구현도 간단하고 안정적이며 적합 - 사용자가 스크롤을 직접 제어해야 하는 경우
→ Observer를 활용해 섹션 이동 타이밍을 직접 제어하는 방식이 더 적합
결론 요약
👉 연출 중심 → ScrollTrigger
👉 UX/인터랙션 중심 → Observer
풀페이지 스크롤 구현 코드
/**
* GSAP 스크롤 설정
*/
const clamp = (value: number, min: number, max: number) => {
return Math.max(min, Math.min(value, max)); // 센셕 인덱스
}
const getSectionIndexByScroll = (
scrollTop: number,
viewportHeight: number,
totalSections: number
) => {
return clamp(Math.round(scrollTop / viewportHeight),0, totalSections - 1);
};
useEffect(() => {
if (typeof window === "undefined") return;
const container = containerRef.current;
if (!container) return;
const sections = sectionsRef.current.filter(Boolean);
const totalSections = sections.length + 1;
// 섹션 이동
const moveToSection = (index: number) => {
if (isScrollingRef.current) return;
const targetIndex = clamp(index, 0, totalSections - 1);
const targetScrollTop = targetIndex * window.innerHeight;
isScrollingRef.current = true;
currentSectionRef.current = targetIndex;
gsap.to(container, {
scrollTop: targetScrollTop,
duration: 1.2,
ease: "power3.out",
onComplete: () => {
isScrollingRef.current = false;
},
});
};
// 스크롤바 드래그 시 섹션 동기화
const syncSectionIndexOnScroll = () => {
if (isScrollingRef.current) return;
currentSectionRef.current = getSectionIndexByScroll( container.scrollTop, window.innerHeight, totalSections ) || 0;
};
// 사용자 wheel 움직임 추적 후 섹션 index 변경
const observer = Observer.create({
target: container,
type: "wheel,touch,pointer",
wheelSpeed: -1,
tolerance: 10,
preventDefault: true,
onUp: () => moveToSection(currentSectionRef.current + 1),
onDown: () => moveToSection(currentSectionRef.current - 1),
});
// 스크롤 이벤트 바인딩
container.addEventListener("scroll", syncSectionIndexOnScroll);
return () => {
observer.kill();
container.removeEventListener("scroll", syncSectionIndexOnScroll);
};
}, [control]);
완성본
