FE 개발을 하다 보면 특정 요소의 가시성(화면에 보이느냐, 보이지 않느냐)에 따라 여러가지 동작을 해야 하는 요구사항이 생기곤 합니다. 예를 들어, 스크롤을 내리다가 특정 요소가 보여지는 시점에 애니메이션이 동작해야 하거나 라는 식으로 말이죠.
const someElement = document.querySelector("...");
someElement.addEventListener("scroll", onScrollHandler);
기존에는 각 요소에 scroll
관련 이벤트를 달아서, 스크롤 이벤트가 발생할 때 마다 콜백을 실행시켜 조작을 하는 방식으로 구현을 했었습니다. 하지만 이 방식은 scroll
이벤트를 등록한 요소가 늘어날수록 엄청난 성능 저하를 일으킬 수 있습니다.
어떻게 하면 이런 구닥다리 방식 말고, 우아하게 요구사항을 구현할 수 있을까요?
🚨 글을 작성하는 현재 시점에선 해당 API는 실험적인 기능임을 명심하자!
특정 DOM 요소(이하 타겟 요소)와 그 상위 요소, 혹은 최상위 도큐먼트인 viewport(이하 루트 요소)와의 교챠영역에 대한 변화를 비동기적으로 감지할 수 있는 API입니다.
보통 스크롤이 어느 위치에 도달했을 때, 애니메이션 혹은 특정 로직을 실행하기 위한 장치로 많이 사용합니다.
new IntersectionObserver
로 생성된 IntersectionObserver
인스턴스 객체는 설정된 비율 만큼의 가시성을 계속 감시합니다. 이 생성자 함수는 다음과 같은 세가지 옵션을 설정할 수 있습니다.
root
rootMargin
threshold
interface DomRectReadOnly {
bottom: number,
height: number,
left: number,
right: number,
top: number,
width: number,
x: number,
y: number,
}
interface IntersectionObserverEntry {
boundingClientRect: DomRectReadOnly, // observable의 전체 레이아웃 정보
intersectionRatio: number, // intersection root와 observable이 얼마나 교차하는지. (0 ~ 1)
intersectionRect: DomRectReadOnly, // intersection root와 observable이 교차한 영역에 대한 레이아웃 정보
isIntersecting: boolean, // intersection root와 교차 여부
isVisible: boolean,
rootBounds: DomRectReadOnly, // intersection root의 레이아웃 정보
target: HTMLElement, // observable 요소
time: number, // IntersectionObserver 인스턴스가 생성된 시점을 기준으로, 이벤트가 일어난 시간
}
각 인터페이스에 대한 자세한 정보는 하단의 참고 섹션을 참고해주세요.
const observer = new IntersectionObserver((entries) => {
const target = entries[0];
});
const target = document.querySelector('#target');
observer.observe(target);
일반적인 경우(option.threshold
가 0인 경우. 대상 요소가 완전히 벗어나거나 완전히 들어온 경우 트리거)에는 다음과 같은 사항만 체크하면 됩니다.
intersectionRatio
가 1
인지 0
인지isIntersecting
이 true
인지 false
인지isVisible
이 true
인지 false
인지💡 해당 섹션은 단일 요소를 관찰하는 방법에 대해 설명합니다.
스크롤의 위치에 따라 네비게이션 바가 보여지고 숨겨지는 로직을 구현해보도록 하겠습니다.
Next.js 에서는 컴포넌트 함수 내부에서는 document
객체에 접근할 수 없기 때문에, useEffect
내부에서 DOM 객체에 접근해야 합니다.
useEffect(() => {
// document 객체는 컴포넌트 함수 내에서 접근할 수 없다는 것을 명심!
const preHeader = document.querySelector('#pre-header');
const observer = new IntersectionObserver((entries) => {});
return () => {
observer.disconnect();
};
}, []);
감시할 대상을 얻은 뒤, observer를 만듭니다. 클린업 함수에서 감시를 해제하는 것도 잊지 마세요!
function isHTMLElement(target: any): target is HTMLElement {
return target instanceof HTMLElement;
}
useEffect(() => {
...
if (isHTMLElement(preHeader)) {
observer.observe(preHeader);
}
...
}, []);
타입가드 함수를 이용해 타입체크를 한 뒤, 감시합니다.
const observer = new IntersectionObserver((entries) => {
const target = entries[0];
if (isHTMLElement(headerRef.current)) {
if (target.intersectionRatio === 0) {
headerRef.current.classList.add('scroll-down');
}
if (target.intersectionRatio > 0) {
headerRef.current.classList.remove('scroll-down');
}
}
});
입맛에 맞게 콜백 함수를 수정합니다.
스크롤을 내리면 경계선과 그림자가 생기며, 다시 스크롤이 맨 위로 붙으면 사라집니다.
💡 해당 섹션은 다중 요소를 관찰하는 방법에 대해 설명합니다.
💡 해당 기능은
scroll-snap-type
속성과scroll-snap-align
속성으로 쉽게 구현할 수 있습니다.
이번에는 One Page Scroll (혹은 Full Page Scroll) 을 구현해보도록 하겠습니다.
먼저, Body 컴포넌트를 구현합니다.
import React, { useEffect } from "react";
export function Body() {
return (
<div>
<div className={"screen lightpink"}>first screen</div>
<div className={"screen lightblue"}>second screen</div>
<div className={"screen lightgreen"}>third screen</div>
<div className={"screen lightcoral"}>fourth screen</div>
<div className={"screen lightsteelblue"}>fifth screen</div>
</div>
);
}
여기서 각 스크린 요소가 100vh
를 가지도록 하는 것이 중요합니다.
.screen {
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
font-size: 4rem;
}
.lightpink {
background-color: lightpink;
}
.lightblue {
background-color: lightblue;
}
.lightgreen {
background-color: lightgreen;
}
.lightcoral {
background-color: lightcoral;
}
.lightsteelblue {
background-color: lightsteelblue;
}
그리고 obserber 객체를 생성합니다.
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {},
{
threshold: 0.1,
}
);
return () => {
observer.disconnect();
};
});
이제 TS를 위한 유틸 함수를 정의합니다.
function isHTMLElement(target: any): target is HTMLElement {
return target instanceof HTMLElement;
}
function isNodeList(target: any): target is NodeList {
return target instanceof NodeList;
}
useEffect
내에 관찰 로직을 구현합니다.
const targets = document.querySelectorAll(".screen");
if (isNodeList(targets)) {
const nodeArray = Array.from(targets);
const isAllDiv = nodeArray.every((node) => node.tagName === "DIV");
if (isAllDiv) {
nodeArray.forEach((node) => observer.observe(node));
}
}
콜백 함수에 스크롤 기능을 구현합니다.
(entries) => {
const [observable] = entries.filter(
(observable) => observable.isIntersecting
);
if (observable && isHTMLElement(observable.target)) {
const offsetTop = observable.target.offsetTop;
window.scroll({
left: 0,
top: offsetTop,
behavior: "smooth",
});
}
}
스크롤 하면 한 페이지 단위로 착착 달라 붙도록 동작합니다. (가끔 미세하게 어긋나긴 하네요..)
번외로, CSS의 scroll-snap
을 이용해, One Page Scroll을 더 편하게 구현할 수 있습니다.
먼저, 캐러셀처럼 크기가 동일한 자식 요소들과, 이것들을 담는 부모 요소를 정의합니다.
<div className={"screen-container"}>
<div className={"screen lightpink"}>first screen</div>
<div className={"screen lightblue"}>second screen</div>
<div className={"screen lightgreen"}>third screen</div>
<div className={"screen lightcoral"}>fourth screen</div>
<div className={"screen lightsteelblue"}>fifth screen</div>
</div>
여기서 중요한 점은, 부모 요소가 스크롤을 가질 수 있도록 하는 것입니다. 즉, 부모요소에 overflow
가 발생하면서, 해당 css 속성이 visible
같은 것이 아니여야 된다는 뜻이죠.
.screen-container {
overflow: scroll; /* visible 같은 속성은 피해주세요 */
width: 100vw;
height: 100vh;
scroll-snap-type: y mandatory; /* y축, 맨데토리 */
}
.screen {
width: 100vw; /* 캐러셀처럼, 부모와 자식의 크기가 동일해야 보기 좋습니다. */
height: 100vh;
scroll-snap-align: start; /* 스냅 위치를 지정합니다. 자세히는 모르겠어요.. */
}
이렇게 구성하면 구현 끝!
MDN - IntersectionObserver
MDN - DOMRectReadOnly
MDN - IntersectionObserverEntry