Every developer knows the benefits of code reusability. It saves time and ensures consistency..
Web components (WC) allow you to create reusable and interoperable custom elements that are encapsulated, so they’re more easily integrated into other projects regardless of framework. Web components work with all major browsers, though with varying degrees of support.
In this blog post, I’ll demonstrate how you can create a reusable and lightweight web component for viewing images from Cloudinary with support for transformations and lazy loading.
Although having web components as a standard for your browsers is great, frameworks and libraries provide many more niceties. Lit helps alleviate this by adding additional features such as HTML templates, embedded JavaScript expressions, and easier attribute binding, streamlining the web development experience.
Let’s make a barebones web component with Lit that can render a Cloudinary image. We’ll add features to it one at a time throughout this blog post. This initial version will only render images and nothing else.
First, let’s install Lit using npm
:
$ npm i lit
With Lit installed, we’ll make our web component live in its own file called cdl-image.js
(we’re effectively making a minimal version of CldImage
):
import { LitElement, css, html, nothing } from "./node_modules/lit/index.js";
export class CdlImage extends LitElement {
static styles = css`
:host {
display: inline-block;
}
`;
static properties = {
baseUrl: { type: String },
src: { type: String },
alt: { type: String },
};
render() {
return html`
<img
src=${this.src ? this._buildImageUrl() : nothing}
alt=${this.alt ?? nothing}
/>
`;
}
/**
* Builds an image URL to Cloudinary using the custom element attributes.
*/
_buildImageUrl() {
// Strip trailing slash if one exists.
const baseUrl = this.baseUrl.endsWith("/")
? this.baseUrl.slice(0, -1)
: this.baseUrl;
return `${baseUrl}/${this.src}`;
}
}
window.customElements.define("cdl-image", CdlImage);
Code language: JavaScript (javascript)
Then, to utilize the web component, we’ll create an index.html file to fetch that script and use the component:
<!DOCTYPE html>
<html>
<head>
<title>Cloudinary Image Web Component</title>
<script type="module" src="./boilerplate.js"></script>
</head>
<body>
<main>
<h1>Cloudinary Image Web Component Demo</h1>
<cdl-image
baseUrl="https://res.cloudinary.com/demo"
src="cld-sample-5"
alt="a white tennis shoe with a blue insole"
></cdl-image>
</main>
</body>
</html>
Code language: HTML, XML (xml)
For baseURL
, you’ll want to use your own if you have your own image assets. Otherwise, demo
works fine. With that, you have everything you need to render an image.
Build systems are out of scope for this blog post, but I’d recommend using Vite to get this example working, though you’re free to use whatever build tool you like:
$ npm i -D vite
Run Vite by executing npx vite
in the root of your project, which will make the example accessible on http://localhost:5173/
. Navigating there should result in a picture of a large tennis shoe.
Let’s add support for the most common image transformations, that being width, height, format, crop, zoom, and gravity.
First, we need to add those transformation properties as fields on our web component:
static properties = {
baseUrl: { type: String },
src: { type: String },
alt: { type: String },
width: { type: Number },
height: { type: Number },
format: { type: String },
crop: { type: String },
zoom: { type: Number },
gravity: { type: String },
};
Code language: JavaScript (javascript)
Our URL-building method will have to be updated to use these transformations as well. We can modify it to check if they’ve been set on the component, and if so, add them to a comma-delimited list of transformations in the URL:
/**
* Builds an image URL to Cloudinary using the custom element attributes.
*/
_buildImageUrl() {
const transforms = [];
// Add attribute specified transformations to the URL.
if (this.width != null) {
transforms.push(`w_${Math.round(this.width)}`);
}
if (this.height != null) {
transforms.push(`h_${Math.round(this.height)}`);
}
if (this.format != null) {
transforms.push(`f_${this.format}`);
}
if (this.crop != null) {
transforms.push(`c_${this.crop}`);
}
if (this.zoom != null) {
transforms.push(`z_${this.zoom}`);
}
if (this.gravity != null) {
transforms.push(`g_${this.gravity}`);
}
// Strip trailing slash if one exists.
const baseUrl = this.baseUrl.endsWith('/') ?
this.baseUrl.slice(0, -1) :
this.baseUrl;
return `${baseUrl}/${transforms.join(',')}/${this.src}`;
}
Code language: JavaScript (javascript)
Also, don’t forget to pass width and height to the img
element as attributes, and with that, you can now use these transformations. Try them out!
The magic behind lazy loading images only when the user can see them comes from IntersectionObserver
. This class allows a callback to be called whenever an element comes into view in the viewport. It supports a bit more options than that, but we don’t need to concern ourselves with those options for our specific use case.
First, let’s define some new state that we’ll need:
static properties = {
...
_observerInitialized: { type: Boolean, state: true },
_hasBeenInViewport: { type: Boolean, state: true },
_imageLoaded: { type: Boolean, state: true },
};
Code language: JavaScript (javascript)
We should create the IntersectionObserver
whenever our custom element gets connected to the DOM like so:
connectedCallback() {
super.connectedCallback();
this._observer = new IntersectionObserver(this._onImageObserved.bind(this));
this._observer.observe(this);
}
Code language: JavaScript (javascript)
Note that the callback passed into it is a method called _onImageObserved
. We’ll define that here in a bit. Passing this into the observe method call tells the observer that we’re waiting for our custom element to become visible on the screen.
Let’s also add some cleanup code that removes the observer whenever the element is removed from the DOM so we don’t accidentally leave the observer in memory after the image element is removed.
disconnectedCallback() {
super.disconnectedCallback();
if (this._observer != null) {
this._observer.unobserve(this);
this._observer = null;
}
}
Code language: JavaScript (javascript)
The _onImageObserved
is where the magic happens.
/**
* @type {IntersectionObserverCallback}
*/
_onImageObserved(entries) {
if (entries.some((entry) => entry.isIntersecting)) {
this._observer.unobserve(this);
this._observer = null;
this._hasBeenInViewport = true;
}
this._observerInitialized = true;
}
Code language: JavaScript (javascript)
This function iterates through all the entries in the observer (we should only have one) and checks if our image element has appeared on the screen yet. This callback gets run both when the observer is first created and when an intersection happens. That’s what we need to check the isIntersecting
property.
We also set some flags so we can apply CSS classes dynamically, and also to assist with cleaning up the observable as we no longer need it for this specific image.
The URL builder also needs to be updated so that we fetch a lower resolution version if it’s called before the intersection has happened, which will happen at page load.
const LAZY_LOAD_SIZE = 32;
...
/**
* Builds an image URL to Cloudinary using the custom element attributes.
*/
_buildImageUrl() {
const transforms = [];
let width = this.width;
let height = this.height;
// Calculate a shrunken width and height to send to Cloudinary if the
// image hasn't been in the viewport yet.
if (!this._hasBeenInViewport) {
if (width != null && height != null) {
const ratio = LAZY_LOAD_SIZE / this.width;
width = LAZY_LOAD_SIZE;
height = this.height * ratio;
} else if (height != null) {
height = LAZY_LOAD_SIZE;
} else {
width = LAZY_LOAD_SIZE;
}
}
// Add attribute specified transformations to the URL.
if (width != null) {
transforms.push(`w_${Math.round(width)}`);
}
if (height != null) {
transforms.push(`h_${Math.round(height)}`);
}
...
}
Code language: JavaScript (javascript)
If we set either just the width or the height we can replace those and Cloudinary will shrink the other dimension to keep the aspect ratio; however, if both are being set, then we need to calculate that ourselves.
As you may already know, most lazy-loaded images have a smooth transition when downloading the full-resolution image. We can implement one of these transitions by adding a few CSS classes and a CSS keyframe animation that plays whenever the image is revealed.
The CSS for creating the blur effect is easy and can be accomplished by changing the filter property.
static styles = css`
:host {
display: inline-block;
}
.cdl-image__img {
display: inline-block;
max-width: 100%;
}
.cdl-image__img--hidden {
opacity: 0;
}
.cdl-image__img--seen {
animation: blurTransition 0.2s;
}
.cdl-image__img--lowres {
filter: blur(6px);
}
@keyframes blurTransition {
0% { filter: blur(6px); }
100% { filter: blur(0); }
}
`;
Code language: JavaScript (javascript)
With that, the logic for changing the CSS classes dynamically can be done with a Lit directive known as classMap
. This directive has an object passed into it that maps CSS classes with conditions that, if true, will apply the respective CSS classes.
<img
class=${classMap({
"cdl-image__img": true,
"cdl-image__img--lowres": !this._imageLoaded,
"cdl-image__img--hidden": src == null,
"cdl-image__img--seen": this._hasBeenInViewport && this._imageLoaded,
})}
src=${src ? this._buildImageUrl() : nothing}
alt=${this.alt ?? nothing}
width=${this.width ?? nothing}
height=${this.height ?? nothing}
@load=${() => {
if (this._hasBeenInViewport) {
this._imageLoaded = true;
}
}}
/>
Code language: JavaScript (javascript)
The cdl-image__img--hidden
class prevents the image from flicking the alt text before the lazy loaded thumbnail finishes downloading. The cdl-image__img--lowres
class applies the blur only when the full images hasn’t loaded yet, and finally the cdl-image__img–seen class applies when the image has both been fully loaded and scrolled into view, which is exactly when we want to remove the blur.
With all of that you should have a functional Cloudinary image component that supports transformations and lazy loading! You can test this by adding enough HTML content to your example such that the image component goes off-screen, and scroll it into view. You can check the network tab in your browser’s devtools
to confirm that the larger image loads when you do this.
When it comes to publishing your web component so that you can use it in your projects, I’d recommend following the Lit project’s advice in their documentation. To summarize a few major points made, keep it simple. Don’t include polyfills, minify your components, and make sure to generate sourcemaps
if you use TypeScript so that consumers of the component get proper type completion. The project utilizing your components will already have its own build tooling and it will be a lot easier to use your component if you leave it mostly as-is when distributing it.
Web components are a great choice for distributing components that must be reusable and interoperable between multiple frameworks, and with libraries like Lit, they aren’t difficult to write. From upload and transformation to optimization and delivery, the Cloudinary platform helps automate your entire visual asset lifecycle. Sign up for free today.
If you found this blog post helpful and want to discuss it in more detail, head over to the Cloudinary Community forum and its associated Discord.