Skip to content
60fps/ui
@60fps/ui-utilsv0.0.2

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:

  • root is mapped to a stable id through a WeakMap (a null root — the viewport — becomes viewport),
  • rootMargin is taken verbatim,
  • threshold is 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.

Vanilla
Outside
0%
scroll down ↓
observed target
↑ scroll back up

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?)

OptionTypeDefaultDescription
rootElement | Document | nullnullThe viewport ancestor to test against. `null` is the browser viewport.
rootMarginstring'0px'Margin grown/shrunk around the root before computing intersections.
thresholdnumber | number[]0Ratio(s) of visibility at which the callback fires.

The returned manager:

MemberTypeDefaultDescription
subscribe(el, cb)(Element, cb) => voidObserve an element; the callback receives its IntersectionObserverEntry.
unsubscribe(el, cb)(Element, cb) => voidRemove a callback; the element is unobserved once its last callback is gone.
observerIntersectionObserverThe 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.

Outside
0%
scroll down ↓
observed target
↑ scroll back up
Outside
0%
scroll down ↓
observed target
↑ scroll back up
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?)

ArgumentTypeDefaultDescription
refRefObject<Element> | Ref<Element>The element to observe. Re-subscribes if it changes.
callback(entry, observer) => voidCalled with the target IntersectionObserverEntry each time it crosses a threshold.
options.rootElement | Document | nullnullThe viewport ancestor to test against. `null` is the browser viewport.
options.rootMarginstring'0px'Margin grown/shrunk around the root before computing intersections.
options.thresholdnumber | number[]0Ratio(s) of visibility at which the callback fires.