Membangun Header Dinamis Dengan Pengamat Persimpangan
Diterbitkan: 2022-03-10Intersection Observer API adalah API JavaScript yang memungkinkan kita untuk mengamati suatu elemen dan mendeteksi ketika elemen tersebut melewati titik tertentu dalam wadah bergulir — sering kali (tetapi tidak selalu) viewport — memicu fungsi panggilan balik.
Intersection Observer dapat dianggap lebih berperforma daripada mendengarkan peristiwa gulir di utas utama, karena tidak sinkron, dan panggilan balik hanya akan diaktifkan ketika elemen yang kami amati memenuhi ambang batas yang ditentukan, sebagai gantinya setiap kali posisi gulir diperbarui. Dalam artikel ini, kita akan membahas contoh bagaimana kita dapat menggunakan Intersection Observer untuk membangun komponen header tetap yang berubah ketika bersinggungan dengan bagian halaman web yang berbeda.
Penggunaan Dasar
Untuk menggunakan Intersection Observer, pertama-tama kita harus membuat pengamat baru, yang mengambil dua parameter: Objek dengan opsi pengamat, dan fungsi panggilan balik yang ingin kita jalankan setiap kali elemen yang kita amati (dikenal sebagai target pengamat) berpotongan dengan root (wadah bergulir, yang harus menjadi nenek moyang dari elemen target).
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)
Ketika kami telah membuat pengamat kami, kami kemudian perlu menginstruksikannya untuk menonton elemen target:
const targetEl = document.querySelector('[data-target]') observer.observe(targetEl)
Nilai opsi mana pun dapat dihilangkan, karena nilai tersebut akan kembali ke nilai defaultnya:
const options = { rootMargin: '0px', threshold: 1.0 }
Jika tidak ada root yang ditentukan, maka itu akan digolongkan sebagai viewport browser. Contoh kode di atas menunjukkan nilai default untuk rootMargin
dan threshold
. Ini bisa sulit untuk divisualisasikan, jadi perlu dijelaskan:
rootMargin
Nilai rootMargin
sedikit mirip dengan menambahkan margin CSS ke elemen root — dan, seperti halnya margin, dapat mengambil banyak nilai, termasuk nilai negatif. Elemen target akan dianggap berpotongan relatif terhadap margin.
Itu berarti bahwa suatu elemen secara teknis dapat diklasifikasikan sebagai "berpotongan" bahkan ketika tidak terlihat (jika akar gulir kami adalah viewport).
rootMargin
default ke 0px
, tetapi dapat mengambil string yang terdiri dari beberapa nilai, seperti menggunakan properti margin
di CSS.
threshold
threshold
dapat terdiri dari satu nilai atau larik nilai antara 0 dan 1. Ini mewakili proporsi elemen yang harus berada dalam batas akar agar dianggap berpotongan . Menggunakan nilai default 1, callback akan diaktifkan ketika 100% elemen target terlihat di dalam root.
Tidak selalu mudah untuk memvisualisasikan kapan suatu elemen akan diklasifikasikan sebagai terlihat menggunakan opsi ini. Saya telah membuat alat kecil untuk membantu memahami Intersection Observer.
Membuat Header
Sekarang setelah kita memahami prinsip-prinsip dasar, mari kita mulai membangun tajuk dinamis kita. Kita akan mulai dengan halaman web yang dibagi menjadi beberapa bagian. Gambar ini menunjukkan tata letak lengkap halaman yang akan kita buat:
Saya telah menyertakan demo di akhir artikel ini, jadi jangan ragu untuk langsung melakukannya jika Anda ingin membuka kodenya. (Ada juga repositori Github.)
Setiap bagian memiliki ketinggian minimum 100vh
(walaupun bisa lebih lama, tergantung kontennya). Header kami diperbaiki di bagian atas halaman dan tetap di tempatnya saat pengguna menggulir (menggunakan position: fixed
). Bagian memiliki latar belakang berwarna yang berbeda, dan saat bertemu dengan header, warna header berubah untuk melengkapi warna bagian. Ada juga penanda untuk menunjukkan bagian pengguna saat ini, yang meluncur saat bagian berikutnya tiba. Untuk memudahkan kita langsung ke kode yang relevan, saya telah menyiapkan demo minimal dengan titik awal kita (sebelum kita mulai menggunakan Intersection Observer API), jika Anda ingin mengikutinya.
Markup
Kita akan mulai dengan HTML untuk header kita. Ini akan menjadi header yang cukup sederhana dengan tautan beranda dan navigasi, tidak ada yang istimewa, tetapi kita akan menggunakan beberapa atribut data: data-header
untuk header itu sendiri (sehingga kita dapat menargetkan elemen dengan JS) , dan tiga tautan jangkar dengan atribut data-link
, yang akan menggulir pengguna ke bagian yang relevan saat diklik:
<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>
Selanjutnya, HTML untuk sisa halaman kita, yang dibagi menjadi beberapa bagian. Untuk singkatnya, saya hanya menyertakan bagian yang relevan dengan artikel, tetapi markup lengkap disertakan dalam demo. Setiap bagian menyertakan atribut data yang menentukan nama warna latar belakang, dan id
yang sesuai dengan salah satu tautan jangkar di header:
<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>
Kami akan memposisikan header kami dengan CSS sehingga akan tetap berada di bagian atas halaman saat pengguna menggulir:
header { position: fixed; width: 100%; }
Kami juga akan memberi bagian kami ketinggian minimum, dan memusatkan konten. (Kode ini tidak diperlukan agar Intersection Observer berfungsi, ini hanya untuk desain.)
section { padding: 5rem 0; min-height: 100vh; display: flex; justify-content: center; align-items: center; }
Peringatan iframe
Saat membangun demo Codepen ini, saya mengalami masalah yang membingungkan di mana kode Intersection Observer saya yang seharusnya bekerja dengan sempurna gagal menembakkan panggilan balik pada titik persimpangan yang benar tetapi malah menembak ketika elemen target berpotongan dengan tepi viewport. Setelah sedikit menggaruk-garuk kepala, saya menyadari bahwa ini karena di Codepen konten dimuat dalam iframe, yang diperlakukan secara berbeda. (Lihat bagian dokumen MDN tentang Clipping dan persegi panjang persimpangan untuk detail selengkapnya.)
Sebagai solusinya, dalam demo kita dapat membungkus markup kita di elemen lain, yang akan bertindak sebagai wadah pengguliran — akar dalam opsi IO — daripada viewport browser, seperti yang mungkin kita harapkan:
<div class="scroller" data-scroller> <header data-header> <!--Header content--> </header> <main> <!--Sections--> </main> </div>
Jika Anda ingin melihat cara menggunakan viewport sebagai root, bukan untuk demo yang sama, ini disertakan dalam repositori Github.
CSS
Di CSS kami, kami akan mendefinisikan beberapa properti khusus untuk warna yang kami gunakan. Kami juga akan menentukan dua properti kustom tambahan untuk teks header dan warna latar belakang, dan menetapkan beberapa nilai awal. (Kami akan memperbarui dua properti khusus ini untuk bagian yang berbeda nanti.)
:root { --mint: #5ae8d5; --chocolate: #573e31; --raspberry: #f2308e; --vanilla: #faf2c8; --headerText: var(--vanilla); --headerBg: var(--raspberry); }
Kami akan menggunakan properti khusus ini di header kami:
header { background-color: var(--headerBg); color: var(--headerText); }
Kami juga akan mengatur warna untuk bagian kami yang berbeda. Saya menggunakan atribut data sebagai penyeleksi, tetapi Anda bisa dengan mudah menggunakan kelas jika Anda mau.
[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); }
Kami juga dapat mengatur beberapa gaya untuk tajuk kami saat setiap bagian terlihat:
/* 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); }
Ada kasus yang lebih kuat untuk menggunakan atribut data di sini karena kita akan mengaktifkan atribut data-theme
dari header pada setiap persimpangan.
Membuat Pengamat
Sekarang setelah kami memiliki HTML dan CSS dasar untuk pengaturan halaman kami, kami dapat membuat pengamat untuk mengawasi setiap bagian kami yang muncul. Kami ingin mengaktifkan panggilan balik setiap kali bagian bersentuhan dengan bagian bawah header saat kami menggulir halaman ke bawah. Ini berarti kita perlu mengatur margin akar negatif yang sesuai dengan tinggi header.
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 }
Kami menetapkan ambang 0 , seperti yang kami inginkan untuk diaktifkan jika ada bagian dari bagian yang berpotongan dengan margin root.
Pertama-tama, kita akan membuat panggilan balik untuk mengubah nilai data-theme
dari header. (Ini lebih mudah daripada menambahkan dan menghapus kelas, terutama ketika elemen header kita mungkin memiliki kelas lain yang diterapkan.)
/* The callback that will fire on intersection */ const onIntersect = (entries) => { entries.forEach((entry) => { const theme = entry.target.dataset.section header.setAttribute('data-theme', theme) }) }
Kemudian kita akan membuat pengamat untuk melihat bagian yang berpotongan:
/* Create the observer */ const observer = new IntersectionObserver(onIntersect, options) /* Set our observer to observe each section */ sections.forEach((section) => { observer.observe(section) })
Sekarang kita akan melihat pembaruan warna header kita ketika setiap bagian memenuhi header.
Namun, Anda mungkin memperhatikan bahwa warna tidak diperbarui dengan benar saat kami menggulir ke bawah. Faktanya, tajuk diperbarui dengan warna bagian sebelumnya setiap kali! Menggulir ke atas, di sisi lain, itu bekerja dengan sempurna. Kita perlu menentukan arah gulir dan mengubah perilaku yang sesuai.
Menemukan Arah Gulir
Kami akan menetapkan variabel di JS kami untuk arah gulir, dengan nilai awal 'up'
, dan satu lagi untuk posisi gulir terakhir yang diketahui ( prevYPosition
). Kemudian, dalam callback, jika posisi scroll lebih besar dari nilai sebelumnya, kita dapat mengatur nilai direction
sebagai 'down'
, atau 'up'
jika sebaliknya.
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() /* ... */ }) }
Kami juga akan membuat fungsi baru untuk memperbarui warna header, meneruskan bagian target sebagai argumen:
const updateColors = (target) => { const theme = target.dataset.section header.setAttribute('data-theme', theme) } const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() updateColors(entry.target) }) }
Sejauh ini kita seharusnya tidak melihat perubahan pada perilaku header kita. Tetapi sekarang setelah kita mengetahui arah gulir, kita dapat meneruskan target yang berbeda untuk fungsi updateColors()
kita. Jika arah gulir ke atas, kami akan menggunakan target entri. Jika down, kita akan menggunakan bagian selanjutnya (jika ada).
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) }) }
Namun, ada satu masalah lagi: tajuk akan diperbarui tidak hanya ketika bagian mengenai tajuk, tetapi ketika elemen berikutnya muncul di bagian bawah viewport. Ini karena pengamat kita mengaktifkan callback dua kali: sekali saat elemen masuk, dan lagi saat elemen keluar.
Untuk menentukan apakah header harus diperbarui, kita dapat menggunakan kunci isIntersecting
dari objek entry
. Mari buat fungsi lain untuk mengembalikan nilai boolean apakah warna header harus diperbarui:
const shouldUpdate = (entry) => { if (direction === 'down' && !entry.isIntersecting) { return true } if (direction === 'up' && entry.isIntersecting) { return true } return false }
Kami akan memperbarui fungsi onIntersect()
kami sesuai dengan itu:
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) }) }
Sekarang warna kita harus diperbarui dengan benar. Kita dapat mengatur transisi CSS, sehingga efeknya sedikit lebih bagus:
header { transition: background-color 200ms, color 200ms; }
Menambahkan Penanda Dinamis
Selanjutnya kita akan menambahkan penanda ke header yang memperbarui posisinya saat kita menggulir ke bagian yang berbeda. Kita bisa menggunakan elemen semu untuk ini, jadi kita tidak perlu menambahkan apapun ke HTML kita. Kami akan memberikan beberapa gaya CSS sederhana untuk memposisikannya di kiri atas header, dan memberinya warna latar belakang. Kami menggunakan currentColor
untuk ini, karena akan mengambil nilai dari warna teks header:
header::after { content: ''; position: absolute; top: 0; left: 0; height: 0.4rem; background-color: currentColor; }
Kita dapat menggunakan properti kustom untuk lebarnya, dengan nilai default 0. Kita juga akan menggunakan properti kustom untuk nilai terjemahan x. Kami akan menetapkan nilai untuk ini dalam fungsi panggilan balik kami saat pengguna menggulir.
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); }
Sekarang kita dapat menulis fungsi yang akan memperbarui lebar dan posisi penanda pada titik perpotongan:
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`) }
Kami dapat memanggil fungsi pada saat yang sama kami memperbarui warna:
const onIntersect = (entries) => { entries.forEach((entry) => { setScrollDirection() if (!shouldUpdate(entry)) return const target = getTargetSection(entry.target) updateColors(target) updateMarker(target) }) }
Kita juga perlu mengatur posisi awal untuk penanda, jadi itu tidak muncul begitu saja. Saat dokumen dimuat, kami akan memanggil fungsi updateMarker()
, menggunakan bagian pertama sebagai target:
document.addEventListener('readystatechange', e => { if (e.target.readyState === 'complete') { updateMarker(sections[0]) } })
Terakhir, mari tambahkan transisi CSS sehingga penanda meluncur melintasi header dari satu tautan ke tautan berikutnya. Saat kita mentransisikan properti width
, kita dapat menggunakan will-change
untuk memungkinkan browser melakukan pengoptimalan.
header::after { transition: transform 250ms, width 200ms, background-color 200ms; will-change: width; }
Pengguliran Halus
Untuk sentuhan terakhir, alangkah baiknya jika, ketika pengguna mengklik tautan, mereka menggulir ke bawah halaman dengan mulus, alih-alih melompat ke bagian. Saat ini kami dapat melakukannya dengan benar di CSS kami, tidak perlu JS! Untuk pengalaman yang lebih mudah diakses, ada baiknya untuk menghormati preferensi gerakan pengguna dengan hanya menerapkan pengguliran halus jika mereka belum menentukan preferensi untuk pengurangan gerakan di pengaturan sistem mereka:
@media (prefers-reduced-motion: no-preference) { .scroller { scroll-behavior: smooth; } }
Demo Terakhir
Menempatkan semua langkah di atas bersama-sama menghasilkan demo lengkap.
Dukungan Peramban
Intersection Observer didukung secara luas di browser modern. Jika perlu, ini dapat di-polyfill untuk browser lama — tetapi saya lebih suka mengambil pendekatan peningkatan progresif jika memungkinkan. Dalam kasus tajuk kami, tidak akan sangat merugikan pengalaman pengguna untuk menyediakan versi sederhana yang tidak berubah untuk browser yang tidak mendukung.
Untuk mendeteksi apakah Intersection Observer didukung, kita dapat menggunakan yang berikut ini:
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 */ }
Sumber daya
Baca lebih lanjut tentang Pengamat Persimpangan:
- Dokumentasi ekstensif, dengan beberapa contoh praktis dari MDN
- Alat visualisator Pengamat Persimpangan
- Visibilitas Elemen Pengaturan Waktu dengan Intersection Observer API – tutorial lain dari MDN, yang membahas bagaimana IO dapat digunakan untuk melacak visibilitas iklan
- Artikel oleh Denys Mishunov ini membahas beberapa kegunaan lain untuk IO, termasuk aset pemuatan lambat. Meskipun itu tidak terlalu diperlukan sekarang (berkat atribut
loading
), masih banyak yang harus dipelajari di sini.