[Next.js] Intersection Observer로 요소 감지하기

찐새·2022년 12월 5일
2

next.js

목록 보기
36/41
post-thumbnail

Intersection Observer API는 타겟 요소와 (최)상위 요소 사이의 교차점 변화를 비동기적으로 관찰하는 방법이다. 두 개의 인자를 받는데, 하나는 콜백 함수이고, 다른 하나는 root, rootMargin, threshold다.

MDN - Intersection observer

root
대상 객체의 가시성을 확인할 때 사용되는 뷰포트 요소입니다. 이는 대상 객체의 조상 요소여야 합니다. 기본값은 브라우저 뷰포트이며, root 값이 null 이거나 지정되지 않을 때 기본값으로 설정됩니다.

rootMargin
root 가 가진 여백입니다. 이 속성의 값은 CSS의 margin 속성과 유사합니다. e.g. "10px 20px 30px 40px" (top, right, bottom, left). 이 값은 퍼센티지가 될 수 있습니다. 이것은 root 요소의 각 측면의 bounding box를 수축시키거나 증가시키며, 교차성을 계산하기 전에 적용됩니다. 기본값은 0입니다.

threshold
observer의 콜백이 실행될 대상 요소의 가시성 퍼센티지를 나타내는 단일 숫자 혹은 숫자 배열입니다. 만일 50%만큼 요소가 보여졌을 때를 탐지하고 싶다면, 값을 0.5로 설정하면 됩니다. 혹은 25% 단위로 요소의 가시성이 변경될 때마다 콜백이 실행되게 하고 싶다면 [0, 0.25, 0.5, 0.75, 1] 과 같은 배열을 설정하세요. 기본값은 0이며(이는 요소가 1픽셀이라도 보이자 마자 콜백이 실행됨을 의미합니다). 1.0은 요소의 모든 픽셀이 화면에 노출되기 전에는 콜백을 실행시키지 않음을 의미합니다.

Next.js에서 간단한 스크롤 애니메이션을 구현하고 싶어 위 API를 적용했다. 구현은 TypeScript를 사용했다.

기본 코드

body {
  height: 6000;
}
const ListItem = ({ fruit }: { fruit: string }) => {
  return (
    <div style={{ marginTop: 800 }}>
      <div
        style={{
          height: 500,
          textAlign: "center",
          fontSize: "2rem",
          opacity: 0,
          transition: "all 0.5s",
        }}
      >
        {fruit}
      </div>
    </div>
  );
};

export default function Home() {
  const fruits = [
    "apple",
    "banana",
    "orange",
    "lemon",
    "lime",
    "pure",
    "peach",
    "berry",
  ];
  return (
    <div>
      {fruits.map((fruit, i) => (
        <ListItem key={i} fruit={fruit} />
      ))}
    </div>
  );
}

요소 획득

옵저빙하려는 요소를 useRef를 이용해 획득했다. 별 거 아닌데 기록하는 이유는 제네릭(generic) 때문이다. 획득하려는 요소에 맞게 제네릭을 입력한다. div 요소가 필요했기 때문에 HTMLDivElement를 제네릭으로 주었다.

const ListItem = ({ fruit }: { fruit: string }) => {
  const target = useRef<HTMLDivElement>(null);
  return (
    <div style={{ marginTop: 800 }}>
      <div
        ref={target}
        style={{
          // (...)
        }}
      >
        {fruit}
      </div>
    </div>
  );
};

제네릭 위계는 EventTarget <- Node <- Element <- HTMLElement <- HTML{요소}Element 순으로 흐른다.

Observe

렌더링 전에는 관찰할 수 없기 때문에 useEffect 내부에 IntersectionObserver 코드를 적었다.

  useEffect(() => {
    let observer: IntersectionObserver;
    if (target) {
      observer = new IntersectionObserver(
        ([e]) => {
          const target = e.target as HTMLElement;
          if (e.isIntersecting) {
            target.style.opacity = "1";
          } else {
            target.style.opacity = "0";
          }
        },
        { threshold: 0.5 }
      );
      observer.observe(target.current as Element);
    }
  }, [target]);

target의 기본값은 null이어서 렌더링 후 타겟을 잡았을 때 IntersectionObserver를 생성한다. 콜백함수의 첫 인자는 observer.observe로 획득한 요소를 배열로 받는다. 여기서는 하나 뿐이어서 구조 분해 할당으로 맨 앞 요소만 가져왔다.

isIntersecting은 현재 요소가 관찰 중인지 여부를 알려준다. 적지 않으면 요소가 화면을 벗어났을 때도 효과가 적용된다.

as를 사용한 이유는 style을 할당할 때 ts2339 에러를 발생시켰기 때문이다. 자꾸 Elementstyle 속성이 없다고 해서 강제로 타입을 지정했다.

threshold: 0.5는 타겟 요소가 절반 이상 보일 때 트리거하겠다는 의미다.

전체 코드

import type { NextPage } from "next";
import { useEffect, useRef } from "react";

const ListItem = ({ fruit }: { fruit: string }) => {
  const target = useRef<HTMLDivElement>(null);
  useEffect(() => {
    let observer: IntersectionObserver;
    if (target) {
      observer = new IntersectionObserver(
        ([e]) => {
          const target = e.target as HTMLElement;
          if (e.isIntersecting) {
            target.style.opacity = "1";
          } else {
            target.style.opacity = "0";
          }
        },
        { threshold: 0.5 }
      );
      observer.observe(target.current as Element);
    }
  }, [target]);
  return (
    <div style={{ marginTop: 800 }}>
      <div
        ref={target}
        style={{
          height: 500,
          textAlign: "center",
          fontSize: "2rem",
          opacity: 0,
          transition: "all 0.5s"
        }}
      >
        {fruit}
      </div>
    </div>
  );
};

export default function Home(): NextPage {
  const fruits = [
    "apple",
    "banana",
    "orange",
    "lemon",
    "lime",
    "pure",
    "peach",
    "berry"
  ];
  return (
    <div>
      {fruits.map((fruit, i) => (
        <ListItem key={i} fruit={fruit} />
      ))}
    </div>
  );
}


참고
코딩애플 - 웹페이지에 스크롤 애니메이션 쉽게 주는 법
MDN Docs - Intersection Observer
ts(2339) 오류 해결하기

profile
프론트엔드 개발자가 되고 싶다

0개의 댓글