Lazy-load images with the Intersection Observer API and React

Lazy-load images with the Intersection Observer API and React

The Intersection Observer API allows you to observe and react to changes when an element enters the viewport or a parent element. It's very commonly used for lazy loading but can also be used for things like animations or changing the active state of links in a navigation bar as you scroll to different sections of a page. Lazy loading images stops the user from having to download resources on initial page load, instead, deferring it until a more appropriate time.

I've been playing around with different effects for lazy-loading images and I like this one where instead of swapping a placeholder image with the real image, we're instead revealing the image with CSS opacity transitions.

Install and import packages and styles

To do this let's start with some imports, we'll be using React hooks, CSS modules and the classnames package to get this working. You can see the completed code below in the CodeSandbox using create-react-app.

1
2
3
import React, { useState, useEffect, useRef } from "react";
import classnames from 'classnames';
import styles from "./styles.module.scss";

Create the component

We'll create a functional component that returns a div element that we'll use as our placeholder. We'll also use the useRef hook to get a reference to our placeholder in the DOM. Later we can use this ref to see if the placeholder has come into the viewport.

1
2
3
4
5
6
7
8
9
10
11
12
export default function App () {
  const placeholder = useRef();

  return (
    <div className={styles.wrapper}>
      <div
        className={styles.placeholder}
        ref={placeholder}
      />
    </div>
  );
}

The useRef hook gives you a way to store a mutable value using the .current property on the object returned. I won't go into why this can be useful, but in our case when we do ref={placeholder} on our placeholder element, React sets the .current property to the corresponding DOM node and keeps that value up-to-date whenever it changes.

Add styles

The styles for this are pretty minimal, the element wrapping the image and placeholder has its display set as relative to constrain the absolutely positioned placeholder. Essentially, the placeholder is a yellow box with a bottom padding set to match the aspect ratio of the image we're using. Adding the transition property means we can use CSS to fade out our placeholder when we programmatically add the hidePlaceholder class.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.wrapper {
  position: relative;
}

.placeholder {
  position: absolute;
  padding-bottom: 67%;
  margin-bottom: 4rem;
  width: 100%;
  background: #ffdd28;
  opacity: 1;
  transition: opacity 2s ease;
}

.hidePlaceholder {
  opacity: 0;
}

At this point we'll have nothing but a yellow rectangle on the page… exciting!

The next step is to make that box fade out, we've already created the CSS class so we just need to add it to the placeholder div. First, we'll create a new piece of state with the useState hook to let us know whether our image should be shown or not. By default this is false, we don't want to show our image on first render. Then, we'll use our showImage state to decide when to add our hidePlaceholder class. To do this, I like to use the classnames package, because it's a bit cleaner than writing it myself and handy for doing conditional logic with class names.

1
2
3
4
5
6
const [showImage, setShowImage] = useState(false);
  
const placeholderStyles = classnames(
  styles.placeholder,
  { [styles.hidePlaceholder]: showImage }
);

Now that is sorted we can update our placeholder element to use the new classes variable.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export default function App () {
  const placeholder = useRef();
  const [showImage, setShowImage] = useState(false);
  
  const placeholderStyles = classnames(
    styles.placeholder,
    { [styles.hidePlaceholder]: showImage }
  )

  return (
    <div className={styles.wrapper}>
      <div
        className={placeholderStyles}
        ref={placeholder}
      />
    </div>
  );
}

Add our image

Now we're at the point where our box will fade to nothing when the value of the showImage state changes. Though, we still need to actually show the image, so let's do that next. I've picked an image and created a variable for it outside of my component called IMAGE_URL. We already have our showImage state so we can also use this to conditionally render our image.

1
2
3
4
5
6
7
<div className={styles.wrapper}>
  <div
    className={placeholderStyles}
    ref={placeholder}
  />
  {showImage && <img src={IMAGE_URL} alt="Girl wearing VR headset" />}
</div>

Set up the Intersection Observer

All we have left to do is change the value of our state when the placeholder comes into view. We need to set up the Intersection Observer.

The Intersection Observer takes two parameters, the first is the callback. This is what will be called when your element comes into view, in our case, when our placeholder comes into view. Do note that this callback will also be executed the very first time the observer tries to watch your element. 

The callback will be given a list of IntersectionObserverEntry objects as its first parameter. Each item in the list represents each item being observed who's intersecting status has changed. In our case the list will always have a length of one, as we're only watching one element (our placeholder).

1
2
3
4
5
6
7
const callback = (entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      setShowImage(true);
    }
  });
};

We can check the value of the isIntersecting property on the entry to see if our placeholder is inside the viewport and when it is, then we should update our showImage state to true.

The second parameter passed into the Intersection Observer is the options object. There are a few different options we can set to configure the observer but we'll just focus on the threshold. The threshold determines when the callback should be run. It can either be one number or an array of numbers. By default this is 0, so if 1 pixel of the element we're watching comes into the viewport then our callback is fired, I've changed this to 1.0 which means our showImage state will not change until the whole placeholder is in the viewport. This also means we can watch it fade in smoothly.

1
2
3
const options = {
  threshold: 1.0
};

Now we can create our new instance of the Intersection Observer, passing in our callback and options, and use it to observe our placeholder.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
useEffect(() => {
  const callback = (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        setShowImage(true);
      }
    });
  };

  const options = {
    threshold: 1.0
  };

  const observer = new IntersectionObserver(callback, options);
  observer.observe(placeholder.current);
}, []);

You'll notice that I've put it all inside of a useEffect. We use the useEffect hook to run code (an effect) after the render when our components are on screen. We need to wait for this to happen before we observe our placeholder. By default the useEffect will run after every render, but we can change this to run when specific values have changed.

We can do this by passing an array as the second value to the useEffect hook, this array should include any dependencies your effect uses to ensure it always has the up-to-date value. In our case the effect doesn't have any dependencies and we only want to set up the Intersection Observer once so I have left the array empty. This means the effect will only run once on first render.

useEffect cleanup

We should also ensure we run cleanup functions in a useEffect we can do this by returning a function inside the useEffect which will be called when the component is destroyed.

1
2
3
4
5
useEffect(() => {
  // intersection observer set-up

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

Here we are using the disconnect method on the observer to stop it from watching all of its target elements for visibility changes. This means it will stop watching our placeholder.

We're done!

Once you've added enough content above your image you'll see as you scroll down your image fades in smoothly after the placeholder is completely in view. Check the CodeSandbox below to try it out. 🙂

Thanks for making it this far! Follow me on Instagram for other JavaScript tips and tutorials 👩🏾‍💻

← Back to writing list