Building a Lazy Loader from Scratch in React (Part 2)

Stay updated with our latest articles:

Introduction

In the previous part of this series, , we saw the setup of a very basic lazy loader using React and the modern IntersectionObserver API.

Just for a quick recap, we crafted a LazyImage component taking a src prop and applying it to a descendant <img> element only when the underlying element is about to show up in the viewport (by merely touching it).

As you can recall, the created lazy loader was exceptionally basic, void of many good things.

Now, in this part of the series, we'll be extending that basic lazy loader to prevent it from creating CLS issues (more on what that means below) and make it responsive (in the sense of responsive designing).

Both of these things are absolutely crucial for the success of any lazy loader:

  • CLS issues lead to a poor user experience, and so we should strive hard to reduce such issues wherever possible.
  • Creating responsive websites is even more crucial as a large part of web surfers comes from mobile phones whom we don't want to give broken-off webpages.

In this article, we'll be covering a couple of features of CSS — in particular padding-bottom (or equivalently, padding-top) and aspect-ratio, and how they'll come in handy for our lazy loader — and just a ton full of fruitful information in general.

So without any further ado, let's begin the learning.

The problem of CLS

Let's start by opening the example webpage that we created with our elementary lazy loader in the previous installment of this series:

Live Example

First off, we note that the actual height of the loaded image isn't 100px, which is the value that we have given to the min-height property of the lazy image's <div>.

When the image finally loads, it increases the height of the <div> to 424px, which is the original height of the image. This is simply because the image is larger than 100px and, likewise, gets the <div> to be stretched along with it to that very height (which is quite a usual behavior).

Anyways, if we put some content after the lazy image in our webpage, this height change, as the lazy image gets loaded, will cause that content to shift down.

This shifting is considered to be a major user experience issue, one that Google recognizes and gives a special place in its Core Web Vitals.

Google calls it Cummulative Layout Shift, or simply CLS.

To quote it from web.dev's definition of CLS:

CLS is a measure of the largest burst of layout shift scores for every unexpected layout shift that occurs during the entire lifespan of a page.

CLS issues arise because of a multitude of reasons. Perhaps, the most common is NOT giving explicit dimensions to images.

In the example above, the rendered <img> element didn't have any explicit width or height set on it; hence, we faced the layout shift problem because of it.

In order to prevent an image from producing a layout shift, we just need to give it a width and height.

Obviously, the first approach that comes to mind, a naive one though, is to set the width and height attributes on the <img> element in order to preset it to a fixed width and height. Later on, when the image finally loads, it'll load within that box that the <img> element gets to form because of these attributes.

Pretty basic, right?

To restate it: in order to keep our lazy loader from producing a high CLS (which is bad), we just ought to define an explicit width and height for every lazy image.

And for now, we're giving a try to doing so using the width and height attributes on the <img> element rendered by LazyImage.

Let's do this for the example that we saw above and see the effect produced.

First up, we'll modify LazyImage, incorporating two new props in it, width and height, which get applied to the <img> element:

import { useState, useEffect, useRef } from 'react';

function LazyImage({ src, width, height }) {
   const [srcExists, setSrcExists] = useState(false);
   const divRef = useRef();

   useEffect(() => {
      divRef.current.setSrcExists = setSrcExists;
      LazyImage.io.observe(divRef.current);
   }, []);

   return (
      <div ref={divRef} className="lazy">
         <img width={width} height={height} src={srcExists ? src : null}/>
      </div>
   );
}

Next up, we'll clear the min-height style from .lazy in the CSS and even its background-color, since we can now clearly visualize the image prior to its load on the webpage:

.lazy {
min-height: 100px;
background-color: #ddd;
}

Instead, we'll apply background-color directly to the <img> element because that's what we ought to visualize:

.lazy img {
   background-color: #ddd;
}

With this, we now need to provide these props in our demo webpage:

import LazyImage from './LazyImage';

function App() {
   return (
      <>
         <h1>Demonstrating lazy loading</h1>
         <p style={{ marginBottom: 1000 }}>Slowly scroll down until you bring the lazy image into view.</p>
         <LazyImage width={640} height={424} src="/image.jpg" />
      </>
   );
}

export default App;

The image that we used in the example is 640px wide and 424px high, hence the values of the width and height attributes as shown above.

Let's see the result:

Live Example

Good, but not perfect yet!

We solved one problem but that at the cost of another one.

The problem with absolute width

In our example, the image's width is 640px and we've explicitly set the width attribute of the <img> element to that very value. Said another way, we've set an absolute width on our lazy image.

The consequence of this approach is that when the viewport's width gets less than 640px, the image starts to overflow out of it, leading to a horizontal scroll bar on the webpage.

This is the problem.

Shown below is an illustration for the webpage that we created above:

Lazy image overflowing out of the viewport.
Lazy image overflowing out of the viewport.

As far as CLS is concerned, yes, our approach of applying the width and height attributes does solve it. But as far as responsiveness is concerned, this approach is raising some alarm bells for us to give attention to!

We can't continue using an absolute width on a lazy image as it keeps the lazy loader devoid of being responsive.

Let's think about a better solution.

First try at responsiveness

If we ask you to give us some suggestions on how to make this lazy loader responsive, what will you pitch in? Give it a minute of thought.

Assuming you've given it a try, let's now think on it together...

The very first solution that comes to mind to make a lazy image responsive is to give the <img> a 100% value for the width CSS property.

As you'd know, this will get the image's width to be precisely as much as the width of its container. The container is the <div> element whose width itself turns out to be as much as the available width of its container (which in our example is the <body> element). So clearly, this is going to be responsive.

At the same time, we'll also need to let go of the height attribute currently set on the <img> element so that our image doesn't get distorted.

With the application of width: 100%, the image's width will obviously be changing with the viewport's width, and with that, we clearly want the height to be changing as well, in order to maintain the original aspect ratio of the image. When we explicitly specify an absolute height, this can NOT happen; the image's height will remain static throughout, leading to distorted images as the width changes.

Frankly speaking, the last thing you'll want on your website is distorted images.

Anyways, let's see this solution in action.

Here's the LazyImage component, with its width and height props removed since we don't need them, at least for now:

import { useState, useEffect, useRef } from 'react';

function LazyImage({ src, width, height }) {
   const [srcExists, setSrcExists] = useState(false);
   const divRef = useRef();

   useEffect(() => {
      divRef.current.setSrcExists = setSrcExists;
      LazyImage.io.observe(divRef.current);
   }, []);

   return (
      <div ref={divRef} className="lazy">
         <img width={width} height={height} src={srcExists ? src : null}/>
      </div>
   );
}

Besides this, the styles for .lazy img in the CSS will be as follows:

.lazy img {
   background-color: #ddd;
width: 100%; }

Notice the addition of the width: 100% style in there — this will look after making our lazy loader responsive.

As always, let's try the solution:

Live Example

The first thing to realize in the example is that the image is being stretched to fill up the viewport, a courtesy of the width: 100% style that we just applied, even though its original width is 640px.

Before we proceed any further, let's change this.

And the simplest way to do so is to set a maximum width on the image. This maximum width will simply be the original width of the image.

If the image's container is larger than the maximum width of the image (which is original width), the image renders at its maximum width. Otherwise, the image renders at the width of its container.

Here's what LazyImage becomes now, bringing back only the width prop:

import { useState, useEffect, useRef } from 'react';

function LazyImage({ src, width }) {
   const [srcExists, setSrcExists] = useState(false);
   const divRef = useRef();

   useEffect(() => {
      divRef.current.setSrcExists = setSrcExists;
      LazyImage.io.observe(divRef.current);
   }, []);

   return (
      <div ref={divRef} className="lazy">
         <img style={{ maxWidth: width }} src={srcExists ? src : null}/>
      </div>
   );
}

The maxWidth style property is set on the <img> element. If you want to, you can instead set it on the <div> element as well.

Let's now witness the solution:

Live Example

Time for the final verdict.

Evidently, this width: 100% solution does indeed solve our responsiveness issue, as illustrated below:

Reponsive lazy image fitting into the viewport.
Reponsive lazy image fitting into the viewport.

But unfortunately, it brings back the CLS issue!

When the height isn't explicitly specified, despite the fact that we have width: 100% set, we get back to point zero in the drive to make our lazy loader better than where we left it in the .

How? Here's how.

The width: 100% style only gets the image to fill up the entire space of its container; its corresponding height is still not known until the point the image loads. Henceforth, when that happens, that is, when the image finally loads, we get a layout shift. Oh no.

In short, we can't use just width: 100%. Maybe we need something additional with it, or maybe a completely different approach.

Let's think further.

Second try at responsiveness

As we already know, by virtue of width: 100%, the width of the image changes as the viewport's width changes. With this, trivially, its height changes as well.

Likewise, if we have to explicitly specify the height, we need to use something that can change with the width.

Setting the height to an absolute value is of no use since that way the image's height would remain the same regardless of its width.

Can you think of any CSS property to help us here?

Using padding-bottom

Well, there's an obscure usage of padding-bottom (or equivalently, of padding-top) that can come in handy here.

Recall that we seek a way to specify the height of a lazy image relative to its width. And padding-bottom can help us with just this very thing.

When padding-bottom is set to a percentage value, for e.g 50%, it is resolved relative to the width of the container of the underlying element.

So, let's say, the width of an element's container is 500px, and the height of the element itself is 0px as there's no content inside it. Setting padding-bottom to 20% on this element would make its height equal to 100px.

Coming back to our lazy loader, in order to employ the potential of padding-bottom to prescribe a height to a lazy image, and thus make it responsive, we need to know the original width and height of the image in advance. Using the width and height, we compute an appropriate value for padding-bottom, as a percentage.

The formula is absolutely simple: (height / width) x 100

But where to apply this property? Should it go on the <img> element or on its containing <div>?

We can't set it on the <img> element as it's meant to showcase an image; having padding-bottom on it will produce extra spacing below the rendered image, which is clearly undesirable.

This leaves us with the containing <div> element. But there's an issue that we face here. If we set padding-bottom on <div>, it will be resolved relative to its container's width, not the width of the <div> itself (as we just learnt above). This will only work fine when the image spans the entire viewport width.

But as we saw above, the image won't always be as large as the viewport, and in that case we'd want to showcase it in its original width.

Therefore, we want padding-bottom to act relative to this width of the image.

How to solve this problem?

Well, we just need to bring in another <div> encapsulating our existing .lazy element. This <div> will get the application of the max-width property on it.

Thereafter, the padding-bottom property will be applied to .lazy; it'll get resolved based on the width of its container which is only as much as the original width of the underlying image.

Perfect!

Let's now see an implementation of all this long discussion.

Here's our LazyImage component, with both the props width and height brought back, and a new <div> container introduced:

import { useState, useEffect, useRef } from 'react';

function LazyImage({ src, width, height }) {
   const [srcExists, setSrcExists] = useState(false);
   const divRef = useRef();

   useEffect(() => {
      divRef.current.setSrcExists = setSrcExists;
      LazyImage.io.observe(divRef.current);
   }, []);

   return (
      <div style={{ maxWidth: width }}>
         <div
      style={{ paddingBottom: `${height / width * 100}%` }}
            ref={divRef}
            className="lazy"
         >
            <img src={srcExists ? src : null}/>
         </div>
      </div>
   );
}

The width and height props are processed by the component to produce a corresponding value for the padding-bottom property to be applied to the <div>.

Now, since padding-bottom gives inner spacing to the <div>, if we need the <img> inside it to render correctly, without showing up after this whole padding, we need to position it absolutely and get its width and height to be equal to the width and height of its container.

The following CSS takes care of this:

.lazy {
   position: relative;
}

.lazy img {
   background-color: #ddd;
   position: absolute;
   top: 0;
   left: 0;
   width: 100%;
   height: 100%
}

Also, notice that we've shifted the background-color style from .lazy img to .lazy.

Here's our demo webpage's code:

import LazyImage from './LazyImage';

function App() {
   return (
      <>
         <h1>Demonstrating lazy loading</h1>
         <p style={{ marginBottom: 1000 }}>Slowly scroll down until you bring the lazy image into view.</p>
         <LazyImage width={640} height={424} src="/image.jpg" />
      </>
   );
}

export default App;

See how we pass on the original width and height of the image to the LazyImage component instance in the width and height props, respectively.

Let's now run the example and see the output:

Live Example

Yup, it's flawless.

As desired, the CLS issue hasn't come back because our image's width and height has been predefined in the code. And most importantly, our image is responsive too, without any kind of distortions.

Using aspect-ratio

Besides padding-bottom, we can also go with the more recent aspect-ratio property in CSS.

aspect-ratio controls the height of an element based on its width. For example, if aspect-ratio is set to 16/9 (a common ratio), and if the width of the element is 160px, its height would be 90px.

Basic stuff, right?

To use aspect-ratio in our lazy loader in place of padding-bottom, we still need the width and height props. However, we don't need the new <div> container that we introduced in the section above for padding-bottom.

That's precisely because aspect-ratio resolves relative to the very element on which it's applied, unlike padding-bottom which resolves relative to the parent container of the element.

Here's the definition of LazyImage when using aspect-ratio:

import { useState, useEffect, useRef } from 'react';

function LazyImage({ src, width, height }) {
   const [srcExists, setSrcExists] = useState(false);
   const divRef = useRef();

   useEffect(() => {
      divRef.current.setSrcExists = setSrcExists;
      LazyImage.io.observe(divRef.current);
   }, []);

   return (
      <div
   style={{ aspectRatio: `${width} / ${height}`, maxWidth: width }}
         ref={divRef}
         className="lazy"
      >
         <img src={srcExists ? src : null}/>
      </div>
   );
}

Both the aspect-ratio and max-width CSS properties are applied to the .lazy element.

Let's now try the lazy loader:

Live Example

Works flawlessly.

It's worth noting here that because aspect-ratio is a relatively recent property in CSS as compared to padding-bottom, it doesn't enjoy as rich of a browser compatibility chart as padding-bottom.

So, if you're aiming for a browser-compatible option, we'd recommend you to err on the side of padding-bottom, for it's a fairly well supported property in many browsers.

Getting the width and height of images

Before we end this discussion, we'd like to shed some light on the width and height props of our LazyImage component and where exactly do we get them for an image.

First off, note that JavaScript can NOT help us get these natural dimensions of an image, at least not when it hasn't been loaded — recall that we need the dimensions of the image right from the beginning.

There is only one path to take here: get the image's dimensions from the underlying file system and pass on the obtained values respectively into the width and height props of LazyImage.

This is the only way to prevent a network round trip to the end server just to get to know of the natural dimensions of an image.

Next steps

At this point, we have significantly improved our lazy loader from where we left it in the first part of this series.

We've now successfully made the lazy loader not pose any CLS problems (as was the case earlier when we didn't have custom dimensions set on lazy images) and also made is responsive.

As you'd agree, this required quite some effort on our side.

In the next part of this series, we'll be adding another feature to our lazy loader, one that'll make it look way more professional that it currently is. That feature is of a fade-in effect, given as lazy images load into view.

But for now, let's take a break, for part 3 is to be continued...

Never miss an article

Follow Codeguage on either of the following channels and stay updated with every latest blog article that we publish.

Improve your web development knowledge, today!