How to trigger action/function when the element appears in the user's viewport?

Posted by Jakub, 17.06.2024

Lava flowing from a volcano - pixel art

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 adds listener to listeners list and lets the IntersectionObserver instance to observe this element.
  • removeListener - removes listener 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.

devFlipCards 2024

Do you accept cookies?

Cookies are small amounts of data saved locally on you device, which helps our website - it saves your settings like theme or language. It helps in adjusting ads and in traffic analysis. By using this site, you consent cookies usage.