How to detect visibility of an element while scrolling the page?
It's a common scenario - we want to trigger an action only when the element is visible during scrolling. It's useful for long, scrollable single-page landing pages and all content is visible on a long page. When the scrolling reaches to the element, we'd like to trigger an action.
There were no comfy solution for it, but there's an IntersectionObserver
class introduced some time ago in ECMA Script
. Here you can check the compatibility of IntersectionObserver
API with modern browsers. If it's not right for you, check out available polyfills, i.e. this one: intersection-observer-polyfill
How does IntersectionObserver
work?
It's a class, whose constructor requires passing a callback. This callback accepts an entries
argument, which is a list of elements observed by IntersectionObserver
instance. It's triggered every time any element from the list appears on the user's viewport or leaves it.
Better explained docs of IntersectionObserver
is available on mdn web docs.
const observer = new IntersectionObserver((entries) => { const element = entries.find((element) => element.id === "myElement"); console.info(element.isIntersecting); }); const myElement = document.getElementById("myElement"); observer.observe(myElement);
Above code will log true
to the console whenever you scroll into an element with myElement
id or false
, whenever it disappears from your view.
How to make it a hook?
In React hooks are most common solution for such utils, so let's build a hook to handle an IntersectionObserver
function useInViewport<T>(el?: MutableRefObject<T>): boolean { const [visible, setVisible] = useState(false); useEffect(() => { const observer = new IntersectionObserver((entries) => { setVisible(entries[0].isIntersecting); }); el?.current && observer.observe(el.current); return () => { el?.current && observer.unobserver(el.current); }; }, [el]); return visible; }
It accepts ref
to the element, which we'd like to observe. It returns true
/false
state depending on the element's visibility. Each change triggers component rerender, so we can adjust it to the changes live. Thanks to cleanup function returned from useEffect
hook, element is not observed anymore when the component gets unmounted.
Hook can be easily refactored to accept id of an element, not it's ref
:
function useInViewport(id: string, remainVisible?: boolean): boolean { const [visible, setVisible] = useState(false); useEffect(() => { const element = document.getElementById(id); const observer = new IntersectionObserver((entries) => { setVisible(entries[0].isIntersecting); }); element && observer.observe(element); return () => { element && observer.unobserver(element); }; }, [el]); return visible; }
Do we need as many IntersectionObservers
as elements?
As you may noticed, IntersectionObservers
callback accepts entries
argument, which is a list of observed elements. We can create an util class which will take car of IntersectionObserver
instantiation and will handle all observed elements at once.
interface Listener { el: Element; listener: (isIntersecting: boolean) => void; } class IntersectionObserverRunner { private static listeners: Listener[] = []; private static observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { const listener = this.listeners.find( (currentListener) => currentListener.el === entry.target, ); listener?.listener(entry.isIntersecting); }); }); static addListener = ( element: Element, listener: (isIntersecting: boolean) => void, ) => { this.observer?.observe(element); this.listeners.push({ el: element, listener }); }; static removeListener = (element: Element) => { this.observer?.unobserve(element); this.listeners.splice( this.listeners.findIndex( (currentListener) => currentListener.el === element, ), 1, ); }; }
Ok, so what's going on here?
Class consists of static methods and fields, which means there's only one instance of it for a whole app. Thanks to that we'll only have one IntersectionObserver
saved in observer
static field of IntersectionObserverRunner
It has listeners
field, which is an array of Listener
type objects. Each of them contains a DOM element and a listener - a function triggered whenever it's visibility change.
In an observer
field we create an instance of IntersectionObserver
class with it's callback. It iterates through every entry observed. For each of them it finds it's listener
in listeners
array and triggers it.
Additionally, there are 2 static functions:
addListener
- it accepts DOM element which we'd like to observe and a function for it. It addslistener
tolisteners
list and lets theIntersectionObserver
instance to observe this element.removeListener
- removeslistener
from an array and unobserves element.
Now we can adjust our previous hook to work with IntersectionObserverRunner
:
function useInViewport<T>( el?: MutableRefObject<T>, remainVisible?: boolean, ): boolean { const [visible, setVisible] = useState(false); useEffect(() => { el?.current && IntersectionObserverRunner.addListener(el.current, (isVisible) => setVisible(isVisible || !!remainVisible), ); return () => { el?.current && IntersectionObserverRunner.removeListener(el.current); }; }, [el, remainVisible]); return visible; }
This hook works in a similar way to the previous one, but instead of creating a new IntersectionObserver
instance each time we use this hook, it uses an existing one. The difference is a new parameter remainVisible
. When it's true
, visibility state will only change once, to true
and remain like that. Thanks to that we can trigger the passed action only once, no matter how many times user will scroll into element.