Crohasang Logo
#11

GSAP 라이브러리를 활용한 랜딩페이지 제작하기

2025년 9월 21일 오후 12:42

랜딩페이지 🔗 https://www.konkuk-kuit.com/6/introduce

0. GSAP를 써보자

2025년 2학기를 맞이하여, 운영진으로 참여했던 KUIT(건국대학교 기획/개발 동아리)도 이제 6기를 맞아 부원들을 모집해야되는 시기가 다가왔다. 저번 4기와 5기 부원 모집 때에는 framer-motion 라이브러리를 활용하여 풀 페이지 스크롤 애니메이션 랜딩페이지를 구현했었는데, 이번 6기 모집 때 똑같은 템플릿을 활용할지 아니면 새로운 인터랙션을 구현할지 고민이 되었다.

4기 랜딩페이지 🔗 https://www.konkuk-kuit.com/4/introduce

풀페이지 스크롤 애니메이션 구현기 🔗 https://quickchabun.tistory.com/131

부원 모집까지 시간이 얼마 안 남은 상황이었다. 기존 템플릿에 배경만 새로 바꿔서 적용할까 고민하던 찰나, 요즘 유행하는 인터랙션 라이브러리는 무엇이 있나 궁금해져서 구글링을 해보았다. 그리고 발견한 신세계. 바로 ‘GSAP 라이브러리’였다.

먼저 공식페이지에 들어가 GSAP를 활용한 인터랙션 예시를 살펴보았는데, css와 바닐라 JS로 구현하려면 매우 어려워보이는 애니메이션들이 부드럽고 멋지게 구현된 모습에 매료되었다. 아직 써보지는 않았지만, LLM과 같이라면 구현할 수 있겠지. 바로 KUIT 랜딩페이지 프로젝트에 새로운 브랜치를 파서 작업을 시작해보았다.

GSAP 공식페이지 🔗 https://gsap.com/

GSAP 쇼케이스 모음 🔗 https://gsap.com/showcase/

1. 구조

📦introduce
 ┣ 📂_components
 ┃ ┣ 📜BodyClassManager.tsx
 ┃ ┣ 📜CountdownTimer.tsx
 ┃ ┣ 📜FirstInteraction.tsx
 ┃ ┣ 📜IntroduceAnimationContainer.tsx
 ┃ ┣ 📜MarqueeItem.tsx
 ┃ ┣ 📜SecondInteraction.tsx
 ┃ ┣ 📜TechStack.tsx
 ┃ ┗ 📜ThirdInteraction.tsx
 ┣ 📂_constants
 ┃ ┗ 📜staff.ts
 ┣ 📂constants
 ┃ ┗ 📜animationConfig.ts
 ┗ 📜page.tsx

IntroduceAnimationContainer 컴포넌트에 애니메이션에 사용될 모든 DOM 요소의 ref를 선언했고, GSAP를 활용한 스크롤 애니메이션 로직 전체가 이 컴포넌트 안에 존재한다.

이 컴포넌트에는 맨 처음 사이트에 접속하면 진행되는 인트로 애니메이션과, 그 뒤에 스크롤을 시작하면 진행되는 스크롤 애니메이션을 관리하는 useEffect 훅 두 개가 존재한다.

2. 인트로 애니메이션

image 1

첫 줄에 ‘KUIT’, 두 번째 줄에 ‘SIXTH’가 있고 두 ‘I’가 내려오면서 합쳐지는 인트로 애니메이션이 구현된다.

  useEffect(() => {
    const ctx = gsap.context(() => {
      gsap.set(lettersRef.current, { autoAlpha: 0, y: 10 });
      gsap.set(iLineRef.current, { scaleY: 0, transformOrigin: 'top center' });
      gsap.set(secondInteractionRef.current, { autoAlpha: 0 });
      gsap.set(thirdInteractionRef.current, { autoAlpha: 0 });
      gsap.set(scrollDownRef.current, { autoAlpha: 0 });

      const introTl = gsap.timeline({
        onComplete: () => {
          setIsIntroFinished(true);
          gsap.to(scrollDownRef.current, {
            autoAlpha: 1,
            duration: 1.5, 
            ease: 'power1.inOut',
          });
        },
      });

      introTl
        .to(lettersRef.current, {
          autoAlpha: 1,
          y: 0,
          duration: 0.5,
          ease: 'power2.out',
          stagger: TEXT_ANIMATION_CONFIG.intro.letterStagger,
        })
        .to(iLineRef.current, {
          scaleY: () => {
            if (!iLineRef.current) return 1;
            const rect = iLineRef.current.getBoundingClientRect();
            const viewportHeight = window.innerHeight;
            const distanceToBottom = viewportHeight - rect.top;
            const initialHeight = iLineRef.current.offsetHeight;
            if (initialHeight === 0) return 1;
            return distanceToBottom / initialHeight;
          },
          duration: TEXT_ANIMATION_CONFIG.intro.lineScaleDuration,
          ease: 'expo.inOut',
        });
    }, containerRef);
    return () => ctx.revert();
  }, []);

먼저 **gsap.set()**을 활용해 요소들의 초기 상태를 설정한다. 여기서

‘autoAlpha: 0’ - 요소들을 투명하게 만듦,

‘scaleY: 0’ - 요소의 높이를 0으로 만들어 화면에서 안보이게 만듦

‘transformOrigin ‘top center’’: 윗변의 중앙을 기준으로 효과가 시작

이라는 뜻을 가지고 있다.

그 다음**, gasp.timeline()**을 사용해 여러 애니메이션을 순서대로 제어할 타임라인(introTl)을 생성한다. 코드에서 확인할 수 있듯이 to 메서드에 첫 번째 매개변수에 애니메이션 ref를 작성하고, 두 번째 매개변수에 애니메이션 로직을 작성한다. .to 체이닝을 통하여 애니메이션이 차례대로 진행된다.

lettersRef는 처음 글자들(I 제외)이 출력되는 애니메이션이다.

ease: ‘power2.out’ - 애니메이션이 빠르게 시작해서 점차 부드럽게 감속하며 끝나는 효과 (t^2),

iLineRef는 알파벳 I가 생성되면서 밑으로 내려가는 애니메이션이다.

이 때, scaleY에는 선이 화면 하단까지 닿게 동적으로 계산하는 로직이 들어갔다.

ease: ‘expo.inOut’ - 천천히 시작 → 빠르게 → 천천히 마무리 하는 효과 (2^10(t-1)),

introTl의 onComplete 콜백은 애니메이션이 종료된 후 실행되는데, setIsIntroFinished 상태를 true로 만들어 스크롤 애니메이션이 실행되게 만들고, scrollDownRef가 가리키는 '아래로 스크롤하세요' 문구를 화면에 나타나게 한다.

3. 스크롤 애니메이션

인트로 애니메이션이 끝나고 setIsIntroFinished가 true가 되면 IntroduceAnimationContainer의 두 번째 useEffect 훅이 실행된다. 인터랙션 컴포넌트는 세 개로 구분했지만, 처음부터 마지막까지 계속 아래로 스크롤만 하면 되기 때문에, 하나의 scrollTl timeline에 .to 메서드로 쭉 연결하는 단순한 구조가 되었다. timeline이 완성한 후, timeline과 containerRef를 ScrollTrigger.create 메서드에 연결하면 스크롤 애니메이션이 실행된다.

useEffect(() => {
  if (!isIntroFinished) return;

  const ctx = gsap.context(() => {
    // 각 요소의 초기 상태를 설정
    gsap.set(projectGroupRef.current, { autoAlpha: 1 });
    (...)
    techStackRefs.forEach(ref => {
      gsap.set(ref.current, { autoAlpha: 0, xPercent: 100 });
    });

    // 하나의 긴 타임라인을 생성
    const scrollTl = gsap.timeline();
    
    // 타임라인에 .to()를 체이닝하여 애니메이션을 순서대로 추가
    scrollTl
    
      // FirstInteraction이 사라지는 애니메이션
      .to(scrollDownRef.current, { autoAlpha: 0, ... })
      .to(textContainerRef.current, { scale: 15, ... })
      .to(firstInteractionRef.current, { autoAlpha: 0, ... })
      
      // SecondInteraction이 나타나고, 숫자 모핑/확대되는 애니메이션
      .to(secondInteractionRef.current, { autoAlpha: 1, ... })
      
      // (34 -> 49 숫자 모핑 및 관련 요소 애니메이션)
      .to(digit9Ref.current, { scale: 200, ... })

      // ThirdInteraction이 나타나는 애니메이션
      .to(thirdInteractionRef.current, { autoAlpha: 1, ... });

    // ThirdInteraction 내부의 각 팀 소개 애니메이션을 루프로 추가
    techStackRefs.forEach(ref => {
      scrollTl
        .to(ref.current, { autoAlpha: 1, xPercent: 0, ... })
        .to({}, { duration: 1.5 })
        .to(ref.current, { autoAlpha: 0, xPercent: -100, ... });
    });

    // 마지막 '지원하기' 섹션이 나타나는 애니메이션 추가
    scrollTl.to(applySectionRef.current, { autoAlpha: 1, ... });

    // 완성된 타임라인을 ScrollTrigger에 연결하여 스크롤과 동기화
    ScrollTrigger.create({
      animation: scrollTl,
      trigger: containerRef.current,
      scrub: 1,
      pin: true,
      // ...
    });

  }, containerRef);

  return () => {
    // ...
  };
}, [isIntroFinished]);

3-1. FirstInteraction (’KUIT SIXTH’ 확대)

image 2

스크롤이 시작되면 textContainerRef의 scale 값을 크게 키워 'KUIT SIXTH' 텍스트가 화면을 채우듯 확대되고, 이어서 firstInteractionRef 전체가 autoAlpha: 0으로 부드럽게 사라지며 다음 장면으로 전환된다.

scrollTl
  // ... scrollDownRef 사라지는 부분 ...
  .to(textContainerRef.current, {
    scale: TEXT_ANIMATION_CONFIG.scroll.containerFinalScale,
    ease: 'power2.in',
  })
  .to(firstInteractionRef.current, {
    autoAlpha: 0,
    duration: 0.5,
  })

3-2. SecondInteraction (’34’ → ‘49’에서 4 이동, 9 확대)

image 3

image 4

FirstInteraction이 사라진 후 secondInteractionRef가 나타나며 숫자 '34'를 보여준다.

스크롤이 계속되면 '3'이 사라지고, digit4Ref의 x 속성을 동적으로 계산하여 '4'가 왼쪽으로 자연스럽게 이동해 '49'를 완성한다. 이어서 digit9Ref의 scale 값을 200으로 설정 흰색 숫자 9가 확대되면서 동시에 화면도 검은색에서 흰색으로 전환된다.

scrollTl
  // ...
  .to(secondInteractionRef.current, {
    autoAlpha: 1,
    duration: 0.5,
  })
  // ... (관련 링크 나타나고 사라지는 부분)
  .to(
    digit4Ref.current,
    {
      x: () => (digit3Ref.current ? -digit3Ref.current.offsetWidth : 0),
      duration: 1,
      ease: 'power3.inOut',
    },
    '<',
  )
  // ... (숫자 9와 관련 링크 나타나고 사라지는 부분)
  .to(
    digit9Ref.current,
    {
      scale: 200,
      duration: 1.5,
      ease: 'expo.in',
      transformOrigin: '75% 25%',
    },
    '<',
  )

3-3. ThirdInteraction (상하단 띠, 운영진 우→좌 이동)

image 5

화면이 흰색으로 바뀌고, thirdInteractionRef가 나타나며 상하단의 Marquee 띠가 나타난다. 이어서 techStackRefs 배열에 담긴 운영진 및 각 파트의 ref들을 forEach 루프로 순회한다. 각 요소는 xPercent 값을 100에서 0으로 변경하여 오른쪽에서 부드럽게 등장하고, 잠시 멈춘 후 -100으로 변경되어 왼쪽으로 사라지는 애니메이션이 스크롤에 맞춰 순차적으로 실행된다.

scrollTl
  // ...
  .to(
    thirdInteractionRef.current,
    {
      autoAlpha: 1,
      duration: 0.7,
      ease: 'power2.out',
    },
    '-=0.3',
  );

techStackRefs.forEach(ref => {
  scrollTl
    .to(ref.current, { autoAlpha: 1, xPercent: 0, duration: 1, ease: 'power2.out' })
    .to({}, { duration: 1.5 })
    .to(ref.current, { autoAlpha: 0, xPercent: -100, duration: 1, ease: 'power2.in' });
});

참고로 좌측 상단에 보이는 애니메이션에는 Lottiefiles 포맷을 적용했다. 최근 FEConf에서 Lottie 관련 강연을 들어봤는데 계기로 이번 프로젝트에 도입해 보았는데, 파일 용량이 작고 적용하기도 간편해서 만족스러웠다.

참고로 스크롤 애니메이션이 끝나고 계속 아래로 스크롤을 하면 스크롤이 안되어야하는데, 계속 스크롤이 되면서 검은 화면이 나타나는 에러가 발생했었다. 계속 에러를 고치려고 코드를 수정해봤지만 수정이 되지 않았고, 마지막 화면이 나와도 스크롤을 막지 않고 무한으로 스크롤이 되게 함으로써 (이 때 화면은 변하지 않는다) 사용자들에게 스크롤을 막은 것처럼 보이게하는 효과를 부여했다.


이번 기회를 통해 GSAP 라이브러리를 활용해 스크롤 애니메이션을 구현하는 법을 배울 수 있었다. 만약 라이브러리 없이 직접 DOM 요소들을 조작하려 했다면 구현하기 훨씬 어려웠을 것 같다. 이번 프로젝트는 처음부터 끝까지 아래로 스크롤만 하면 되었기 때문에 때문에 한 파일에 로직을 전부 구현할 수 있었는데, 만약 컴포넌트마다 다른 애니메이션을 구현해야 했다면 구조를 어떻게 짜면 좋았을 지 고민을 했을 것 같다.

GSAP를 통하여 스크롤 애니메이션 대신 다른 애니메이션들도 구현할 수 있지 않을까. 사용자들이 보고 만족하는 인터랙션에는 스크롤뿐만이 아닌 다른 요소들도 많을 것 같다. 이를 위해 다른 사이트들은 어떤 인터랙션을 구현했는지 레퍼런스들을 많이 찾아보고, 또 어떻게 구현했는지 분석을 열심히 해야겠다.