I have had mediocre success with a combination of 3 observers:
- An event listener listens for scroll events on any of the scrollable ancestors of the observed element. For this, a capture scroll event listener can be registered on the document, which checks if the event target is an ancestor of the observed element.
- A
ResizeObserver
detects resizes of the observed element.
- An
IntersectionObserver
detects position changes of the observed element. To achieve this, an invisible child element is added to the observed element, 2×2 pixels in size, positioned using position: fixed
. The invisible element does not have any top
or left
coordinates, causing it to be rendered inside the observed element, but it is moved using a negative margin-left
and margin-top
to an absolute position of -1,-1
in the top left corner of the viewport. With this positioning, 1 of the 4 pixels is visible in the viewport (at position 0,0
of the viewport), while the other 3 pixels are invisible (at position -1,-1
, -1,0
and 0,-1
of the viewport). As soon as the observed element moves, its invisible child moves with it, causing 0 or more than 1 pixel to be visible and the IntersectionObserver
to fire, leading us to emit a change event and repositioning the invisible element.
function observeScroll(element: HTMLElement, callback: () => void): () => void {
const listener = (e: Event) => {
if ((e.target as HTMLElement).contains(element)) {
callback();
}
};
document.addEventListener('scroll', listener, { capture: true });
return () => {
document.removeEventListener('scroll', listener, { capture: true });
};
}
function observeSize(element: HTMLElement, callback: () => void): () => void {
const resizeObserver = new ResizeObserver(() => {
callback();
});
resizeObserver.observe(element);
return () => {
resizeObserver.disconnect();
};
}
function observePosition(element: HTMLElement, callback: () => void): () => void {
const positionObserver = document.createElement('div');
Object.assign(positionObserver.style, {
position: 'fixed',
pointerEvents: 'none',
width: '2px',
height: '2px'
});
element.appendChild(positionObserver);
const reposition = () => {
const rect = positionObserver.getBoundingClientRect();
Object.assign(positionObserver.style, {
marginLeft: `${parseFloat(positionObserver.style.marginLeft || '0') - rect.left - 1}px`,
marginTop: `${parseFloat(positionObserver.style.marginTop || '0') - rect.top - 1}px`
});
};
reposition();
const intersectionObserver = new IntersectionObserver((entries) => {
const visiblePixels = Math.round(entries[0].intersectionRatio * 4);
if (visiblePixels !== 1) {
reposition();
callback();
}
}, {
threshold: [0.125, 0.375, 0.625, 0.875]
});
intersectionObserver.observe(positionObserver);
return () => {
intersectionObserver.disconnect();
positionObserver.remove();
};
}
export function observeBounds(element: HTMLElement, callback: () => void): () => void {
const destroyScroll = observeScroll(element, callback);
const destroySize = observeSize(element, callback);
const destroyPosition = observePosition(element, callback);
return () => {
destroyScroll();
destroySize();
destroyPosition();
};
}
When using this code, keep in mind that the callback is called synchronously and will block whatever event is calling it. The callback should call whatever actions asynchronously (for example using setTimeout()
).
Note: There are some situations where this will not work:
- When the observed element has an ancestor that acts as a containing block for fixed elements (
transform
, perspective
, filter
or will-change: transform
is set, see MDN under fixed
) AND has overflow
set to something else than visible
, fixed descendants will not be able to escape the containing block. This will cause permanent invisibility of all 4 pixels of the position detector element, so the IntersectionObserver
will not fire on position changes. I am looking for a solution here.
- When the observer is used inside an iframe and the top left corner of the iframe is out of view, the
IntersectionObserver
will also report 0 pixels of visibility.