Skip to main content

Erase CLS by Automatically Optimizing Images with Vite

I love Vite, not only because it's fast, but also because of its amazing plugin system.

At its core, a Vite plugin lets you edit, on the fly, any file that is imported, whether it exists or not, in your project.

Once you understand that, everything becomes possible. Everything as a Vite plugin!


For years now, I've been building content-first websites where the content is written in Markdown. I also love using images to illustrate my content.

However, images are tricky. To improve performance, you want to lazy‑load them, but lazy loading inevitably causes Cumulative Layout Shift (CLS) if you don't reserve space. To reserve space, the browser needs to know the image dimensions beforehand. But you don't always want the same dimensions everywhere, so for each image, you need its dimensions for the browser to reserve the right amount of space.

Beyond that, images can quickly become large, and hosting them in your repository isn't ideal. So you might want to transfer them to a dedicated bucket. This means they will be served from a different domain, which, combined with lazy loading, even with the correct dimensions, will leave a blank space until they are fully loaded.

Example of Cumulative Layout Shift (CLS) issue when lazy-loading an image without reserved space.

This is a solvable problem, but it requires a lot of manual work. I hate manual and repetitive work that can be automated!

!NOTE This article is dedicated to HTML content. Another article covers how to handle Markdown content using Markdown Exit.

Vite to the rescue

The first question that comes to mind is: How do you know whether a Vite plugin is the right tool for the job?

This is an important question. I could just give you the solution, but you won't be able to apply a similar approach to your own problems. Sadly, most tutorials miss this step.

Let's analyze the problem by looking at its inputs and outputs.

The input is probably a raw HTML snippet like this one:

<div>
  <img src="/path/to/image.jpg" alt="An image">
</div>

The output should be an optimized HTML snippet like this one:

<div>
  <img
    src="https://cdn.example.com/path/to/image-optimized.jpg"
    alt="An image"
    width="600"
    height="400"
    loading="lazy"
    style="background-image: url('data:image/svg+xml;base64,...'); background-size: cover;"
  >
</div>

Vite is built around a pipeline that processes files as they're imported. This means we can hook into the pipeline, detect when the code contains an image, and transform it accordingly.

Our use case fits perfectly with Vite's capabilities. Vite plugins can have side effects on the imported files. They unlock really powerful use cases like automatic image compression, resizing, ...

!NOTE This article assumes images are available locally. Handling remote images won't be covered here, but I'll give a few hints at the end of the article.

Building the plugin

What's the plan? It's great to have an idea of what we want to achieve, but how do we get there?

We need to hook into Vite's pipeline and look for Vue templates. Then, we need to find all <img> tags, extract their src attributes, load the images, get their dimensions, generate the blurred placeholder, and finally replace the original <img> tags with optimized ones.

!NOTE I'm using Vue for all my projects, but the same approach can be applied to any frontend framework supported by Vite.

Sounds easier than it is, especially when you don't know where to start and which tool to use.

To handle all the image processing, I use unpic. It's a fantastic set of primitives to handle everything related to images. To manipulate code and generate source maps, I use MagicString.

That's all we need!

Now, we can create a Vite plugin that does exactly what we want.

!NOTE You can use a Vite + Vue project as a starting point. The source code of the final plugin is available on GitHub.

In the vite.config.ts file of our project, let's first create a plugin:

vite.config.ts
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    vue(),
    (() => {
      return {
        name: 'unpic',
      }
    })()
  ],
})

At a minimum, a Vite plugin must have a name. Our plugin is an IIFE (Immediately Invoked Function Expression). This allows us to have a context to store state without relying on global variables.

Then, we need to hook into the right part of the pipeline. There are a lot of hooks available, but in practice, the most useful one is transform. This hook is called for each file that is imported in the project. It receives the file's code and its id (path). We only want to process Vue files, so we can filter by file extension.

vite.config.ts
(() => {
  return {
    name: 'unpic',
    async transform(code, id) {
      if (!id.endsWith('.vue'))         return

      // Some magic will happen here

      return {
        code,
        map: null,
      }
    }
  }
})()

But we can make it even more performant. Calling our plugin on every file is unnecessary, especially with Rolldown, where the communication between Rust and JavaScript incurs a small overhead. To avoid that, we can use some filters to only process files that match certain criteria. In our case, we only want to process Vue files.

vite.config.ts
(() => {
  const imgTagRegex = /<img\s[^>]*src=["']([^"']+)["'][^>]*>/g
  return {
    name: 'unpic',
    enforce: 'pre',
    transform: {
      filter: {
        id: /\.vue$/,        code: imgTagRegex,      },
      async handler(code) {},
    }
  }
})()

This filter ensures that our plugin only runs on Vue files that contain at least one <img> tag. So much better!

Profile picture of Estéban

Thanks for reading! My name is Estéban, and I love to write about web development and the human journey around it.

I've been coding for several years, and I'm still learning new things every day. I share what I learn because I would have appreciated clear and complete resources when I started programming.

If you have a question or want to chat, leave a comment below or reach out through my social profiles.

I hope you learned something useful. Share the article, leave a comment, or add a reaction if you did. Support my work on GitHub.

Follow me

Estéban Soubiran

Software engineer, conference speaker, and open source enthusiast.

What do you think?

Discussions

Loading discussions...

Add a comment

Checking your session...