If you have worked with Gatsby before, you have probably used gatsby-image. Gatsby-image gives us these two techniques without the hassle of building it ourselves.

But we are developers. We like to build things ourselves.

So let’s build it.

First, let’s analyze the problem.

We need to know which images have entered the viewport

Once an image enters the viewport, we need to load the thumbnail and the full-sized image

Once the full-sized image is loaded, we need to swap out the thumbnail

We need to make sure that our page doesn’t “jump” when we load our images. Our placeholder container should be the same height and width as our final image.

Let’s get started

Start by scaffolding a new React application using create-react-app.

npx create-react-app progressive-images

We will use Unsplash for our images. I used the Unsplash API to get an array of ten of their latest images. This response is saved in a Github Gist.

Copy and paste the contents of this gist into a file called images.json .

Now open App.js and replace it with the following.

import React from "react";

import images from "./images.json";

import ImageContainer from "./components/image-container";

import "./App.css"; function App() {

return (

<div className="app">

<div className="container">

{images.map(res => {

return (

<div key={res.id} className="wrapper">

<ImageContainer

src={res.urls.regular}

thumb={res.urls.thumb}

height={res.height}

width={res.width}

alt={res.alt_description}

/>

</div>

);

})}

</div>

</div>

);

} export default App;

And open App.css and paste in the following

.app {

display: flex;

justify-content: center;

padding-top: 1em;

} .container {

width: 100%;

max-width: 600px;

} .wrapper {

padding: 1em 0;

}

Now let’s create components/image-container.js .

Before worrying about rendering an image, let’s make the fallback container.

import React from "react";

import "./image-container.css"; const ImageContainer = props => {

const aspectRatio = (props.height / props.width) * 100; return (

<div

className="image-container"

style={{ paddingBottom: `${aspectRatio}%` }}

/>

);

};

And image-container.css

.image-container {

position: relative;

overflow: hidden;

background: rgba(0, 0, 0, 0.05);

}

First, we figure out the aspect ratio of the image. This is calculated by dividing the width by the height. Then we add padding-bottom to our image container with this value.

For example, a 1024 x 768 px image has an aspect ratio of 0.75. We would add padding-bottom: 75% to our container.

We can run our app with yarn start and see what it looks like.

Our app so far.

Now we have some boxes that are the same size as the images we want to render.

Intersection Observer

Now we need a way of keeping track of when an image enters the viewport. For this, we can use the new browser API IntersectionObserver .

The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document’s viewport — developer.mozilla.org

Let’s make a custom hook. Create the file hooks/use-intersection-observer.js .

import React from "react"; const useIntersectionObserver = ({

target,

onIntersect,

threshold = 0.1,

rootMargin = "0px"

}) => {

React.useEffect(() => {

const observer = new IntersectionObserver(onIntersect, {

rootMargin,

threshold

}); const current = target.current; observer.observe(current); return () => {

observer.unobserve(current);

};

});

}; export default useIntersectionObserver;

Since we didn’t define a root , IntersectionObserver defaults to the viewport. We defined a threshold of 0.1 . This means that when 10% of the target is visible within the viewport, our callback is invoked.

Using our custom hook

To use this custom hook, we need to invoke it with a target and a callback function.

Our target with be a React ref attached to our container div.

Our callback function will set a state variable indicating that the image is visible. Then it will call observer.unobserve . Once an image is visible, we don’t need IntersectionObserver to observe it any longer.

Make the following changes to image-container.js .

import React from "react";

import useIntersectionObserver from "../hooks/use-intersection-observer";

import "./image-container.css"; const ImageContainer = props => {

const ref = React.useRef();

const [isVisible, setIsVisible] = React.useState(false); useIntersectionObserver({

target: ref,

onIntersect: ([{ isIntersecting }], observerElement) => {

if (isIntersecting) {

setIsVisible(true);

observerElement.unobserve(ref.current);

}

}

}); const aspectRatio = (props.height / props.width) * 100; return (

<div

ref={ref}

className="image-container"

style={{ paddingBottom: `${aspectRatio}%` }}

>

{isVisible && (

<img className="image" src={props.src} alt={props.alt} />

)}

</div>

);

}; export default ImageContainer;

Now we render the full-sized image when our component enters the viewport.

Let’s see it in action.

Lazy Loading Images

Nice! Our app is now lazy loading the images. Our images are only downloaded when they are visible in the viewport.

If you check the network tab, you can see this in action. Check the Waterfall.

Network Tab While Running our App — Waterfall

Adding the Blur Up Technique

Start by creating two new files. components/image.js and components/image.css .

Our Image component renders two images: the full-sized image and the thumbnail. We hide the thumbnail when the full-sized image is loaded.

Copy and paste the code below into components/image.js .

import React from "react";

import "./image.css"; const Image = props => {

const [isLoaded, setIsLoaded] = React.useState(false);

return (

<React.Fragment>

<img

className="image thumb"

alt={props.alt}

src={props.thumb}

style={{ visibility: isLoaded ? "hidden" : "visible" }}

/>

<img

onLoad={() => {

setIsLoaded(true);

}}

className="image full"

style={{ opacity: isLoaded ? 1 : 0 }}

alt={props.alt}

src={props.src}

/>

</React.Fragment>

);

}; export default Image;

And the CSS below into components/image.css .

.image {

position: absolute;

top: 0;

left: 0;

width: 100%;

height: 100%;

} .full {

transition: opacity 400ms ease 0ms;

} .thumb {

filter: blur(20px);

transform: scale(1.1);

transition: visibility 0ms ease 400ms;

}

Now let’s run our application one last time.

Be sure to open devtools and disable caching.

Lazy loading and Blur Up together

Summary

We made a React application with lazy loaded images. Our application only renders images after they enter the viewport. It also progressively renders them by first showing a blurred thumbnail.

Take a look at the repository here.