A Blog by Hangindev

Create a Lazy-Loading Image Component with React Hooks

Lazy loading image in Medium
Lazy loading image in Medium

Lazy-loading images (like those in Medium or those created by gatsby-image ๐Ÿงก) can sometimes add an extra touch of style to a page. To create such an effect, one will need A) a tiny version of the image for preview, ideally inlined as data URL, and B) the aspect ratio of the image to create a placeholder to prevent reflows. In this article, I will share how I created a lazy-loading image component with React Hooks.

CodeSandbox Demo

First, the barebone - HTML/CSS ๐Ÿฆด

Usually, a lazing loading image consists of 4 HTML Elements:

<div class="wrapper">
<div style="padding-bottom:56.25%"></div>
  1. a relatively positioned wrapper div,
  2. an intrinsic placeholder div for maintaining aspect ratio. It has padding-bottom with a percentage value(relative to the width of the containing block), e.g. for a 16:9 image, the percentage is calculated as 9/16 * 100% = 56.25%,
  3. an absolutely positioned img for the tiny version of the image, also known as LQIP(Low-Quality Image Placeholder), stretched to cover the wrapper. Data URL is usually used as the src to save HTTP requests,
  4. an absolutely positioned img for the source image, placed on top of the LQIP, initialized with opacity: 0.
.wrapper {
position: relative;
overflow: hidden;
img {
position: absolute;
width: 100%;
height: 100%;
top: 0;
bottom: 0;
left: 0;
right: 0;
object-fit: cover;
object-position: center;
.source {
opacity: 0;
transition: opacity 1s;
.loaded {
opacity: 1;

Turn it into React Component โš›

import React, { useState, useEffect, useRef } from "react";
import clsx from "clsx"; // a utility for constructing className conditionally
function LazyImage({ className, src, alt, lqip, aspectRatio = 2/3 }) {
const [loaded, setLoaded] = useState(false);
const imgRef = useRef();
useEffect(() => {
if (imgRef.current && imgRef.current.complete) {
}, []);
return (
<div className={clsx("wrapper", className)}>
<div style={{ paddingBottom: `${100 / aspectRatio}%` }} />
<img src={lqip} aria-hidden="true" />
onLoad={() => setLoaded(true)}
className={clsx("source", loaded && "loaded")}
export default LazyImage;

Let's break it down: there is a loaded state to track the loading state of the souce image, initialized to be false. A "load" event listener is added to the source img element so when it finishes loading, the state is updated and a "loaded" class name is appended to its class list which sets its opacity to 1. In cases which the source image has completely loaded before this component is mounted, the newly added "load" event listener will never fire. That's why a ref is also passed to the img element for checking its complete attribute on mount, and update the state accordingly.

A loading="lazy" attribute is added to the source img to tell the browser to load the image immediately if it is in the viewport, or to fetch it when the user scrolls near it. More about that in this web.dev article. I also added aria-hidden="true" to the LQIP img to hide it from the accessibility API.


To use this component, you'll have to generate the image LQIP and get its aspect ratio. There are libraries that help you to integrate the generation into your build process, for example, zouhir/lqip. Apparently, if you're using Cloudindary, you can create LQIP through their image transformation pipeline. But I suspect you can only get a regular URL instead of data URL or base64 so you might have to convert it yourself if you want to inline it.

In previous projects, I used sharp(a high-performance image processing module) in Next.js getStaticProps (a function that runs at build time for static generation) to help me populating those image data. Below is the function that I used:

import got from 'got';
import sharp from 'sharp';
async function generateLazyImage(src) {
const { body } = await got(src, { responseType: 'buffer' });
const sharpImage = sharp(body);
const { width, height, format } = await sharpImage.metadata();
const lqipBuf = await sharpImage
.resize({ width: 30, height: 30, fit: 'inside' })
return {
aspectRatio: width / height,
lqip: `data:image/${format};base64,${lqipBuf.toString('base64')}`,

That's it! The <LazyImage /> is a pretty simple component that I use in almost all of my projects. Let me know your thoughts and how you present images on your sites. ๐Ÿ˜‰

Please follow my Twitter account if you want to read my future posts. I promise I will figure out how to do RSS with Next.js soon...๐Ÿ”œ

Discuss on Twitter
I am redesigning my blog to integrate a CMS. (before that I used MDX to write my posts) I figured it's another good chance to learn new tech stacks. After playing with several headless CMSs, I picked Sanity.io to be used with my go-to SSG/SSR framework Next.js. I also tried out TailwindCSS to learn about what's going on with this trendy utility-first CSS. I will share my learning progress and tips I found regularly.