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.
Hi, Dev.to Developers! Excited to be part of the DEV Community and this is my first post.
First, the barebone - HTML/CSS ๐ฆด
Usually, a lazing loading image consists of 4 HTML Elements:
<div class="wrapper">
<div style="padding-bottom:76%;"></div>
<img
src="https://images.unsplash.com/photo-1518991791750-044b923256f0?fit=crop&w=25"
/>
<img
src="https://images.unsplash.com/photo-1518991791750-044b923256f0?fit=crop&w=1200"
class="source"
/>
</div>
- a relatively positioned wrapper
div
, - 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%, - 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, - an absolutely positioned
img
for the source image, placed on top of the LQIP, initialized withopacity: 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) {
setLoaded(true);
}
}, []);
return (
<div className={clsx("wrapper", className)}>
<div style={{ paddingBottom: `${100 / aspectRatio}%` }} />
<img src={lqip} aria-hidden="true" />
<img
loading="lazy"
src={src}
alt={alt}
ref={imgRef}
onLoad={() => setLoaded(true)}
className={clsx("source", loaded && "loaded")}
/>
</div>
);
}
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.
Also, 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.
Usage
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'; // HTTP request library for Node.js
import sharp from 'sharp';
sharp.cache(false);
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' })
.toBuffer();
return {
src,
aspectRatio: width / height,
lqip: `data:image/${format};base64,${lqipBuf.toString('base64')}`,
};
}
That's it! This <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... (Updated on Jun25, 2020: there is RSS feed for my blog now. โ )
React Summit is coming back on October 15-16. There'll be speakers like Kent C. Dodds, Max Stoiber, Sara Vieira, Sharif Shameem.
Register for free before September 20: https://ti.to/gitnation/react-summit?source=REFURCR-1.
Top comments (2)
Ah, how awesome! Thank you for this post โ I need to implement this in a small project I built to help students understand lifecycle methods.
Thanks! Wow, that's my first time to see such an analogy. Interesting!๐