Progressively Loading Images In React
Making a Medium Style Image Loader With Intersection Observer and React Hooks
Have you ever wondered how Medium loads images? Maybe you noticed how images seem to render in multiple steps. A blurry version of the image appears on screen and then is replaced by the full-sized version.

How Medium stories loads images
- The image loading does not begin until the image enters the viewport
- Then a “blurred up” thumbnail loads
- Then the full-size image loads and replaces the thumbnail
We can categorize this image loading technique into two distinct features.
1. Lazy loading
Lazy loading is technique that defers loading of non-critical resources at page load time. Instead, these non-critical resources are loaded at the moment of need. Where images are concerned, “non-critical” is often synonymous with “off-screen” — developers.google.com
Lazy loading is a great technique because it can drastically improve your site’s perceived performance.
Imagine you wrote a ten-minute long blog post with 20 high-resolution images. If all 20 images were loaded at once, the post would be slow to load. With lazy loading, we can load the images on demand. No matter how long the story, we only render what’s visible to the user.
2. “Blur Up”
To display something on the screen faster, we show a blurred tiny image scaled to full width. When we have finished loading the full-sized image, we swap them out.
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.

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.

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.

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 intocomponents/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.

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.