이 글은 아래 원문을 번역한 글로 의역이 있을 수 있습니다. 정확한 의미를 파악하고 싶으신 분은 원문을 참고해주시기 바랍니다.
원문: https://www.smashingmagazine.com/2021/07/dynamic-header-intersection-observer/
저작권 정보: https://www.smashingmagazine.com/privacy-policy/
빠른 요약 ↬ 뷰포트 내의 특정 임계값으로 스크롤할 때 페이지의 일부 구성요소가 요소에 응답해야 하는 UI를 구축해야 하는 경우 또는 뷰포트 자체 내부 및 외부 UI를 구축해야 하는 경우 자바스크립트에서, 스크롤에 콜백을 계속 발사하기 위해 event listener를 부착하는 것은 성능 집약적일 수 있으며, 현명하지 못한 방법으로 사용할 경우, 느린 사용자 환경을 만들 수 있다. 하지만 Intersection Observer를 사용한 더 나은 방법이 있다.
Intersection Observer API는 요소를 관찰하고 스크롤 컨테이너에서 특정 지점을 통과할 때 일반적으로(항상 그렇지는 않지만) 콜백 함수를 트리거하는 것을 탐지할 수 있는 JavaScript API이다.
Intersection Observer는 비동기적이기 때문에 메인 스레드에서 스크롤 이벤트를 listening 하는 것보다 더 뛰어난 수행자로 간주될 수 있으며, 콜백은 스크롤 위치가 업데이트될 때마다 실행되는 것이 아닌 우리가 관찰하는 요소가 지정된 임계값을 충족할 때만 실행된다. 이 글에서는 Intersection Observer를 사용하여 웹 페이지의 다른 섹션과 엇갈릴 때 변경되는 고정 헤더 앨리먼트를 구축하는 방법을 보여 주는 예를 살펴보기로 한다.
기본 사용법
Intersection Observer를 사용하려면 먼저 두 가지 매개변수를 가지는 새 관찰자를 만들어야 한다. 관찰자의 옵션이 있는 개체, 그리고 우리가 관찰하고 있는 요소(관찰자 대상이라고 함)가 루트(타겟 요소의 조상이어야 하는 scrolloing container)와 교차할 때마다 실행하고자 하는 콜백 함수.
const options = {
root: document.querySelector('[data-scroll-root]'),
rootMargin: '0px',
threshold: 1.0
}
const callback = (entries, observer) => {
entries.forEach((entry) => console.log(entry))
}
const observer = new IntersectionObserver(callback, options)
관찰자를 만들면 목표 요소를 감시하도록 지시할 필요가 있다.
const targetEl = document.querySelector('[data-target]')
observer.observe(targetEl)
옵션 값은 기본값으로 되돌아가므로 생략할 수 있다.
const options = {
rootMargin: '0px',
threshold: 1.0
}
루트가 지정되지 않은 경우 브라우저 뷰포트로 분류된다. 위의 코드 예제는 rootMargin 그리고 threshold 두 가지 모두에 대한 기본값을 보여준다.이러한 내용은 시각화하기가 어려울 수 있으므로 다음과 같이 설명할 가치가 있다.
rootMargin
rootMargin 값은 루트 요소에 CSS 여백을 추가하는 것과 약간 비슷하며 여백과 마찬가지로 음수 값을 포함한 여러 값을 취할 수 있다. 타겟 요소는 여백에 대해 교차하는 것으로 간주된다.
즉, 요소가 보이지 않을 때에도(스크롤 루트가 뷰포트인 경우) 기술적으로 "간격"으로 분류될 수 있다는 것을 의미한다.
rootMargin의 기본값을 0px로 설정했지만 CSS의 margin 프로퍼티를 사용하는 것처럼 여러 값으로 구성된 문자열을 취할 수 있다.
threshold
threshold는 0과 1 사이의 단일 값 또는 값의 배열로 구성될 수 있다. 교차하는 것으로 간주되기 위해 앨리먼트가 루트 범위 내에 있어야 하는 비율을 나타낸다. 기본값 1을 사용하면 타겟 요소의 100%가 루트 내에 보이면 콜백이 실행된다.
이러한 옵션을 사용하여 요소를 볼 수 있는 것으로 분류할 시점을 시각화하는 것이 항상 쉬운 것은 아니다. 나는 교차로 옵저버를 잡을 수 있도록 작은 도구를 만들었다.
Header 만들기
기본 원리를 파악했으니 이제 동적 헤더를 구축해 봅시다. 우리는 섹션으로 나누어진 웹페이지부터 시작할 것이다. 이 이미지는 우리가 구축할 페이지의 전체 레이아웃을 보여준다.
이 글의 끝에 데모를 포함시켰으니 코드를 보고 싶다면 얼마든지 바로 가십시오. (Github 도 있다.)
각 섹션의 최소 높이는 (내용에 따라 더 길어질 수 있지만) 100vh이다. 헤더는 페이지 상단에 고정되어 사용자가 스크롤할 때 제자리에 유지된다(position: fixed사용). 섹션의 배경색은 서로 다르며, 헤더를 만나면 헤더의 색상이 변경되어 섹션의 색상을 보완한다. 사용자가 있는 현재 섹션을 표시하는 마커도 있으며, 다음 섹션이 도착하면 이 마커가 미끄러진다. 관련 코드로 바로 가기 쉽도록, 만약 당신이 따라 하고 싶은 경우를 대비하여 시작점(Intersection Observer API를 사용하기 전에)으로 미니멀 데모를 설정했다.
마크업
헤더에 대한 HTML로 시작합시다. 홈 링크와 내비게이션이 있는 꽤 간단한 헤딩이 될 겁니다. 특별히 화려한 것은 없지만 몇 가지 데이터 속성을 사용할 겁니다. data-header 헤더 자체(그래서 JS로 요소를 타겟팅할 수 있음) 및 클릭하면 해당 섹션으로 스크롤할 수 있는 data-link속성이 있는 앵커 링크 3개.
<header data-header>
<nav class="header__nav">
<div class="header__left-content">
<a href="#0">Home</a>
</div>
<ul class="header__list">
<li>
<a href="#about-us" data-link>About us</a>
</li>
<li>
<a href="#flavours" data-link>The flavours</a>
</li>
<li>
<a href="#get-in-touch" data-link>Get in touch</a>
</li>
</ul>
</nav>
</header>
다음으로, 우리 페이지의 나머지 부분에 대한 HTML은 섹션으로 나뉜다. 간결성을 위해 기사와 관련된 부분만 포함시켰는데, 전체 마크업이 데모에 포함되어 있다. 각 섹션에는 배경색 이름을 지정하는 데이터 속성과 헤더의 앵커 링크 중 하나에 해당하는 id를 포함하고 있다.
<main>
<section data-section="raspberry" id="home">
<!--Section content-->
</section>
<section data-section="mint" id="about-us">
<!--Section content-->
</section>
<section data-section="vanilla" id="the-flavours">
<!--Section content-->
</section>
<section data-section="chocolate" id="get-in-touch">
<!--Section content-->
</section>
</main>
사용자가 스크롤할 때 페이지 상단에 고정된 상태를 유지할 수 있도록 CSS로 헤더를 배치한다.
header {
position: fixed;
width: 100%;
}
또한 구역에 최소 높이를 부여하고, 내용물의 중심을 잡을 것이다.(이 코드는 IntersectionIntersection Observer가 작동하는데 필요한 것이 아니라 단지 디자인용일 뿐이다.)
section {
padding: 5rem 0;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
IFRAME 경고
이 코드 펜 데모를 만드는 동안, 나는 곤혹스러운 문제에 부딪쳤다. 완벽하게 작동했어야 할 나의 Intersection Observer 코드는 교차점의 정확한 지점에서 콜백을 실행하지 못하고 대신 목표 요소가 뷰포트 가장자리와 교차할 때 실행되는 것이었다. 머리를 좀 긁어본 후, 나는 이것이 코드 펜에서 콘텐츠가 iframe 내에 로드되기 때문임을 깨달았다. (자세한 내용은 Clipping에 관한 MDN문서를 참조하십시오.)
해결 방법으로, 데모에서는 다음과 같이 브라우저 뷰포트가 아닌 스크롤 컨테이너(입출력 옵션의 루트) 역할을 하는 다른 요소로 마크업을 랩핑 할 수 있었다.
<div class="scroller" data-scroller>
<header data-header>
<!--Header content-->
</header>
<main>
<!--Sections-->
</main>
</div>
동일한 데모에서 root로 뷰포트를 사용하는 방법을 원한다면 Github에 포함되어 있다.
CSS
CSS에서는 사용 중인 색상에 대한 몇 가지 사용자 지정 속성을 정의할 겁니다. 또한 헤더 텍스트와 배경색에 대한 사용자 지정 속성 2개를 추가로 정의하고 초기 값을 설정하십시오. (이 두 개의 사용자 지정 속성을 나중에 서로 다른 섹션에 대해 업데이트할 예정입니다)
:root {
--mint: #5ae8d5;
--chocolate: #573e31;
--raspberry: #f2308e;
--vanilla: #faf2c8;
--headerText: var(--vanilla);
--headerBg: var(--raspberry);
}
우리의 header에 다음 사용자 지정 속성을 사용하십시오.
header {
background-color: var(--headerBg);
color: var(--headerText);
}
우리는 또한 다른 섹션에 대한 색상을 설정할 것이다. 나는 데이터 속성을 selectors로 사용하고 있지만, 네가 원한다면 클래스로 쉽게 사용할 수 있어.
[data-section="raspberry"] {
background-color: var(--raspberry);
color: var(--vanilla);
}
[data-section="mint"] {
background-color: var(--mint);
color: var(--chocolate);
}
[data-section="vanilla"] {
background-color: var(--vanilla);
color: var(--chocolate);
}
[data-section="chocolate"] {
background-color: var(--chocolate);
color: var(--vanilla);
}
또한 각 섹션이 시야에 들어오는 경우 헤더에 대해 몇 가지 스타일을 설정할 수 있다.
/* Header */
[data-theme="raspberry"] {
--headerText: var(--raspberry);
--headerBg: var(--vanilla);
}
[data-theme="mint"] {
--headerText: var(--mint);
--headerBg: var(--chocolate);
}
[data-theme="chocolate"] {
--headerText: var(--chocolate);
--headerBg: var(--vanilla);
}
데이터 속성을 사용하는 더 강력한 사례가 있는데, 각 intersection에서 헤더의 data-theme 속성을 전환하기 때문이다.
관찰자 만들기
이제 우리는 우리의 페이지를 위한 기본 HTML과 CSS를 만들었으므로, 우리는 관찰자를 만들어 우리의 각 섹션이 시야에 들어오도록 지켜볼 수 있다. 페이지를 아래로 스크롤할 때 머리글 하단에 섹션이 닿을 때마다 콜백을 보내고 싶다. 이는 헤더의 높이에 해당하는 마이너스 루트 마진을 설정해야 한다는 것을 의미한다.
const header = document.querySelector('[data-header]')
const sections = [...document.querySelectorAll('[data-section]')]
const scrollRoot = document.querySelector('[data-scroller]')
const options = {
root: scrollRoot,
rootMargin: `${header.offsetHeight * -1}px`,
threshold: 0
}
우리는 section의 어느 부분이라도 root margin과 교차하는 경우 콜백이 실행되기를 원하기 때문에 threshold를 0으로 설정한다.
우선 헤더의 data-theme 값을 바꾸기 위해 callback을 만들 것이다. (특히 헤더 요소에 다른 클래스가 적용될 수 있는 경우 클래스를 추가 및 제거하는 것보다 더 간단하다.)
/* The callback that will fire on intersection */
const onIntersect = (entries) => {
entries.forEach((entry) => {
const theme = entry.target.dataset.section
header.setAttribute('data-theme', theme)
})
}
그다음에 sections가 교차하는 것을 관찰하기 위해 관찰자를 만들 것입니다.
/* Create the observer */
const observer = new IntersectionObserver(onIntersect, options)
/* Set our observer to observe each section */
sections.forEach((section) => {
observer.observe(section)
})
이제 각 섹션이 헤더와 만날 때 헤더 색상이 업데이트되는 것을 봐야 한다.
미셸 바커의 pen 해피 페이스 아이스크림 팔루어 2단계를 참조하십시오.
그러나 아래로 스크롤할 때 색상이 올바르게 업데이트되지 않는 것을 알 수 있다. 사실, 헤더는 매번 이전 섹션의 색상으로 업데이트되고 있답니다! 위쪽으로 스크롤하면 다른 한편으로는 완벽하게 작동한다. 우리는 스크롤 방향을 정하고 그에 따라 행동을 바꿀 필요가 있다.
스크롤 방향 찾기
JS에서 스크롤 방향에 대한 변수를 설정합니다. 초기 값은 'up'이고 마지막으로 스크롤 위치(prevYPosition)에 대한 변수를 설정합니다. 그런 뒤 콜백 내에서 스크롤 위치가 이전 값보다 크면 우리는 'down'또는 'up' 등의 direction 설정을 할 수 있다.
let direction = 'up'
let prevYPosition = 0
const setScrollDirection = () => {
if (scrollRoot.scrollTop > prevYPosition) {
direction = 'down'
} else {
direction = 'up'
}
prevYPosition = scrollRoot.scrollTop
}
const onIntersect = (entries, observer) => {
entries.forEach((entry) => {
setScrollDirection()
/* ... */
})
}
또한 헤더 색상을 업데이트하는 새로운 함수를 만들어 타겟 섹션을 인수로 전달한다.
const updateColors = (target) => {
const theme = target.dataset.section
header.setAttribute('data-theme', theme)
}
const onIntersect = (entries) => {
entries.forEach((entry) => {
setScrollDirection()
updateColors(entry.target)
})
}
지금까지 우리는 헤더의 행동에 변화가 없다고 보아야 한다. 그러나 이제 스크롤 방향을 알게 되었으니, 우리의 updateColors() 함수에 다른 타겟을 전달할 수 있다. 스크롤 방향이 올라가면 엔트리 타겟을 사용할 겁니다. 내려간다면 다음 섹션을 이용할 겁니다.
const getTargetSection = (target) => {
if (direction === 'up') return target
if (target.nextElementSibling) {
return target.nextElementSibling
} else {
return target
}
}
const onIntersect = (entries) => {
entries.forEach((entry) => {
setScrollDirection()
const target = getTargetSection(entry.target)
updateColors(target)
})
}
그러나 한 가지 더 문제가 있는데, 섹션이 헤더에 닿았을 때뿐만 아니라 뷰포트 하단에서 다음 요소가 보이면 헤더가 업데이트된다는 점이다. 이것은 우리의 observer가 콜백을 두 번 실행하기 때문이다: 원소가 들어갈 때 한 번, 그리고 떠날 때 다시 한번.
헤더를 업데이트할지 여부를 결정하기 위해 entryentry 객체의 isIntersecting 키를 사용할 수 있다. 헤더 색상이 업데이트되어야 하는지 여부에 대한 boolean 값을 반환하는 또 다른 함수를 만들자.
const shouldUpdate = (entry) => {
if (direction === 'down' && !entry.isIntersecting) {
return true
}
if (direction === 'up' && entry.isIntersecting) {
return true
}
return false
}
우리는 그에 따라 우리의 onIntersect() 함수를 업데이트할 겁니다.
const onIntersect = (entries) => {
entries.forEach((entry) => {
setScrollDirection()
/* Do nothing if no need to update */
if (!shouldUpdate(entry)) return
const target = getTargetSection(entry.target)
updateColors(target)
})
}
이제 우리의 색은 올바르게 업데이트될 것이다. CSS transition을 설정해서 효과가 좀 더 좋아지게 할 수 있다.
header {
transition: background-color 200ms, color 200ms;
}
미셸 바커의 pen 해피 페이스 아이스크림 팔루어 3단계를 참조하십시오.
동적 마커 추가
다음에는 다른 섹션으로 스크롤할 때 위치를 업데이트하는 마커를 헤더에 추가하겠다. 여기에 pseudo-element를 사용할 수 있기 때문에 HTML에 아무것도 추가하지 않아도 되고 간단한 CSS 스타일링을 주어 머리글 왼쪽 상단에 위치시키고 배경색을 부여할 겁니다. 우리는 이를 위해 헤더 텍스트 색상값을 가지는 currentColor를 사용한다.
header::after {
content: '';
position: absolute;
top: 0;
left: 0;
height: 0.4rem;
background-color: currentColor;
}
기본값이 0인 width에 대한 사용자 지정 속성을 사용할 수 있다. 우리는 또한 translate x 값을 위해 사용자 지정 속성을 사용할 것이다. 사용자가 스크롤할 때 우리의 콜백 함수 안에서 이 값들을 설정할 겁니다.
header::after {
content: '';
position: absolute;
top: 0;
left: 0;
height: 0.4rem;
width: var(--markerWidth, 0);
background-color: currentColor;
transform: translate3d(var(--markerLeft, 0), 0, 0);
}
이제 교차점에서 마커의 폭과 위치를 업데이트하는 함수를 작성할 수 있다.
const updateMarker = (target) => {
const id = target.id
/* Do nothing if no target ID */
if (!id) return
/* Find the corresponding nav link, or use the first one */
let link = headerLinks.find((el) => {
return el.getAttribute('href') === `#${id}`
})
link = link || headerLinks[0]
/* Get the values and set the custom properties */
const distanceFromLeft = link.getBoundingClientRect().left
header.style.setProperty('--markerWidth', `${link.clientWidth}px`)
header.style.setProperty('--markerLeft', `${distanceFromLeft}px`)
}
색상을 업데이트하는 동시에 함수를 호출할 수 있다.
const onIntersect = (entries) => {
entries.forEach((entry) => {
setScrollDirection()
if (!shouldUpdate(entry)) return
const target = getTargetSection(entry.target)
updateColors(target)
updateMarker(target)
})
}
마커의 초기 위치도 정해야 할 테니, 갑자기 나타나는 것은 아니다. 문서가 로드되면, 우리는 첫 번째 섹션을 타겟으로 사용하는updateMarker() 함수를 호출할 것이다.
document.addEventListener('readystatechange', e => {
if (e.target.readyState === 'complete') {
updateMarker(sections[0])
}
})
마지막으로 마커가 한 링크에서 다음 링크로 머리글을 가로질러 미끄러지도록 CSS transition을 추가하자. width 속성을 전환하면서 우리는 브라우저 최적화를 수행할 수 있도록 will-change를 사용할 수 있다.
header::after {
transition: transform 250ms, width 200ms, background-color 200ms;
will-change: width;
}
부드러운 스크롤링
최종 터치에서는 사용자가 링크를 클릭할 때 페이지로 바로 내려가지 않고 페이지를 부드럽게 스크롤하면 좋을 것이다. 요즘은 이것을 JS 필요 없이 CSS에서 바로 할 수 있어! 보다 접근하기 쉬운 경험을 위해, 사용자가 시스템 설정에서 동작 감소에 대한 선호도를 지정하지 않은 경우에만 부드러운 스크롤을 구현하여 사용자의 움직임 선호도를 존중하는 것이 좋다.
@media (prefers-reduced-motion: no-preference) {
.scroller {
scroll-behavior: smooth;
}
}
최종 데모
위의 모든 단계를 종합하면 완전한 데모가 된다.
미셸 바커의 Pen Happy Face Ice Cream Parlour Cathrossion Observer의 예를 참조하십시오.
브라우저 지원
Intersection Observer는 현대 브라우저에서 널리 지지를 받고 있다. 필요한 경우 구형 브라우저를 위해 폴리 필링 할 수 있지만 나는 가능한 경우의 점진적인 개선 접근 방식을 선호한다. 헤더의 경우, 비지원 브라우저에 대해 간단하고 변하지 않는 버전을 제공하는 것은 사용자 경험에 크게 해가 되지 않을 것이다.
Intersection Observer가 지원되는지 여부를 감지하려면 다음을 사용하십시오.
if ('IntersectionObserver' in window && 'IntersectionObserverEntry' in window && 'intersectionRatio' in window.IntersectionObserverEntry.prototype) {
/* Code to execute if IO is supported */
} else {
/* Code to execute if not supported */
}
Resources
Intersection Observer에 대해 자세히 알아보기:
- MDN의 몇 가지 실제 사례를 포함한 광범위한 문서화
- Intersection Observer 시각화 도구
- Intersection Observer API를 사용한 타이밍 요소 가시성 - MDN의 또 다른 자습서 - 광고 가시성을 추적하기 위해 IO를 사용하는 방법을 살펴보는 MDN의 또 다른 자습서
- Denys Mischunov의 글에서는 lazy-loading assets를 포함한 IO에 대한 몇 가지 다른 용도에 대해 다루고 있다. 지금은 그게 덜 필요하긴 하지만, 아직 배울 것이 많다.
'JavaScript' 카테고리의 다른 글
An in-depth perspective on webpack's bundling process (한글) (2) (0) | 2022.07.13 |
---|---|
An in-depth perspective on webpack's bundling process (한글) (1) (0) | 2022.04.06 |
You Can Label a JavaScript `if` Statement (한글) (1) | 2022.01.26 |
Deep-copying in JavaScript using structuredClone (한글) (2) | 2022.01.12 |
Implementing Private Fields for JavaScript (한글) (0) | 2021.08.18 |