Lazy Loaded Video Component in React 🦥🎥

Matt Lewandowski - May 8 - - Dev Community

Do you use videos 🎥 on your website or web application? If so, you may have encountered issues with slow page load times or a suboptimal user experience. Videos can be a powerful tool for engaging your users, but they can also create performance challenges if not optimized properly.

Lazy loading is a technique that addresses these challenges by loading videos only when they are visible in the viewport. This means that the video content is not loaded until the user scrolls to the point where the video becomes visible, reducing initial page load time and saving bandwidth.

When you combine lazy loading, video posters, and the ability to play and pause videos, you end up with a highly optimized video component that can be used anywhere. You can see a good example of this on my change log, where the videos are only loaded as you scroll. Example GIF below. I've added an overlay to show exactly when the video is paused or playing.

Lazy Loading Videos

If you have ever used the <Image/> component that NextJS offers, you would know the power of lazy loading images on demand and the ease of doing so with a reusable component.

What We'll Cover

In this walkthrough, I'll guide you through the process of building a reusable <Video/> component using React and the Intersection Observer API. By the end of this tutorial, you'll have a powerful new tool that's super easy to use.

We will cover:

  1. The HTML <video/> element
  2. The Intersection Observer API
  3. Creating the <Video/> component
  4. The useIsVisible hook

The HTML <video/> Element

The HTML <video/> element is used to embed video content in a web page. It provides various attributes and methods to control the playback and behavior of the video. Docs.

Some important attributes of the <video/> element include:

  • src: Specifies the URL of the video file.
  • poster: Specifies an image to be shown while the video is downloading or until the user plays the video.
  • autoplay: Specifies that the video should start playing automatically when the page loads.
  • loop: Specifies that the video should restart from the beginning when it ends.
  • muted: Specifies that the audio output of the video should be muted.
  • preload: Specifies how the video should be loaded when the page loads.

You can also use the <source/> element inside the <video/> element to specify multiple video sources for different formats.

The Intersection Observer API

The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or the viewport. It allows you to detect when an element enters or leaves the viewport and respond accordingly. Docs.

The Intersection Observer API is highly efficient and performant compared to traditional methods like listening for scroll events or using getBoundingClientRect().

To create an intersection observer, you use the IntersectionObserver constructor, which accepts a callback function and an options object. The callback function is called whenever the observed element enters or leaves the viewport, and the options object allows you to configure the observer's behavior.

Creating the <Video/> Component

Now let's dive into creating the <Video/> component. The component will handle lazy loading, playing, and pausing the video based on its visibility in the viewport.



import { CSSProperties, useCallback, useEffect, useRef } from "react";

import { useIsVisible } from "@/hooks/use-is-visible";

type VideoComponentProps = {
  src: string;
  poster?: string;
  alt?: string;
  style?: CSSProperties;
};
export const VideoComponent = ({
  src,
  poster,
  style,
  alt,
}: VideoComponentProps) => {
  const { isVisible, targetRef } = useIsVisible(
    {
      root: null,
      rootMargin: "200px",
      threshold: 0.1,
    },
    false,
  );

  const videoRef = useRef<HTMLVideoElement>(null);

  const startVideoOnMouseMove = useCallback(async () => {
    try {
      await videoRef.current.play();
    } catch (e) {
      // do nothing
    }
  }, []);

  const stopVideoOnMove = useCallback(() => {
    try {
      videoRef.current.pause();
    } catch (e) {
      // do nothing
    }
  }, []);

  useEffect(() => {
    if (isVisible) {
      startVideoOnMouseMove();
    } else {
      stopVideoOnMove();
    }
  }, [isVisible, startVideoOnMouseMove, stopVideoOnMove]);

  return (
    <span
      ref={targetRef as any}
      style={{
        position: "relative",
        minHeight: "50px",
        height: "100%",
      }}
    >
      <video
        ref={videoRef}
        loop
        muted
        autoPlay={false}
        preload="none"
        playsInline
        poster={poster}
        aria-label={alt}
        style={{
          objectFit: "contain",
          display: "block",
          width: "100%",
          height: "100%",
          ...style,
        }}
      >
        <source src={src} type="video/mp4" />
        Your browser does not support the video tag. Please try viewing this
        page in a modern browser.
      </video>
    </span>
  );
};


Enter fullscreen mode Exit fullscreen mode

The component consists of three main parts:

  1. The useIsVisible hook: This hook uses the Intersection Observer API to determine if the video is visible in the viewport based on the specified options.

  2. Playing and pausing the video: We create a ref (videoRef) and assign it to the video element. The video element has props that tell it not to load and not to play. We manually control the loading, playing, and pausing of the video using the ref. If playing or pausing fails due to user interaction or permissions, we catch the error and ignore it.

  3. The rendered component: We assign the targetRef from the useIsVisible hook to the parent <span> element. This is the element we observe to determine visibility. Since the video is not loaded until it becomes visible, we need to give the <span> a minHeight to ensure it can be observed. The video element itself has various attributes like loop, muted, autoPlay, preload, and poster to control its behavior. We also include an aria-label for accessibility.

The useIsVisible Hook

The useIsVisible hook is responsible for determining if an element is visible in the viewport using the Intersection Observer API.



import { useEffect, useRef, useState } from "react";

interface IntersectionObserverOptions {
  root?: Element | null;
  rootMargin?: string;
  threshold?: number | number[];
}

export const useIsVisible = (
  options?: IntersectionObserverOptions,
  once = false,
) => {
  const optionsRef = useRef(options);
  const [isVisible, setIsVisible] = useState(false);
  const targetRef = useRef<Element | null>(null);

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          setIsVisible(() => true);
          if (once) {
            observer.unobserve(entry.target);
            observer.disconnect();
          }
        } else {
          setIsVisible(() => false);
        }
      });
    }, optionsRef.current);

    if (targetRef.current) {
      observer.observe(targetRef.current);
    }

    return () => {
      if (targetRef.current) {
        observer.unobserve(targetRef.current);
      }
      observer.disconnect(); // Clean up the IntersectionObserver
    };
  }, [once]);

  return { isVisible, targetRef };
};



Enter fullscreen mode Exit fullscreen mode

The hook accepts an options object that corresponds to the options of the Intersection Observer API. It also accepts an optional once parameter, which determines if the observer should continue observing after the element becomes visible. For videos, we set once to false since we want to know when the video enters and leaves the viewport.

The hook returns an isVisible boolean indicating whether the element is currently visible and a targetRef that should be assigned to the element you want to observe.

Understanding the rootMargin Option

The rootMargin option allows you to specify margins around the root (viewport) that alter the bounds of the intersection. It can be used to trigger visibility before or after the element actually enters the viewport.

  • A positive margin ("200px") means the element will be considered visible before it enters the viewport.
  • A negative margin ("-200px") means the element will be considered visible only after it has entered the viewport by the specified amount.

Be cautious with negative margins, as they may prevent the element from ever becoming visible if the user's screen is smaller than the negative margin.

Positive and negative margin

Let's take a look at each one.

Let's split it up into positive and negative so it's easier to understand.

Positive Margin

With a positive margin, both videos in this instance are "visible" and loaded, even though the first video is not technically inside of the viewport. This is useful if you want to load the video right before it is shown to the user, so it appears like it was already loaded.

Negative margin

With a negative margin, only one video is "visible" and loaded, even though they are both in the viewport. This is how I have my margin set in my demo gifs above. This is useful when debugging so that you can actually see it working. You might use this if you only want to play the video when it's in the center of the user's screen.

Caution with negative margins. If the user's screen is smaller than your negative margin, the element will never be visible.

If you have a lot of elements, generally you would have a positive margin so that the experience feels smoother. Elements outside of the margin will still be waiting to load. For example, here is a longer version of a positive margin.

Longer positive margin

Putting It All Together

With the <Video/> component and the useIsVisible hook, you now have a reusable and efficient way to lazy load videos in your React applications. The component ensures that videos are loaded and played only when they are visible in the viewport, providing a smoother user experience and better performance.

You can see this in action on my landing page, where I have four video elements with posters that load as you scroll.

Videos playing and pausing while scrolling

If you have any questions or suggestions on how this could be improved, feel free to leave them below. I hope this helps you optimize your video performance and enhance the user experience in your React applications!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .