ข้ามไปเนื้อหาหลัก

Category: guide

Intersection Observer API — Lazy Load, Scroll Animations, Infinite Scroll

ใช้ IntersectionObserver ตรวจว่า element เข้า/ออก viewport โดยไม่ต้องใช้ scroll event

· อ่านประมาณ 4 นาที

สารบัญ

ทำไมไม่ใช้ 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!;
  });
}