Создание динамического заголовка с помощью Intersection Observer

Опубликовано: 2022-03-10
Краткое резюме ↬ Вам когда-нибудь приходилось создавать пользовательский интерфейс, в котором какой-либо компонент на странице должен реагировать на элементы, когда они прокручиваются до определенного порога в области просмотра — или, возможно, в самой области просмотра и за ее пределами? В JavaScript присоединение прослушивателя событий для постоянного запуска обратного вызова при прокрутке может сильно увеличить производительность и при неразумном использовании может привести к замедлению работы пользователя. Но есть лучший способ с Intersection Observer.

Intersection Observer API — это API JavaScript, который позволяет нам наблюдать за элементом и обнаруживать, когда он проходит указанную точку в контейнере прокрутки — часто (но не всегда) области просмотра — запуская функцию обратного вызова.

Intersection Observer можно считать более производительным, чем прослушивание событий прокрутки в основном потоке, поскольку он асинхронный, а обратный вызов срабатывает только тогда, когда наблюдаемый нами элемент достигает указанного порога, а не каждый раз, когда обновляется позиция прокрутки. В этой статье мы рассмотрим пример того, как мы можем использовать Intersection Observer для создания фиксированного компонента заголовка, который изменяется при пересечении с различными разделами веб-страницы.

Основное использование

Чтобы использовать Intersection Observer, нам нужно сначала создать новый наблюдатель, который принимает два параметра: объект с параметрами наблюдателя и функцию обратного вызова, которую мы хотим выполнять всякий раз, когда элемент, за которым мы наблюдаем (известный как цель наблюдателя), пересекается. с корнем (контейнер прокрутки, который должен быть предком целевого элемента).

 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 к корневому элементу — и, как и поля, может принимать несколько значений, включая отрицательные значения. Целевой элемент будет считаться пересекающимся относительно полей.

Корень прокрутки с положительными и отрицательными значениями корневого поля. Оранжевый квадрат расположен в точке, где он будет классифицирован как «пересекающийся», при условии, что пороговое значение по умолчанию равно 1. (Большой предварительный просмотр)

Это означает, что технически элемент может быть классифицирован как «пересекающийся», даже если он находится вне поля зрения (если наш корень прокрутки является областью просмотра).

Оранжевый квадрат пересекается с корнем, хотя и находится за пределами видимой области. (Большой превью)

rootMargin по умолчанию равен 0px , но может принимать строку, состоящую из нескольких значений, точно так же, как использование свойства margin в CSS.

threshold

threshold может состоять из одного значения или массива значений от 0 до 1. Он представляет долю элемента, который должен находиться в пределах корневых границ, чтобы он считался пересекающимся . Используя значение по умолчанию 1, обратный вызов сработает, когда 100% целевого элемента будет видно в корне.

Пороги 1, 0 и 0,5 соответственно приводят к срабатыванию обратного вызова, когда видны 100%, 0% и 50%. (Большой превью)

С помощью этих опций не всегда легко визуализировать, когда элемент будет классифицирован как видимый. Я создал небольшой инструмент, который поможет разобраться с Intersection Observer.

Еще после прыжка! Продолжить чтение ниже ↓

Создание заголовка

Теперь, когда мы поняли основные принципы, давайте начнем создавать наш динамический заголовок. Мы начнем с веб-страницы, разделенной на разделы. На этом изображении показан полный макет страницы, которую мы будем создавать:

(Большой превью)

Я включил демонстрацию в конец этой статьи, так что не стесняйтесь сразу переходить к ней, если хотите разобраться в коде. (Есть также репозиторий Github.)

Каждый раздел имеет минимальную высоту 100vh (хотя они могут быть и больше, в зависимости от контента). Наш заголовок фиксируется в верхней части страницы и остается на месте, пока пользователь прокручивает страницу (используя position: fixed ). Разделы имеют фон разного цвета, и когда они встречаются с заголовком, цвета заголовка меняются, чтобы дополнить цвета раздела. Существует также маркер, показывающий текущий раздел, в котором находится пользователь, который перемещается, когда появляется следующий раздел. Чтобы нам было проще перейти непосредственно к соответствующему коду, я создал минимальную демонстрацию с нашей отправной точкой (до того, как мы начнем использовать API Intersection Observer), на случай, если вы захотите продолжить.

Разметка

Мы начнем с HTML для нашего заголовка. Это будет довольно простой заголовок с домашней ссылкой и навигацией, ничего особенного, но мы собираемся использовать пару атрибутов данных: data-header для самого заголовка (чтобы мы могли настроить таргетинг на элемент с помощью JS). и три якорные ссылки с атрибутом data-link , которые будут прокручивать пользователя до соответствующего раздела при нажатии:

 <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"> <!--Section content--> </section> <section data-section="mint"> <!--Section content--> </section> <section data-section="vanilla"> <!--Section content--> </section> <section data-section="chocolate"> <!--Section content--> </section> </main>

Мы поместим наш заголовок с помощью CSS так, чтобы он оставался фиксированным в верхней части страницы при прокрутке пользователем:

 header { position: fixed; width: 100%; }

Мы также зададим нашим разделам минимальную высоту и центрируем содержимое. (Этот код не нужен для работы Intersection Observer, он нужен только для дизайна.)

 section { padding: 5rem 0; min-height: 100vh; display: flex; justify-content: center; align-items: center; }

iframe Предупреждение

При создании этой демо-версии Codepen я столкнулся с запутанной проблемой, когда мой код Intersection Observer, который должен был работать идеально, не запускал обратный вызов в правильной точке пересечения, а вместо этого срабатывал, когда целевой элемент пересекался с краем окна просмотра. Немного поразмыслив, я понял, что это из-за того, что в Codepen контент загружается в iframe, который обрабатывается по-другому. (Подробнее см. в разделе документов MDN об отсечении и прямоугольнике пересечения.)

В качестве обходного пути в демонстрации мы можем обернуть нашу разметку в другой элемент, который будет действовать как контейнер прокрутки — корень в наших параметрах ввода-вывода — а не в окне просмотра браузера, как мы могли бы ожидать:

 <div class="scroller" data-scroller> <header data-header> <!--Header content--> </header> <main> <!--Sections--> </main> </div>

Если вы хотите увидеть, как использовать окно просмотра в качестве корня вместо той же демонстрации, это включено в репозиторий Github.

CSS

В нашем CSS мы определим некоторые пользовательские свойства для цветов, которые мы используем. Мы также определим два дополнительных настраиваемых свойства для текста заголовка и цвета фона и установим некоторые начальные значения. (Позже мы собираемся обновить эти два настраиваемых свойства для разных разделов.)

 :root { --mint: #5ae8d5; --chocolate: #573e31; --raspberry: #f2308e; --vanilla: #faf2c8; --headerText: var(--vanilla); --headerBg: var(--raspberry); }

Мы будем использовать эти пользовательские свойства в нашем заголовке:

 header { background-color: var(--headerBg); color: var(--headerText); }

Мы также установим цвета для различных разделов. Я использую атрибуты данных в качестве селекторов, но вы можете так же легко использовать класс, если хотите.

 [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); }

Здесь более веский аргумент в пользу использования атрибутов данных, потому что мы собираемся переключать атрибут 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 }

Мы устанавливаем порог 0 , так как мы хотим, чтобы он срабатывал, если какая- либо часть раздела пересекается с корневым полем.

Прежде всего, мы создадим обратный вызов для изменения значения data-theme данных заголовка. (Это более просто, чем добавление и удаление классов, особенно когда к нашему элементу заголовка могут применяться другие классы.)

 /* The callback that will fire on intersection */ const onIntersect = (entries) => { entries.forEach((entry) => { const theme = entry.target.dataset.section header.setAttribute('data-theme', theme) }) }

Затем мы создадим наблюдателя для наблюдения за пересекающимися секциями:

 /* Create the observer */ const observer = new IntersectionObserver(onIntersect, options) /* Set our observer to observe each section */ sections.forEach((section) => { observer.observe(section) })

Теперь мы должны увидеть, как цвета нашего заголовка обновляются, когда каждый раздел встречается с заголовком.

См. Pen [Кафе-мороженое Happy Face — Шаг 2] (https://codepen.io/smashingmag/pen/poPgpjZ) Мишель Баркер.

См. Кафе-мороженое Pen Happy Face – Step 2 от Мишель Баркер.

Однако вы можете заметить, что цвета не обновляются правильно при прокрутке вниз. Фактически, заголовок каждый раз обновляется цветами предыдущего раздела! С другой стороны, прокрутка вверх работает отлично. Нам нужно определить направление прокрутки и соответствующим образом изменить поведение.

Поиск направления прокрутки

Мы установим переменную в нашем JS для направления прокрутки с начальным значением 'up' и другую для последней известной позиции прокрутки ( prevYPosition ). Затем в обратном вызове, если позиция прокрутки больше, чем предыдущее значение, мы можем установить значение direction как 'down' или 'up' если наоборот.

 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) }) }

Однако есть еще одна проблема: заголовок будет обновляться не только тогда, когда раздел достигает заголовка, но и когда следующий элемент появляется в нижней части окна просмотра. Это связано с тем, что наш наблюдатель запускает обратный вызов дважды: один раз, когда элемент входит, и еще раз, когда он уходит.

Чтобы определить, должен ли заголовок обновляться, мы можем использовать ключ isIntersecting из объекта entry . Давайте создадим еще одну функцию, которая будет возвращать логическое значение, указывающее, должны ли обновляться цвета заголовков:

 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, чтобы эффект был немного лучше:

 header { transition: background-color 200ms, color 200ms; } 

См. Pen [Кафе-мороженое Happy Face — Шаг 3] (https://codepen.io/smashingmag/pen/bGWEaEa) Мишель Баркер.

См. Кафе-мороженое Pen Happy Face — Step 3 от Мишель Баркер.

Добавление динамического маркера

Далее мы добавим в заголовок маркер, который обновляет свою позицию по мере прокрутки к различным разделам. Мы можем использовать для этого псевдоэлемент, поэтому нам не нужно ничего добавлять в наш HTML. Мы добавим ему простой CSS-стиль, чтобы расположить его в верхнем левом углу заголовка, и зададим ему цвет фона. Мы используем для этого currentColor , так как он примет значение цвета текста заголовка:

 header::after { content: ''; position: absolute; top: 0; left: 0; height: 0.4rem; background-color: currentColor; }

Мы можем использовать пользовательское свойство для ширины со значением по умолчанию 0. Мы также будем использовать пользовательское свойство для значения перевода 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, чтобы маркер скользил по заголовку от одной ссылки к другой. Поскольку мы изменяем свойство width , мы можем использовать will-change , чтобы позволить браузеру выполнять оптимизацию.

 header::after { transition: transform 250ms, width 200ms, background-color 200ms; will-change: width; }

Плавная прокрутка

В качестве последнего штриха было бы неплохо, если бы, когда пользователь щелкает ссылку, он плавно прокручивал страницу вниз, а не переходил к разделу. В наши дни мы можем сделать это прямо в нашем CSS, без JS! Для более доступного опыта рекомендуется уважать предпочтения пользователя в движении, реализуя плавную прокрутку только в том случае, если они не указали предпочтение для уменьшения движения в своих системных настройках:

 @media (prefers-reduced-motion: no-preference) { .scroller { scroll-behavior: smooth; } }

Финальная демонстрация

Объединение всех вышеперечисленных шагов приводит к полной демонстрации.

См. Pen [Кафе-мороженое Happy Face — пример Intersection Observer] (https://codepen.io/smashingmag/pen/XWRXVXQ) Мишель Баркер.

См. пример кафе-мороженого Pen Happy Face — Intersection 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 */ }

Ресурсы

Подробнее о Intersection Observer:

  • Обширная документация с некоторыми практическими примерами из MDN.
  • Инструмент визуализации Intersection Observer
  • Отслеживание видимости элемента синхронизации с помощью API-интерфейса Intersection Observer — еще одно руководство от MDN, в котором рассматривается, как можно использовать ввод-вывод для отслеживания видимости рекламы.
  • В этой статье Дениса Мишунова рассматриваются некоторые другие варианты использования IO, в том числе ресурсы с отложенной загрузкой. Хотя сейчас это менее необходимо (благодаря атрибуту loading ), здесь еще есть чему поучиться.