Intersection Observer로 스크롤 기반 애니메이션 구현

배경

회사 소개 웹사이트에서 스크롤에 따라 섹션이 등장하는 애니메이션이 필요했다. 스크롤 이벤트 리스너로 구현하면 매 프레임마다 getBoundingClientRect()를 호출해 리플로우가 발생한다. IntersectionObserver를 사용해 요소가 뷰포트에 진입할 때만 애니메이션을 트리거하도록 했다.

useIntersectionObserver 훅

import { useEffect, useRef, useState } from 'react';

interface UseIntersectionObserverOptions {
  threshold?: number;
  rootMargin?: string;
  triggerOnce?: boolean;
}

export default function useIntersectionObserver(
  options: UseIntersectionObserverOptions = {},
) {
  const { threshold = 0.3, rootMargin = '0px 0px -50px 0px', triggerOnce = true } = options;
  const [isVisible, setIsVisible] = useState(false);
  const elementRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          if (triggerOnce) observer.disconnect();
        } else if (!triggerOnce) {
          setIsVisible(false);
        }
      },
      { threshold, rootMargin },
    );

    if (elementRef.current) observer.observe(elementRef.current);
    return () => observer.disconnect();
  }, [threshold, rootMargin, triggerOnce]);

  return { isVisible, elementRef };
}

주요 설계 결정:

  • threshold: 0.3: 요소의 30%가 보여야 트리거. 0으로 설정하면 1px만 보여도 실행되어 사용자가 애니메이션을 인지하기 어렵다.
  • rootMargin: '0px 0px -50px 0px': 하단에 -50px 마진을 줘서, 요소가 뷰포트 하단 50px 안쪽에 진입해야 트리거된다. 스크롤할 때 요소가 충분히 올라온 후 애니메이션이 시작되는 효과.
  • triggerOnce: true: 한 번 트리거되면 disconnect()로 옵저버를 해제한다. 등장 애니메이션은 반복할 필요가 없다.

CSS 애니메이션

@keyframes slide-up {
  from { opacity: 0; transform: translateY(20px); }
  to { opacity: 1; transform: translateY(0); }
}

.animate-slide-up {
  animation: slide-up 0.6s ease-out forwards;
}

.animate-fade-in-up {
  animation: slide-up 0.8s ease-out forwards;
}

순차적 등장을 위해 카드별로 딜레이를 다르게 설정했다.

.animate-service-card-1 { animation: slide-up 0.6s ease-out 0.1s forwards; }
.animate-service-card-2 { animation: slide-up 0.6s ease-out 0.2s forwards; }
.animate-service-card-3 { animation: slide-up 0.6s ease-out 0.3s forwards; }
.animate-service-card-4 { animation: slide-up 0.6s ease-out 0.4s forwards; }

forwards는 애니메이션 종료 후 마지막 프레임의 스타일을 유지시킨다. 이를 누락하면 애니메이션 후 요소가 원래 위치(opacity: 0)로 돌아간다.

접근성

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

모션 민감성이 있는 사용자를 위해 prefers-reduced-motion 미디어 쿼리로 모든 애니메이션을 비활성화한다. 0ms가 아닌 0.01ms로 설정하는 이유는 animation-fill-mode: forwards가 적용되어 최종 상태로 즉시 이동하게 하기 위해서다.

컴포넌트에서의 사용

function TextSection({ title, description }: Props) {
  const { isVisible, elementRef } = useIntersectionObserver();

  return (
    <div ref={elementRef}
      className={isVisible ? 'animate-fade-in-up' : 'opacity-0'}>
      <h2>{title}</h2>
      <p>{description}</p>
    </div>
  );
}

초기 상태는 opacity-0으로 숨기고, isVisibletrue가 되면 애니메이션 클래스를 적용한다. 훅이 elementRef를 반환하므로 ref를 수동으로 관리할 필요 없다.

서비스 카드 섹션처럼 여러 카드가 순차적으로 나타나는 경우:

function ServicesSection({ services }: Props) {
  const { isVisible, elementRef } = useIntersectionObserver();

  return (
    <div ref={elementRef}>
      <h2 className={isVisible ? 'animate-service-title' : 'opacity-0'}>
        서비스
      </h2>
      {services.map((service, i) => (
        <div key={i}
          className={isVisible ? `animate-service-card-${i + 1}` : 'opacity-0'}>
          {service.name}
        </div>
      ))}
    </div>
  );
}

부모 요소 하나에만 Observer를 걸고, 자식 카드들은 CSS 딜레이로 순차 등장시킨다. Observer를 카드마다 거는 것보다 효율적이다.

requestAnimationFrame 무한 스크롤

파트너 로고 섹션에서는 로고가 끊임없이 흐르는 애니메이션을 requestAnimationFrame으로 구현했다.

function Partners({ logos }: Props) {
  const positionRef = useRef(0);
  const containerRef = useRef<HTMLDivElement>(null);
  const [isPaused, setIsPaused] = useState(false);

  useEffect(() => {
    let frameId: number;
    const speed = 0.5;

    const animate = () => {
      if (!isPaused && containerRef.current) {
        positionRef.current -= speed;
        const totalWidth = containerRef.current.scrollWidth / 2;
        if (Math.abs(positionRef.current) >= totalWidth) {
          positionRef.current = 0;
        }
        containerRef.current.style.transform = `translateX(${positionRef.current}px)`;
      }
      frameId = requestAnimationFrame(animate);
    };

    frameId = requestAnimationFrame(animate);
    return () => cancelAnimationFrame(frameId);
  }, [isPaused]);

  return (
    <div onMouseEnter={() => setIsPaused(true)}
         onMouseLeave={() => setIsPaused(false)}>
      <div ref={containerRef}>
        {[...logos, ...logos].map((logo, i) => (
          <img key={i} src={logo} />
        ))}
      </div>
    </div>
  );
}

로고 배열을 두 번 복제해서 이어붙이고, 절반 지점에 도달하면 위치를 0으로 리셋한다. 시각적으로 끊김 없는 무한 루프가 된다. hover 시 일시정지, 모바일에서는 터치로 일시정지한다.

CSS animation으로도 구현할 수 있지만, requestAnimationFrame 방식은 일시정지/속도 변경 등 동적 제어가 쉽다.

Swiper 통합

모바일 해상도에서는 카드를 Swiper로 슬라이드하도록 전환한다.

import { Swiper, SwiperSlide } from 'swiper/react';
import { Pagination } from 'swiper/modules';

function MobileServiceCards({ services }: Props) {
  return (
    <div className="block md:hidden">
      <Swiper
        modules={[Pagination]}
        slidesPerView="auto"
        spaceBetween={20}
        slidesOffsetBefore={20}
        slidesOffsetAfter={20}
        pagination={{ clickable: true }}
      >
        {services.map((service, i) => (
          <SwiperSlide key={i} style={{ width: '80%' }}>
            <ServiceCard {...service} />
          </SwiperSlide>
        ))}
      </Swiper>
    </div>
  );
}

slidesPerView: 'auto'SwiperSlidewidth: 80%를 지정해 양쪽에 다음 카드의 일부가 살짝 보이는 peek 효과를 준다. slidesOffsetBefore/After로 첫 번째와 마지막 카드에도 좌우 여백이 생긴다.

Kakao Map 임베딩

Contact 섹션에 카카오 지도를 임베딩했다. SDK를 beforeInteractive 전략으로 로드하고, Geocoder로 주소를 좌표로 변환한다.

function KakaoMap({ address, markerImageUrl, mapLevel }: Props) {
  const mapRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    window.kakao.maps.load(() => {
      const container = mapRef.current;
      const map = new window.kakao.maps.Map(container, {
        center: new window.kakao.maps.LatLng(37.4812, 126.8826),
        level: mapLevel ?? 4,
      });

      const geocoder = new window.kakao.maps.services.Geocoder();
      geocoder.addressSearch(address, (result, status) => {
        if (status === window.kakao.maps.services.Status.OK) {
          const coords = new window.kakao.maps.LatLng(result[0].y, result[0].x);
          map.setCenter(coords);
          new window.kakao.maps.CustomOverlay({
            position: coords,
            content: `<img src="${markerImageUrl}" style="width:40px;height:40px;" />`,
            map,
          });
        }
      });
    });
  }, [address]);

  return <div ref={mapRef} style={{ width: '100%', height: '400px' }} />;
}

CustomOverlay로 기본 마커 대신 회사 로고를 표시한다. next.config.ts의 CSP에 카카오 도메인(dapi.kakao.com, ssl.daumcdn.net 등)을 추가해야 한다.

Reference

연결문서