Introduction
In the previous part of this series, Building a Lazy Loader from Scratch in React (Part 1), 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:
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:
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:
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:
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:
Time for the final verdict.
Evidently, this width: 100%
solution does indeed solve our responsiveness issue, as illustrated below:
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 first part of this series.
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 absolute
ly 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:
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:
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...