Tracking an Elements Visibility in React

Recently on a React project I had a need to know when an element came into view and how much was visible. I was able to find several partial solutions, even an NPM package that came close.

The first part of my solution is a hook I found multiple variations of in multiple sources called useIsInViewport. You pass in a ref to your element and get back a boolean with whether the element is visible.
function useIsInViewport(ref) {
  const [isIntersecting, setIsIntersecting] = useState(false);

  const observer = useMemo(
    () =>
      new IntersectionObserver(([entry]) => {
        	setIsIntersecting(entry.isIntersecting);
	}
      ),
    [],
  );

  useEffect(() => {
    observer.observe(ref.current);

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

  return isIntersecting;
}
This will update when the element connected to the passed in ref comes into view, but it doesn’t tell me how much is in view. This can be useful in a document viewer, for example, where you want to indicate a page is being read based on being scrolled into view and is taking up most of the viewport. This has no threshold set so as soon as one pixel is visible isIntersecting is true. This behavior is useful for infinite scrolling lists to trigger loading of the next set of data but in the example of our document viewer we still don’t know how much is visible.

IntersectionObserver does provide the intersectionRatio in its callback so let’s make an update to get that as well.

function useIsInViewport(ref) {
  const [isIntersecting, setIsIntersecting] = useState(false);
  const [intersectionRatio, setIntersectionRatio] = useState(0);

  const observer = useMemo(
    () =>
      new IntersectionObserver(([entry]) => {
        	setIsIntersecting(entry.isIntersecting);
		setIntersectionRatio(entry.intersectionRatio);
	}
      ),
    [],
  );

  useEffect(() => {
    observer.observe(ref.current);

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

  return [isIntersecting, intersectionRatio];
}
Now when isIntersecting goes true we also get the ratio, but you will quickly find in a scrolling scenario you will get an intersectionRatio of close to zero and no further update. This is because the IntersectionObserver’s default threshold is 0 and once it has reached its threshold it calls its callback. In our example though we want to know when our element is 50% visible so we need to set the threshold.
function useIsInViewport(ref) {
  const [isIntersecting, setIsIntersecting] = useState(false);
  const [intersectionRatio, setIntersectionRatio] = useState(0);

  const observer = useMemo(
    () =>
      new IntersectionObserver(([entry]) => {
        	setIsIntersecting(entry.isIntersecting);
		setIntersectionRatio(entry.intersectionRatio);
	},
	{ threshold: [0.5] }
      ),
    [],
  );

  useEffect(() => {
    observer.observe(ref.current);

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

  return [isIntersecting, intersectionRatio];
}

By setting the threshold to 0.5 the IntersectionObserver will now call back when it comes 50% into view. This may be all the further you need to go but for me I had one more problem. In my example document viewer, a user’s viewport may be wider than tall, and my documents are formatted as either Letter or A4. I also need the document to be readable so it might not be possible to display 50% of a document on the screen at one time.

I know the dimensions of the document and I can get the dimensions of the viewport so I can calculate what the intersection ratio is that would constitute 50% of the viewport available but in our most recent iteration we will only get an update when it reaches 50%. If the viewport is such that at max, you will only get 40% then we will never reach our threshold

function useIsInViewport(ref) {
  const [isIntersecting, setIsIntersecting] = useState(false);
  const [intersectionRatio, setIntersectionRatio] = useState(0);

  const observer = useMemo(
    () =>
      new IntersectionObserver(([entry]) => {
        	setIsIntersecting(entry.isIntersecting);
		setIntersectionRatio(entry.intersectionRatio);
	},
	{ threshold: [0.1, 0.2, 0.3, 0.4, 0.5] }
      ),
    [],
  );

  useEffect(() => {
    observer.observe(ref.current);

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

  return [isIntersecting, intersectionRatio];
}

Conclusion

As you may have noticed earlier the threshold option on IntersectionObserver is an array so the way I solved this problem was to pass in some thresholds that are granular enough for me to answer my visibility problem without excessive updates. If you need more precision, you add more thresholds to a higher precision level, and if not fewer thresholds or more broad values.

About Intertech

Intertech is a Software Development Consulting Firm that provides single and multiple turnkey software development teams, available on your schedule and configured to achieve success as defined by your requirements independently or in co-development with your team. Intertech teams combine proven full-stack, DevOps, Agile-experienced lead consultants with Delivery Management, User Experience, Software Development, and QA experts in Business Process Automation (BPA), Microservices, Client- and Server-Side Web Frameworks of multiple technologies, Custom Portal and Dashboard development, Cloud Integration and Migration (Azure and AWS), and so much more. Each Intertech employee leads with the soft skills necessary to explain complex concepts to stakeholders and team members alike and makes your business more efficient, your data more valuable, and your team better. In addition, Intertech is a trusted partner of more than 4000 satisfied customers and has a 99.70% “would recommend” rating.