Intersection Observer for Element Visibility Tracking

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_website-b2b-advance_0.png
    B2B ADVANCE company website development
    1215
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    852
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1043
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    823
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Website development for FIXPER company
    815

Implementing Intersection Observer for Element Visibility Tracking on a Website

Intersection Observer — a browser API for asynchronously observing whether an element enters the viewport or another container. Works in a separate thread, doesn't block the main thread, doesn't cause layout thrashing — unlike the old approach with getBoundingClientRect() in a scroll handler.

Applications: lazy loading images, animations on appearance, infinite scroll, tracking read content, ad impressions.

Basic Setup

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      // entry.isIntersecting — whether element is visible
      // entry.intersectionRatio — fraction of visible part (0 to 1)
      // entry.boundingClientRect — element size and position
      // entry.time — timestamp
    })
  },
  {
    root: null,          // null = viewport
    rootMargin: '0px',   // margins (like CSS margin)
    threshold: 0.1,      // trigger at 10% visibility
    // threshold: [0, 0.25, 0.5, 0.75, 1] — multiple thresholds
  }
)

observer.observe(element)
observer.unobserve(element)
observer.disconnect() // stop all observations

Animations on Appearance

Pattern without extra libraries:

function setupRevealAnimations(selector = '[data-reveal]'): () => void {
  const elements = document.querySelectorAll<HTMLElement>(selector)
  if (!elements.length) return () => {}

  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const el = entry.target as HTMLElement
          el.classList.add('is-revealed')
          observer.unobserve(el)
        }
      })
    },
    { threshold: 0.15, rootMargin: '0px 0px -50px 0px' }
  )

  elements.forEach((el) => observer.observe(el))
  return () => observer.disconnect()
}

CSS:

[data-reveal] {
  opacity: 0;
  transform: translateY(24px);
  transition: opacity 500ms ease, transform 500ms ease;
}

[data-reveal].is-revealed {
  opacity: 1;
  transform: translateY(0);
}

@media (prefers-reduced-motion: reduce) {
  [data-reveal] { opacity: 1; transform: none; transition: none; }
}

Lazy Loading Images

function lazyLoadImages(selector = 'img[data-src]'): void {
  const images = document.querySelectorAll<HTMLImageElement>(selector)

  // Native lazy loading as primary method
  if ('loading' in HTMLImageElement.prototype) {
    images.forEach((img) => {
      img.src = img.dataset.src!
      img.removeAttribute('data-src')
    })
    return
  }

  // Intersection Observer as fallback
  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)
      })
    },
    { rootMargin: '200px 0px' } // load 200px before appearance
  )

  images.forEach((img) => observer.observe(img))
}

Infinite Scroll

function createInfiniteScroll(
  sentinel: HTMLElement,
  onLoadMore: () => Promise<boolean> // returns false when data ends
): () => void {
  let loading = false

  const observer = new IntersectionObserver(
    async (entries) => {
      const entry = entries[0]
      if (!entry.isIntersecting || loading) return

      loading = true
      const hasMore = await onLoadMore()
      loading = false

      if (!hasMore) observer.disconnect()
    },
    { rootMargin: '400px 0px' }
  )

  observer.observe(sentinel)
  return () => observer.disconnect()
}

Tracking Read Depth (Analytics)

function trackReadDepth(
  article: HTMLElement,
  onMilestone: (percent: number) => void
): () => void {
  const thresholds = [0.25, 0.5, 0.75, 1.0]
  const reported = new Set<number>()

  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        const ratio = entry.intersectionRatio
        for (const t of thresholds) {
          if (ratio >= t && !reported.has(t)) {
            reported.add(t)
            onMilestone(t * 100)
          }
        }
      })
    },
    { threshold: thresholds }
  )

  observer.observe(article)
  return () => observer.disconnect()
}

// Usage:
trackReadDepth(articleEl, (percent) => {
  analytics.track('article_read_depth', { percent })
})

React Hook

function useIntersectionObserver(
  options: IntersectionObserverInit = {}
): [RefObject<HTMLElement | null>, boolean, IntersectionObserverEntry | null] {
  const ref = useRef<HTMLElement>(null)
  const [isVisible, setIsVisible] = useState(false)
  const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null)

  useEffect(() => {
    const el = ref.current
    if (!el) return

    const observer = new IntersectionObserver(
      ([entry]) => {
        setIsVisible(entry.isIntersecting)
        setEntry(entry)
      },
      options
    )

    observer.observe(el)
    return () => observer.disconnect()
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [options.threshold, options.rootMargin])

  return [ref, isVisible, entry]
}

// Usage:
function AnimatedCard() {
  const [ref, isVisible] = useIntersectionObserver({ threshold: 0.2 })

  return (
    <div
      ref={ref as React.RefObject<HTMLDivElement>}
      className={isVisible ? 'card card--visible' : 'card'}
    >
      ...
    </div>
  )
}

What's Included

Setting up observers for needed scenarios — animations, lazy loading, infinite scroll, read analytics. React hooks, proper cleanup of observers on unmounting, prefers-reduced-motion for animations.

Timeline: 0.5 day for basic scenarios, 1 day with multiple patterns.