JavaScript

How to tell an element is in view using JavaScript.

Recently, I've created a dynamic story where I wanted an event to trigger when an element is in view. The solution was simple, but it's easy to make a mistake and drain your website's performance. Let's break it down into how we can create a flexible function that doesn't freeze the page. Note, modern browsers have a new API that makes this problem trivial, but first let's try to understand the problem and how to solve it. Skip to Modern Solution!

First, how do you know an element is in view? Let's create a function called isElementInView() that returns true or false when we pass an element.

const isElementInView = (element) => {
    return false;
}

Today, all major browsers support the getBoundingClientRect() method. This method returns the position of the element on the page. It includes the properties: left, top, right, bottom, x, y, width, and height. Using these properties and the current windows inner width and height, we can check if the element is in view.

const isElementInView = (el) => {
    if (!el) return false;
    const rect = el.getBoundingClientRect();
    const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
    const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
    return (
        rect.top >= 0 &&
        rect.left >= 0 &&
        rect.bottom <= viewportHeight &&
        rect.right <= viewportWidth
    );
};

When an element is in full view and we call the function isElementInView, it will return true. If it is not in view or only partially in view, the function will return false. Now it's nice that we can identify that an element is in view, this would only work if we call the function constantly. This can be achieved by hooking up this function with the scroll event.

Here is how:

window.addEventListener("scroll", () => {
    const element = document.getElementById("my-element");
    if (isElementInView(element)) {
        doSomething();
    }
});

Every time a user scrolls, even by a fraction of a pixel, the browser will trigger the event. If the element is in view then our function doSomething() will be called. There is a potential problem here. If you only want the function doSomething() to be called once, then we will have to make some modifications. For example, we can add a boolean to track if the function has been called already. Let's make that change.

let hasBeenCalled = false;
window.addEventListener("scroll", () => {
    if (hasBeenCalled) {
        return;
    }
    const element = document.getElementById("my-element");
    if (isElementInView(element)) {
        doSomething();
        hasBeenCalled = true;
    }
});

Now, everytime you scroll we first check if the function has been called before we do any work. Only if it hasn't do we check if the element is in view. After we call our function doSomething() we make sure to set hasBeenCalled to true so the function won't be triggered again. This is a good improvement, but I can't help but think that every time we scroll, we are still triggering an event, even if we don't do a lot of computation with it. Let's try to improve this by clearing the event after it has been triggered.

First, we will define the onScroll function so we can reference it later. When the event is triggered, we will remove the event from the page.

const onScroll = () => {
    const element = document.getElementById("my-element");
    if (!isElementInView(element)) {
        return;
    }
    doSomething();
    window.removeEventListener("scroll", onScroll);
};
window.addEventListener("scroll", onScroll);

Note, I've updated the condition so that if the element is not in view we exit the function with a return. This works great, but I can't help but think about how often the scroll event is triggered. If I slowly scroll the page by 200 pixels, that means I am calling the onScroll at least 200 times. As a consequence I'm also calling document.getElementById 200 times, and then I'm checking the bounding box properties 200 times as well. The more I scroll, the more I call these expensive DOM methods.

One trick we can use is to limit the number of times the function can be called in an interval. This method is called throttling. For example, we can say: only trigger the scroll event once every 200ms. This will drastically reduce the performance hit. We can do so by adding a delay whenever the event is triggered.

let isCheckingScroll = false;
const delay = 200; // milliseconds
const onScroll = () => {
    if (isCheckingScroll) {
        return ;
    }
    isCheckingScroll = true;
    setTimeout(() => {
        isCheckingScroll = false;
    }, delay);
    const element = document.getElementById("my-element");
    if (!isElementInView(element)) {
        return;
    }
    doSomething();
    window.removeEventListener("scroll", onScroll);
};
window.addEventListener("scroll", onScroll);

When the onScroll event is triggered, we check if isCheckingScroll has been set to true already. This prevents any further work. If it hasn't been set, we set isCheckingScroll preventing any further work. Then we create a time out timeout that will reset the value to false in 200 milliseconds in our case. This means, the function will only be called once every 200ms.

We've come a long way from where we started. Now we know how to efficiently trigger an event when an element is in view. But it becomes tedious to rewrite these steps any time we need to monitor an element. Especially when you want to have multiple elements you want to track on the page. So the next step will be to package this into a reusable function. Let's create a function called whenInView(). This function will take an element, a call back function, and rate for how often we want to check during scrolling.

const whenInView = (el, cb, rate = 200) => {
  let isCheckingScroll = false;
  const onScroll = () => {
    if (isCheckingScroll) {
      return;
    }
    isCheckingScroll = true;
    setTimeout(() => {
      isCheckingScroll = false;
    }, rate);
    if (!isElementInView(el)) {
      return;
    }
    window.removeEventListener("scroll", onScroll);
    cb();
  };
  window.addEventListener("scroll", onScroll);
};

Here is an example on how to use it:

const doSomething = () => {
    console.log("Open the box of chocolate");
};

const box = document.querySelector(".box-of-chocolate");
whenInView(box, doSomething, 200);

When the element is in view, we should see the message in the console. You can increase or decrease the rate to something that works best for you.

Other considerations.

One thing to take into account is the whenInView function does not consider if you want to trigger these events multiple times. For example, if the element goes out of view, and then back into view, do we want to trigger it again? I'll leave it as an exercise for the reader.

Also, the isElementInView function only triggers if the element is fully in view. Depending on your needs, you might want to implement a function that considers when the element is partially in view.

const isElementPartiallyInView = (el) => {
    if (!el) return false;
    const rect = el.getBoundingClientRect();
    const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
    const viewportWidth = window.innerWidth || document.documentElement.clientWidth;

    return (
        rect.bottom > 0 && // Element's bottom is below the top of the viewport
        rect.right > 0 &&  // Element's right is to the right of the left of the viewport
        rect.top < viewportHeight && // Element's top is above the bottom of the viewport
        rect.left < viewportWidth // Element's left is to the left of the right of the viewport
    );
};

Modern Solution.

Many years back, I had tackled this very issue but there were no solutions provided by the browser. But today, we have an API that takes away all the guess work and let the browser properly calculate when an element is in view saving us precious resources. We can use the IntersectionObserver API. Here is how it works:

const observer = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            doSomething();
            observer.unobserve(entry.target); // Stop observing once in view
        }
    });
}, {
    root: null, // default is the viewport
    rootMargin: '0px', // no extra margin
    threshold: 0.1 // trigger when 10% of the element is visible
});

observer.observe(box);

So the observer observes the element we want to watch and when it is in view it will intersect with the and trigger the call back. Note that we can make use of the threshold parameter to decide how much of the element, in percentage, needs to be visible before we trigger our call back.

We can easily wrap this function as well in our whenInView method and if the browser supports it make use of it:

// Final code
const RATE = 200; // default rate for scrollInView
const scrollInView = (el, cb) => {
  let isCheckingScroll = false;
  const onScroll = () => {
    if (isCheckingScroll) {
      return;
    }
    isCheckingScroll = true;
    setTimeout(() => {
      isCheckingScroll = false;
    }, RATE);
    if (!isElementInView(el)) {
      return;
    }
    window.removeEventListener("scroll", onScroll);
    cb();
  };
  window.addEventListener("scroll", onScroll);
};

const isElementInView = (el) => {
  if (!el) return false;
  const rect = el.getBoundingClientRect();
  const viewportHeight =
    window.innerHeight || document.documentElement.clientHeight;
  const viewportWidth =
    window.innerWidth || document.documentElement.clientWidth;
  return (
    rect.top >= 0 &&
    rect.left >= 0 &&
    rect.bottom <= viewportHeight &&
    rect.right <= viewportWidth
  );
};

const observeInView = (element, callback) => {
  const observer = new IntersectionObserver(
    (entries, observer) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          callback();
          observer.unobserve(entry.target); // Stop observing once in view
        }
      });
    },
    {
      root: null, // default is the viewport
      rootMargin: "0px", // no extra margin
      threshold: 0.5, // 50% of the element must be visible
    }
  );

  observer.observe(element);
};

const whenInView = ( () => {
    return "IntersectionObserver" in window
        ? observeInView
        : scrollInView;
})();

// Example usage:
whenInView(document.querySelector('#myElement'), () => {
  console.log('Element is in view!');
});

Comments

There are no comments added yet.

Let's hear your thoughts

For my eyes only