Category: guide
Intersection Observer API — Lazy Load, Scroll Animations, Infinite Scroll
ใช้ IntersectionObserver ตรวจว่า element เข้า/ออก viewport โดยไม่ต้องใช้ scroll event
สารบัญ
ทำไมไม่ใช้ scroll event
// ❌ วิธีเก่า — fires ทุก pixel ที่ scroll
window.addEventListener('scroll', () => {
const el = document.querySelector('.lazy-img');
const rect = el.getBoundingClientRect();
if (rect.top < window.innerHeight) {
// load image
}
});
ปัญหา:
- getBoundingClientRect() force layout reflow — ช้ามากถ้าเรียกบ่อย
- scroll fires ทุก frame — ต้องใช้ throttle/debounce
- main thread blocked — jank ในการ scroll
IntersectionObserver รันใน separate thread และ callback เฉพาะเมื่อ intersection state เปลี่ยน
พื้นฐาน
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
console.log(`${entry.target.id} เข้า viewport`);
}
});
}, {
threshold: 0, // 0 = เพิ่งเริ่มเห็น (default), 1 = เห็นทั้งหมด
rootMargin: '0px', // ขยาย/ย่อ boundary ก่อน trigger
root: null, // null = viewport, หรือ element ใดก็ได้
});
const target = document.querySelector('.my-element');
observer.observe(target);
// หยุด observe
observer.unobserve(target);
// หยุดทั้งหมด
observer.disconnect();
Lazy Loading Images
function lazyLoadImages() {
const images = document.querySelectorAll('img[data-src]');
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target as HTMLImageElement;
img.src = img.dataset.src!;
img.removeAttribute('data-src');
observer.unobserve(img); // เสร็จแล้วหยุด observe
});
}, {
rootMargin: '200px', // โหลดก่อนเข้า viewport 200px
});
images.forEach((img) => observer.observe(img));
}
document.addEventListener('DOMContentLoaded', lazyLoadImages);
<!-- HTML: ใส่ placeholder ไว้ก่อน -->
<img
data-src="/real-image.jpg"
src="/placeholder-blur.jpg"
width="800"
height="600"
alt="Product photo"
loading="lazy"
/>
หมายเหตุ: browsers รุ่นใหม่มี loading="lazy" built-in — ใช้ IntersectionObserver เมื่อต้องการ custom logic เท่านั้น
Scroll Animations
function initScrollAnimations() {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
entry.target.classList.toggle('is-visible', entry.isIntersecting);
});
}, {
threshold: 0.1, // เมื่อเห็น 10%
rootMargin: '0px 0px -50px 0px', // offset ด้านล่าง 50px
});
document.querySelectorAll('[data-animate]').forEach((el) => {
observer.observe(el);
});
}
[data-animate] {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.5s ease, transform 0.5s ease;
}
[data-animate].is-visible {
opacity: 1;
transform: none;
}
/* Stagger ด้วย CSS */
[data-animate]:nth-child(1) { transition-delay: 0ms; }
[data-animate]:nth-child(2) { transition-delay: 80ms; }
[data-animate]:nth-child(3) { transition-delay: 160ms; }
@media (prefers-reduced-motion: reduce) {
[data-animate],
[data-animate].is-visible {
opacity: 1;
transform: none;
transition: none;
}
}
Infinite Scroll
function setupInfiniteScroll(container, loadMore) {
const sentinel = document.createElement('div');
sentinel.setAttribute('aria-hidden', 'true');
container.appendChild(sentinel);
let loading = false;
const observer = new IntersectionObserver(async (entries) => {
if (!entries[0].isIntersecting || loading) return;
loading = true;
try {
const hasMore = await loadMore();
if (!hasMore) observer.disconnect();
} finally {
loading = false;
}
}, {
rootMargin: '400px', // เริ่มโหลดก่อนถึง sentinel 400px
});
observer.observe(sentinel);
return () => observer.disconnect();
}
// ใช้งาน
const cleanup = setupInfiniteScroll(
document.querySelector('.posts'),
async () => {
const posts = await fetchNextPage();
if (posts.length === 0) return false;
renderPosts(posts);
return true;
}
);
// cleanup เมื่อ component unmount
window.addEventListener('beforeunload', cleanup);
Active Section Highlight (Table of Contents)
function initToCHighlight(headings, tocLinks) {
let activeId = null;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
activeId = entry.target.id;
}
});
tocLinks.forEach((link) => {
link.classList.toggle(
'is-active',
link.getAttribute('href') === `#${activeId}`
);
});
}, {
rootMargin: '-20% 0px -70% 0px', // highlight section ที่อยู่ใน 20-30% ของ viewport
threshold: 0,
});
headings.forEach((h) => observer.observe(h));
return () => observer.disconnect();
}
threshold Array
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// entry.intersectionRatio = 0.0 ถึง 1.0
const opacity = entry.intersectionRatio;
entry.target.style.opacity = String(opacity);
});
}, {
threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
// callback fires ทุกครั้งที่ visibility ข้าม threshold นี้
});
Custom Root (Scroll Container)
const scrollContainer = document.querySelector('.scroll-area');
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
console.log(entry.isIntersecting); // visible ใน scrollContainer
});
}, {
root: scrollContainer, // observe ภายใน element นี้ ไม่ใช่ viewport
threshold: 0.5,
});
Entry Properties
interface IntersectionObserverEntry {
isIntersecting: boolean; // true ถ้า element อยู่ใน viewport
intersectionRatio: number; // 0.0–1.0 ส่วนที่มองเห็น
boundingClientRect: DOMRect; // ตำแหน่งของ target
intersectionRect: DOMRect; // ส่วนที่ overlap กับ root
rootBounds: DOMRect | null; // ขนาดของ root
target: Element; // element ที่ observe
time: number; // timestamp ที่ intersection เปลี่ยน
}
Browser Support
รองรับทุก browser ตั้งแต่ปี 2019+ (Chrome 58+, Firefox 55+, Safari 12.1+) สำหรับ rootMargin แบบ percentage ต้องใช้ Safari 12.1+
// Feature detection
if ('IntersectionObserver' in window) {
// ใช้ IntersectionObserver
} else {
// Fallback: โหลดทันที
document.querySelectorAll('[data-src]').forEach((img) => {
(img as HTMLImageElement).src = (img as HTMLImageElement).dataset.src!;
});
}