test

Detect if a React Component is Out of Viewport Bounds

26 January, 2021 | 5 min read

So I recently found myself asking, "How do I detect if a component is out of viewport bounds?", the reason I was asking myself this question was because I wanted to know how to detect and move components if they were not in view, such as tooltips, dropdowns, menu items etc...

At first I thought the useLayoutEffect hook might have been a good option however it would not work for a component that was, for example, draggabble unless you were able to hook (😃) into the dragging state and add that as a dependency of the hook. It also wouldn't really work for anything that was controlled by CSS such as animations or hover effects either.

So I kept researching, googling, and testing things out until I came across the JavaScript MutationObserver.

A built-in object that observes a DOM element, firing a callback in case of modifications - W3 Schools

I'm not going to pretend to be a MutationObserver expert but I'll link to this article in case you want to learn more about it.

Basically we're going to utilize the MutationObserver to watch for changes to a component and then we're going to check the components position.

On wards to the code! ⚔️

Basic Hook Setup 🏗️

If you haven't written a hook before you may be a little intimidated but they are actually pretty simple. A custom hook is just a function that can utilize/call other React Hooks.

export const useOutOfBounds = () => {
	// Cool code goes here 😃
};
copied

Since our hook isn't going to accept an parameters we're not going to include them in the function definition. One thing to note here is the function name, useOutOfBounds, React requires that custom hook names start with use otherwise it will throw obscure errors.

Effect, State, Observer 🕵🏽‍♂️, and Hook Return

Since this is a custom hook we can call other hooks, such as useState, useRef, and useEffect which is precisely what we will be doing.

export const useOutOfBounds = () => {
	const componentRef = React.useRef();
 	const [isOutOfBounds, setIsOutOfBounds] = React.useState({
		top: 0,
		bottom: 0,
		left: 0,
		right: 0
 	});
 	const observer = new MutationObserver(mutationObserverCallback); //We'll define this callback in a second
    
    React.useEffect(() => {
		if (componentRef.current) {
			observer.observe(componentRef.current, { attributes: true, childList: true, subtree: true });
		}

		return () => observer.disconnect();
	}, [componentRef, observer]);

	return [componentRef, isOutOfBounds];

};
copied
  • useRef is used (😂) to create a ref that the user of the hook will pass to the component we will be observing.
  • useState will store the information about the out of bounds positioning of the component
  • useEffect is used to kick off the observation and cleanup when the component is mounted and unmounted

We are also creating a MutationObserver and passing it a callback, don't worry we'll setup the callback in just a moment. Within the useEffect we are invoking the observe method of the MutationObserver we created when the component ref has been updated, this is what tells the observer to start observing/watching the dom element/component for changes. Lastly, within the useEffect we simply invoke the disconnect method of the MutationObserver to stop watching the component on cleanup (we don't want stray observers running).

The last thing we did was define the return for the hook, in typical hook fashion we are returning an array, where the first item is the component ref that the user will pass to their component and the second is the object detailing how far out of bounds the component is.

Observer callback setup

Now lets create the callback that will be passed to the MutationObserver, this is going to do all of the work. I'm not going to repeat the rest of the hook code from above but this code should be within the scope of the hook function.

const mutationObserverCallback = (mutationRecord, observer) => {
  if (componentRef.current) {
    const rect = componentRef.current.getBoundingClientRect();
    const windowWidth = Math.min(document.documentElement.clientWidth, window.innerWidth);
    const windowHeight = Math.min(document.documentElement.clientHeight, window.innerHeight);
    let directions = {
      top: 0,
      bottom: 0,
      left: 0,
      right: 0
    };

    if (rect.top < 0) {
      directions.top = Math.abs(0 - rect.top);
    } 

    if (rect.bottom > windowHeight) {
      directions.bottom = Math.abs(windowHeight - rect.bottom);
    }

    if (rect.left < 0) {
      directions.left = Math.abs(0 - rect.left);
    }

    if (rect.right > windowWidth) {
      directions.right = Math.abs(windowWidth - rect.right);
    }

    if (isOutOfBounds.top !== directions.top || isOutOfBounds.bottom !== directions.bottom || isOutOfBounds.left !== directions.left || isOutOfBounds.right !== directions.right) {
      setIsOutOfBounds(directions);
    }
  }
}
copied

Phew 💨 that's a lot of code, let me explain!

  • rect basically is the position of the component we are watching. where top, right, left, and bottom are the distance of the component from those various sides of the viewport. (Mozilla Docs)
  • windowWidth and windowHeight are pretty self explanatory

After we define those variables we just go through some simple logic in the if statements and get the amount of pixels the component is out of viewport on the various sides.

Lastly we do a comparison of the properties of the current out of bounds and the new one before setting it.

Time to Use the Hook 🥳

Its actually quite simple, all you need to do is import the hook like so:

import { useOutOfBounds } from './path-to-hook'
copied

Then call the hook like this (Remember the returned array from the hook, well we're destructuring it here):

const [componentRef, outOfBounds] = useOutOfBounds();
copied

Demo

This is a gif of a quick demo that I made in a CRA app using this hook. You'll see the out of bounds object logged to the console as the text is moved slightly out of the viewport.

ViewPort Bounds Gif

We're Done! 🎉

We made a custom hook! If it was your first time doing so, congrats! If not hopefully you are able to use this hook in some future projects!