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.
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 😃
};
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.
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];
};
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 componentuseEffect
is used to kick off the observation and cleanup when the component is mounted and unmountedWe 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.
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);
}
}
}
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 explanatoryAfter 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.
Its actually quite simple, all you need to do is import the hook like so:
import { useOutOfBounds } from './path-to-hook'
Then call the hook like this (Remember the returned array from the hook, well we're destructuring it here):
const [componentRef, outOfBounds] = useOutOfBounds();
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.
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!