In this blog post, we will build a custom web component called CloudinaryImage
that allows us to lazy load images from Cloudinary. Lazy loading can significantly improve the performance of web pages by loading only the images currently visible in the viewport, reducing unnecessary network requests and improving load times. I have written about a responsive/progressive lazy-loading image component for Metalsmith/Nunjucks last year, a web component will take this approach to the next level.
Introduction to the CloudinaryImage
component
The CloudinaryImage
component is designed to display a low-resolution image initially and then replace it with a high-resolution image once it becomes visible in the viewport. To achieve this, we will use the Intersection Observer API to detect when the component is in the viewport. The component will also ensure the image maintains its aspect ratio and is styled with the appropriate CSS to prevent layout shifts.
The component is used like this:
<cloudinary-image
base="<cloudinary-base-url>"
imageId="<cloudinary-image-id>"
alt="<image-alt-text>"
></cloudinary-image>
Defining Properties and Attributes
The CloudinaryImage
component accepts three attributes: base
, imageid,
and alt
. These attributes will be used to build the Cloudinary URLs and provide alternative text for the images.
The state of these attributes will be reflected in the properties of the component and the properties state will be cached in an object called props
. Changing attributes will update this props
object and all element updates will be based on the props
object.
The props
object is defined in the constructor.
this.props = {
base: "",
imageid: "",
alt: ""
};
Instead of defining getters and setters dynamically within the constructor, we'll explicitly set them in the class body. It's best practice to place getters and setters outside of the constructor. Adhering to this convention ensures our custom elements align with standards set by other elements and libraries, simplifying the process for other developers to understand and collaborate on our code.
It's important to note that we don't modify attributes when properties change. This convention aligns with typical behavior seen in HTML elements and other web components. Attributes are read once in the connectedCallback
and subsequently cached in the props
object. When properties alter, the props
object updates, triggering an appropriate component update.
// explicitly define properties reflecting to attributes
get base() {
return this.props.base;
}
set base(value) {
this.props.base = value;
this.updateImage
}
get imageid() {
return this.props.imageid;
}
set imageid(value) {
this.props.imageid = value;
this.updateImage();
}
get alt() {
return this.props.alt;
}
set alt(value) {
this.props.alt = value;
}
Updating the Image
We are using a method updateImage
to update the image. This method will be called when the component is added to the DOM and when any property changes. The method will update the aspect ratio of the image wrapper, load the low-resolution image, and start observing the component for intersection with the viewport to then load the high-resolution image.
this.updateImage = async () => {
// update aspect ratio to image wrapper
const aspectRatio = await this.getAspectRatio(this.props.base, this.props.imageid);
this.imageWrapper.style.aspectRatio = aspectRatio;
// load low resolution image
this.lowResImage.src = `${this.props.base}w_100,c_fill,g_auto,f_auto/${this.props.imageid}`;
this.lowResImage.alt = this.props.alt;
// images are only loaded when they are visible in the viewport
this.observer.observe(this);
};
Image Loading and Intersection Observer
To load the images lazily and swap the low-resolution image with the high-resolution image when it becomes visible, we'll use the Intersection Observer API.
First we load the low-resolution image in the connectedCallback
when the component is added to the DOM. When the component becomes visible, the Intersection Observer will call the loadImage
method. This method will load the high-resolution image and disconnect the Intersection Observer.
class CloudinaryImage extends HTMLElement {
constructor() {
super();
// Create an Intersection Observer to load the high-resolution image when it becomes visible
this.observer = new IntersectionObserver(this.loadImage.bind(this));
...
}
...
connectedCallback() {
this.props.base = this.getAttribute("base");
this.props.imageid = this.getAttribute("imageid");
this.props.alt = this.getAttribute("alt");
this.updateImage();
}
...
loadImage(entries, observer) {
// Load the high-resolution image when it becomes visible
if (!entries[0].isIntersecting) return;
// Disconnect the observer once the image is loaded
this.observer.unobserve(this);
// Set the source for the high-resolution image
this.highResImage.src = `${this.base}${this.getImageTransformations()}/${this.imageid}`;
this.highResImage.alt = this.alt;
...
}
...
}
Image Aspect Ratio
To prevent layout shifts when loading the images, we set the aspect ratio of the image on the container. We get the image dimensions by using the Cloudinary API so we can calculate the image aspect ratio.
async getAspectRatio(base, imageid) {
try {
// get the image properties from cloudinary
// ref: https://cloudinary.com/documentation/image_transformation_reference#fl_getinfo
const response = await fetch(`${base}fl_getinfo/${imageid}`, {
headers: { Accept: "application/json" },
});
// Check if the response status is not OK (i.e., not a 2xx status)
if (!response.ok) {
throw new Error(`Failed to fetch aspect ratio. Status: ${response.status}`);
}
const data = await response.json();
// Ensure the expected properties exist in the returned data
if (!data.input || typeof data.input.width !== "number" || typeof data.input.height !== "number") {
throw new Error("Unexpected response format from Cloudinary");
}
// image dimensions
const imageWidth = data.input.width;
const imageHeight = data.input.height;
if (imageHeight === 0) {
throw new Error("Image height is 0, cannot compute aspect ratio");
}
const aspectRatio = (Math.round((imageWidth / imageHeight) * 100) / 100).toFixed(3);
return aspectRatio;
} catch (error) {
console.error(`Error getting aspect ratio: ${error.message}`);
return "1"; // Default aspect ratio (1:1) if there's an error.
}
}
Styling the Component
The component incorporates CSS to ensure the image renders correctly within its container. Default styling uses overflow:hidden
on the image container, ensuring content doesn't spill outside the bounds of the container. Similarly, the use of object-fit: cover
on the image ensures it maintains its aspect ratio while completely filling its parent container. For this fitting to be effective, the image's height is set to 100% by default.
However, web designs are dynamic, and the default settings might not always be the best fit. If, for instance, you want the image height to adjust automatically based on its intrinsic proportions, the --image-height
CSS variable comes into play. This variable is accessible in the light DOM, enabling customization. You can set the image height to auto
using this variable.
The low-resolution image is initially blurred and then fades out when the high-resolution image has been loaded. The high-resolution image is positioned absolutely with a z-index
to place it behind the low-resolution image.
Putting It All Together
Finally, we register the custom element with customElements.define
.
customElements.define("cloudinary-image", CloudinaryImage);
Here is the code for the CloudinaryImage
component:
/**
* @name CloudinaryImage
* @description Custom element for lazy loading images from cloudinary
* @example <cloudinary-image base="<cloudinary-base-rl>" imageid="<cloudinary-image-id>" alt="<your alt text>"></cloudinary-image>
* @param {string} base - cloudinary base url
* @param {string} imageid - cloudinary image id
* @param {string} alt - image alt text
*
* - Load a low resolution image for fast loading and then replace it with a high resolution image
* once it has been loaded.
* - To prevent layout shift, the image is wrapped in a figure element with an aspect ratio
* matching the image. The aspect ratio is calculated from the image width and height which is
* fetched from cloudinary.
* - The figure element is styled with overflow: hidden and the image is styled with
* object-fit: cover. This will ensure that the image will fill the figure element
* without stretching or squashing the image.
*/
class CloudinaryImage extends HTMLElement {
constructor() {
super();
// cache the state of the component
this.props = {
base: "",
imageid: "",
alt: ""
};
// updated image
this.updateImage = async () => {
// update aspect ratio to image wrapper
const aspectRatio = await this.getAspectRatio(this.props.base, this.props.imageid);
this.imageWrapper.style.aspectRatio = aspectRatio;
// load low resolution image
this.lowResImage.src = `${this.props.base}w_100,c_fill,g_auto,f_auto/${this.props.imageid}`;
this.lowResImage.alt = this.props.alt;
// images are only loaded when they are visible in the viewport
this.observer.observe(this);
};
// Create an observer instance and load a high resolution image when the component is visible
this.observer = new IntersectionObserver(this.loadImage.bind(this));
this.shadow = this.attachShadow({ mode: "open" });
this.shadow.innerHTML = `
<style>
:host {
--image-height: auto;
}
figure {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
margin: 0;
transition: all 0.3s ease-in-out;
}
img {
display: block;
width: 100%;
height: var(--image-height);
object-fit: cover;
}
.low-res {
filter: blur(10px);
}
.low-res.remove {
transition: opacity 1s ease-in-out;
opacity: 0;
}
.high-res {
display: block;
position: absolute;
z-index: -1;
top: 0;
left: 0;
}
</style>
<figure>
<img class="low-res" src="" alt="">
<img class="high-res" src="" alt="">
</figure>
`;
this.imageWrapper = this.shadowRoot.querySelector("figure");
this.lowResImage = this.shadowRoot.querySelector(".low-res");
this.highResImage = this.shadowRoot.querySelector(".high-res");
} // end constructor
static get observedAttributes() {
return ["base", "imageid", "alt"];
}
// explicitly define properties reflecting to attributes
get base() {
return this.props.base;
}
set base(value) {
this.props.base = value;
this.updateImage();
}
get imageid() {
return this.props.imageid;
}
set imageid(value) {
this.props.imageid = value;
this.updateImage();
}
get alt() {
return this.props.alt;
}
set alt(value) {
this.props.alt = value;
}
async attributeChangedCallback(property, oldValue, newValue) {
if (!oldValue || oldValue === newValue) return;
switch (property) {
case "base":
this.props.base = newValue;
break;
case "imageid":
this.props.imageid = newValue;
break;
case "alt":
this.props.alt = newValue;
break;
}
this.updateImage();
}
connectedCallback() {
this.props.base = this.getAttribute("base");
this.props.imageid = this.getAttribute("imageid");
this.props.alt = this.getAttribute("alt");
this.updateImage();
}
disconnectedCallback() {
this.observer.unobserve(this);
}
/**
* Get the image transformation parameters
* @returns {string} image transformation parameters
* @private
* @example const imageParams = getImageTransformations();
* @see https://cloudinary.com/documentation/image_transformations
*/
getImageTransformations() {
// get width of figure parent element
// Note: do this after shadow.append otherwise offsetWidth will be 0
const parentWidth = this.offsetWidth;
// get device pixel ratio
const pixelRatio = window.devicePixelRatio || 1.0;
// build transformation parameters for the cloudinary image url
const imageParams = `w_${100 * Math.round((parentWidth * pixelRatio) / 100)},f_auto`;
return imageParams;
}
/**
* Get the aspect ratio of the image
* @returns {number} aspect ratio
* @private
* @async
* @example const aspectRatio = await getAspectRatio();
*/
async getAspectRatio(base, imageid) {
try {
// get the image properties from cloudinary
// ref: https://cloudinary.com/documentation/image_transformation_reference#fl_getinfo
const response = await fetch(`${base}fl_getinfo/${imageid}`, {
headers: { Accept: "application/json" },
});
// Check if the response status is not OK (i.e., not a 2xx status)
if (!response.ok) {
throw new Error(`Failed to fetch aspect ratio. Status: ${response.status}`);
}
const data = await response.json();
// Ensure the expected properties exist in the returned data
if (!data.input || typeof data.input.width !== "number" || typeof data.input.height !== "number") {
throw new Error("Unexpected response format from Cloudinary");
}
// image dimensions
const imageWidth = data.input.width;
const imageHeight = data.input.height;
if (imageHeight === 0) {
throw new Error("Image height is 0, cannot compute aspect ratio");
}
const aspectRatio = (Math.round((imageWidth / imageHeight) * 100) / 100).toFixed(3);
return aspectRatio;
} catch (error) {
console.error(`Error getting aspect ratio: ${error.message}`);
return "1"; // Default aspect ratio (1:1) if there's an error.
}
}
/**
* Load the initial high-res image
* create high resolution image
* @param {Array} entries
* @param {Object} observer
* @returns {void}
*/
loadImage = (entries, observer) => {
if (!entries[0].isIntersecting) return;
// disconnect observer once image is loaded
this.observer.unobserve(this);
const imageParams = this.getImageTransformations();
// high res image source
this.highResImage.src = `${this.props.base}${imageParams}/${this.props.imageid}`;
this.highResImage.alt = this.props.alt;
// once the hi-res image has been loaded, fade-out the low-res image and remove it
this.highResImage.onload = () => {
this.lowResImage.classList.add("remove");
this.lowResImage.addEventListener("transitionend", () => {
this.lowResImage.remove();
});
};
};
}
// register component
customElements.define("cloudinary-image", CloudinaryImage);
Conclusion
In this blog post, we created a custom web component called CloudinaryImage
that allows us to lazy load images from Cloudinary. The component uses the Intersection Observer API to load high-resolution images only when they become visible in the viewport, reducing page load times and improving performance.
The complete source code for the CloudinaryImage
component can be found in this GitHub repository.