본문 바로가기
카테고리 없음

GSAP 사용기 : 풀페이지 스크롤은 어떻게 구현하는 게 좋을까

by memeseo 2026. 1. 20.

사용 방법

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]);

완성본