Впровадження Intersection Observer для відстеження видимості елементів на веб-сайті
Intersection Observer — браузерний API для асинхронного спостереження за тим, входить ли елемент в область видимості viewport або іншого контейнера. Працює в окремому потоці, не блокує основний потік, не викликає layout thrashing — на відміну від старого підходу з getBoundingClientRect() в обробнику scroll.
Застосування: ледачое завантаження зображень, анімації при появленні, нескінченна прокрутка, відстеження прочитаного контенту, рекламні покази.
Базова настройка
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// entry.isIntersecting — видно ли елемент
// entry.intersectionRatio — частка видимої частини (0 до 1)
// entry.boundingClientRect — розміри і положення елемента
// entry.time — часова мітка
})
},
{
root: null, // null = viewport
rootMargin: '0px', // відступи (як CSS margin)
threshold: 0.1, // спрацювати при 10% видимості
// threshold: [0, 0.25, 0.5, 0.75, 1] — кілька порогів
}
)
observer.observe(element)
observer.unobserve(element)
observer.disconnect() // зупинити всі спостереження
Анімації при появленні
Паттерн без лишних бібліотек:
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; }
}
Ледачое завантаження зображень
function lazyLoadImages(selector = 'img[data-src]'): void {
const images = document.querySelectorAll<HTMLImageElement>(selector)
// Нативне ледачое завантаження як основний метод
if ('loading' in HTMLImageElement.prototype) {
images.forEach((img) => {
img.src = img.dataset.src!
img.removeAttribute('data-src')
})
return
}
// Intersection Observer як 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' } // завантажувати за 200px до появи
)
images.forEach((img) => observer.observe(img))
}
Нескінченна прокрутка
function createInfiniteScroll(
sentinel: HTMLElement,
onLoadMore: () => Promise<boolean> // повертає false, коли дані закінчилися
): () => 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()
}
Відстеження глибини прочитання (аналітика)
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()
}
// Використання:
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]
}
// Використання:
function AnimatedCard() {
const [ref, isVisible] = useIntersectionObserver({ threshold: 0.2 })
return (
<div
ref={ref as React.RefObject<HTMLDivElement>}
className={isVisible ? 'card card--visible' : 'card'}
>
...
</div>
)
}
Що включено
Настройка спостерігачів для потрібних сценаріїв — анімацій, ледачого завантаження, нескінченного скролу, аналітики прочитання. React хуки, правильна очистка спостерігачів при розмонтуванні, prefers-reduced-motion для анімацій.
Терміни: 0,5 дня на базові сценарії, 1 день при кількох паттернах.







