IntersectionObserver
IntersectionObserver — браузерный API для асинхронного отслеживания пересечения элемента с viewport (или другим элементом). Не блокирует главный поток и заменяет дорогие вычисления на scroll.
Зачем нужно
Раньше для определения видимости элемента слушали scroll и вычисляли getBoundingClientRect — это дорого и блокирует UI. IntersectionObserver делает то же самое эффективно и декларативно.
Где используется
- Lazy loading изображений
- Бесконечный скролл (infinite scroll)
- Анимации при скролле (appear on scroll)
- Фиксация заголовков (sticky detection)
- Аналитика (отслеживание просмотров секций)
- Подгрузка рекламы
Предпосылки
Базовое использование
// Создание observer
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
console.log(entry.target); // наблюдаемый элемент
console.log(entry.isIntersecting); // видим ли (boolean)
console.log(entry.intersectionRatio); // доля видимости (0..1)
console.log(entry.boundingClientRect); // размеры элемента
console.log(entry.intersectionRect); // видимая часть
console.log(entry.rootBounds); // размеры root
});
});
// Начинаем наблюдение
const el = document.querySelector('.target');
observer.observe(el);
// Можно наблюдать несколько элементов одним observer
document.querySelectorAll('.card').forEach(card => {
observer.observe(card);
});
// Прекращение наблюдения
observer.unobserve(el); // конкретный элемент
observer.disconnect(); // все элементы
Опции
const observer = new IntersectionObserver(callback, {
// root — корневой элемент (null = viewport)
root: null, // или document.querySelector('.scroll-container')
// rootMargin — отступы от root (как CSS margin)
rootMargin: '0px', // по умолчанию
rootMargin: '100px', // триггер на 100px раньше (для preload)
rootMargin: '-50px', // триггер когда элемент на 50px внутри
rootMargin: '100px 0px', // верх/низ и лево/право
// threshold — при какой доле видимости срабатывать
threshold: 0, // как только 1px видим (по умолчанию)
threshold: 0.5, // когда 50% видимо
threshold: 1.0, // когда 100% видимо
threshold: [0, 0.25, 0.5, 0.75, 1], // при каждом пороге
});
Lazy loading изображений
// HTML: <img data-src="photo.jpg" alt="Фото" class="lazy">
const lazyObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
img.classList.add('loaded');
observer.unobserve(img); // больше не наблюдаем
});
}, {
rootMargin: '200px' // начинаем загрузку за 200px до видимости
});
document.querySelectorAll('img.lazy').forEach(img => {
lazyObserver.observe(img);
});
// CSS:
// .lazy { opacity: 0; transition: opacity 0.3s; }
// .loaded { opacity: 1; }
Бесконечный скролл
// HTML: <div id="list">...</div><div id="sentinel"></div>
let page = 1;
let loading = false;
const sentinel = document.querySelector('#sentinel');
const list = document.querySelector('#list');
const scrollObserver = new IntersectionObserver(async (entries) => {
if (!entries[0].isIntersecting || loading) return;
loading = true;
const data = await fetchData(++page);
data.forEach(item => {
const el = document.createElement('div');
el.className = 'item';
el.textContent = item.title;
list.appendChild(el);
});
loading = false;
// Если данных больше нет — отключаем
if (data.length === 0) {
scrollObserver.disconnect();
sentinel.textContent = 'Больше нет данных';
}
}, {
rootMargin: '300px' // подгружаем заранее
});
scrollObserver.observe(sentinel);
Анимация при скролле (Appear on scroll)
const appearObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
// Если анимация одноразовая:
appearObserver.unobserve(entry.target);
}
});
}, {
threshold: 0.1 // 10% элемента видимо
});
document.querySelectorAll('.animate-on-scroll').forEach(el => {
appearObserver.observe(el);
});
// CSS:
// .animate-on-scroll { opacity: 0; transform: translateY(30px); transition: all 0.6s; }
// .animate-on-scroll.visible { opacity: 1; transform: translateY(0); }
Sticky detection
// Определяем, когда header «прилипает»
const header = document.querySelector('.sticky-header');
// Создаём sentinel-элемент ПЕРЕД header
const stickySentinel = document.createElement('div');
stickySentinel.style.height = '1px';
header.parentElement.insertBefore(stickySentinel, header);
const stickyObserver = new IntersectionObserver(([entry]) => {
header.classList.toggle('stuck', !entry.isIntersecting);
}, {
threshold: 0
});
stickyObserver.observe(stickySentinel);
// CSS:
// .sticky-header { position: sticky; top: 0; }
// .sticky-header.stuck { box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
Отслеживание просмотров (аналитика)
const viewTracker = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
const id = entry.target.dataset.sectionId;
analytics.track('section_viewed', { section: id });
viewTracker.unobserve(entry.target); // считаем один раз
}
});
}, {
threshold: 0.5 // 50% секции видимо
});
document.querySelectorAll('[data-section-id]').forEach(section => {
viewTracker.observe(section);
});
Частые ошибки
1. Забыть unobserve
// Утечка: observer продолжает наблюдать удалённые элементы
const observer = new IntersectionObserver(callback);
observer.observe(el);
el.remove(); // элемент удалён, но observer ещё живёт
// Правильно
observer.unobserve(el);
el.remove();
2. Callback вызывается при инициализации
// IntersectionObserver вызывает callback СРАЗУ при observe
// с текущим состоянием элемента
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// Первый вызов: isIntersecting может быть false!
if (!entry.isIntersecting) return; // не забывайте проверку
doSomething(entry.target);
});
});
3. threshold: 1.0 не срабатывает
// threshold: 1.0 требует, чтобы элемент был ПОЛНОСТЬЮ видим
// Если элемент больше viewport — никогда не сработает
const observer = new IntersectionObserver(callback, {
threshold: 1.0 // проблема для больших элементов
});
// Решение: используйте меньший threshold или rootMargin
Практика
- Реализуй lazy loading для списка изображений
- Построй бесконечный скролл с загрузкой данных
- Добавь анимацию появления секциям при прокрутке
- Создай индикатор прогресса чтения статьи (на основе видимых секций)