IntersectionObserver
Track when elements cross the viewport — or any scroll container — while keeping the number of native
IntersectionObserver instances to a minimum: a thousand observed targets can share a handful of
observers. It’s the primitive that powers InView; reach for it directly when you want
raw intersection data.
How it works
A native IntersectionObserver is bound to one { root, rootMargin, threshold } configuration for its
whole life — so the only way to share one is to hand the same instance back to everyone who asks for the
same configuration. getIntersectionObserver(options) does exactly that, by serializing the options
into a string key:
rootis mapped to a stable id through aWeakMap(anullroot — the viewport — becomesviewport),rootMarginis taken verbatim,thresholdis stringified — a number stays as-is, an array becomes a comma-separated list.
The resulting root|rootMargin|threshold key indexes a cache of observers: the first caller for a key
creates the observer, everyone after reuses it. So a list of 500 lazy images that all watch the viewport
at threshold: 0 allocates one IntersectionObserver, not 500 — and a second group using a different
rootMargin transparently gets its own.
Scroll inside the panel — the target is observed against the panel as its root, so the ratio
bar tracks intersectionRatio live.
import { getIntersectionObserver } from '@60fps/ui-utils';
// Same options anywhere in the app → same underlying observer.
const io = getIntersectionObserver({ threshold: 0.5 });
const onIntersect = (entry: IntersectionObserverEntry) => {
console.log(entry.isIntersecting, entry.intersectionRatio);
};
io.subscribe(el, onIntersect);
// later…
io.unsubscribe(el, onIntersect);
getIntersectionObserver(options?)
| Option | Type | Default | Description |
|---|---|---|---|
root | Element | Document | null | null | The viewport ancestor to test against. `null` is the browser viewport. |
rootMargin | string | '0px' | Margin grown/shrunk around the root before computing intersections. |
threshold | number | number[] | 0 | Ratio(s) of visibility at which the callback fires. |
The returned manager:
| Member | Type | Default | Description |
|---|---|---|---|
subscribe(el, cb) | (Element, cb) => void | — | Observe an element; the callback receives its IntersectionObserverEntry. |
unsubscribe(el, cb) | (Element, cb) => void | — | Remove a callback; the element is unobserved once its last callback is gone. |
observer | IntersectionObserver | — | The underlying shared observer instance. |
Convenience hooks
useIntersectionObserver wraps the manager with framework lifecycle so you never wire up
subscribe / unsubscribe by hand: it subscribes once the ref is attached, unsubscribes on unmount,
re-subscribes if the element changes, and always calls your latest callback. Pass a ref, a callback and
the observer options.
import { useRef, useState } from 'react';
import { useIntersectionObserver } from '@60fps/ui-react/intersection-observer';
function Tracked() {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useIntersectionObserver(ref, (entry) => setVisible(entry.isIntersecting), { threshold: 0.5 });
return <div ref={ref}>{visible ? 'visible' : 'hidden'}</div>;
}
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { useIntersectionObserver } from '@60fps/ui-vue/intersection-observer';
const el = useTemplateRef<HTMLElement>('el');
const visible = ref(false);
useIntersectionObserver(el, (entry) => (visible.value = entry.isIntersecting), { threshold: 0.5 });
</script>
<template>
<div ref="el">{{ visible ? 'visible' : 'hidden' }}</div>
</template>
useIntersectionObserver(ref, callback, options?)
| Argument | Type | Default | Description |
|---|---|---|---|
ref | RefObject<Element> | Ref<Element> | — | The element to observe. Re-subscribes if it changes. |
callback | (entry, observer) => void | — | Called with the target IntersectionObserverEntry each time it crosses a threshold. |
options.root | Element | Document | null | null | The viewport ancestor to test against. `null` is the browser viewport. |
options.rootMargin | string | '0px' | Margin grown/shrunk around the root before computing intersections. |
options.threshold | number | number[] | 0 | Ratio(s) of visibility at which the callback fires. |