Create a lazy-loading image component for faster Vue.js apps

Create a lazy-loading image component in Vue.js that speeds up our apps, has zero dependencies, and works as a drop-in replacement for an HTML image tag.

Before we get started, let’s first understand what we are building. The goal is to create a Vue.js component to display an image, but it should only download that image when it is in the viewport. In other words, if we have a large list of images, only the ones the user actually needs to see will be downloaded. This will speed up our apps.

Here’s the goals:

  • Should function as a drop-in replacement for <img> tags
  • Lazy-load images when they scroll into view
  • Shows a placeholder while the image is downloading
  • No external dependencies

Basic initial template

We know we will need an <img> tag, so let’s start there. For accessibility purposes, images should always have an alt attribute, even if it’s empty. We can easily pull the alt attribute from the $attrs or fall back to an empty string.

<template>
  <img :alt="$attrs.alt || ''" />
</template>

Tiny transparent placeholder

Now, something we need to fix is that we don’t actually want to set the image src attribute right away because that would cause it to download immediately.

Instead, what we want is a tiny transparent PNG that we can use as a placeholder for the image src. Using a placeholder instead of an empty value will cause the image to take up space on the page while it waits to download the real image.

For this to work properly, we need to hijack the src attribute by expecting it as a prop. We also need to tap into the <img> width and height attributes to create a tiny transparent PNG with an HTML canvas, and export it as a computed property called dataUrl.

<template>
  <img
    :src="dataUrl"
    :alt="$attrs.alt || ''"
  />
</template>

<script>
export default {
  props: {
    src: {
      type: String,
      required: true,
    },
  },

  computed: {
    dataUrl() {
      const { width, height } = this.$attrs
      if (!width || !height) return ""

      // create a tiny png with matching aspect ratio as img
      const w = 100
      const canvas = document.createElement("canvas")
      canvas.width = w
      canvas.height = (height / width) * w

      return canvas.toDataURL()
    },
  },
}
</script>

Alright, so we’ve got a transparent image when the component lands on the page. Now we need to figure out how to set our src attribute to the real image when the component is in view.

For that, we will hook into the mounted lifecyle event and use Intersection Observer. Before we get to that, however, we need to do a little refactor.

Keeping expected image behavior

We need a <div> to wrap our <img> (This will make more sense later). Divs and images behave slightly different, so we also nee a little bit of style to make sure it still behaves like a native <img>.

<template>
  <div class="app-img">
    <img
      :src="dataUrl"
      :alt="$attrs.alt || ''"
      v-bind="$attrs"
    />
  </div>
</template>

<script>
export default {
  inheritAttrs: false,

  props: {
    src: {
      type: String,
      required: true,
    },
  },

  computed: {
    dataUrl() {
      const { width, height } = this.$attrs
      if (!width || !height) return ""

      // create a tiny png with matching aspect ratio as img
      const w = 100
      const canvas = document.createElement("canvas")
      canvas.width = w
      canvas.height = (height / width) * w

      return canvas.toDataURL()
    },
  },
}
</script>

<style>
.app-img {
  display: inline-block;
}
</style>

Any attributes it receives should be bound to our <img> tag and not the <div>. We’ll use v-bind and $attrs for the image, and tell the component not to add the default attributes to the root <div> by setting inheritAttrs to false.

Lazy loading with Intersection Observer

Ok, we’re ready to implement the Intersection Observer for lazy-loading. I won’t go into too much detail on how Intersection Observer works, but in the mounted hook, we create an IntersectionObserver that watches for the component (via $el) to enter the viewport. When it does, we add an event handler to the image’s ‘load‘ event, and assigns the image’s src attribute (which begins loading it). We also do a bit of cleanup work to the beforeDestroy hook just in case.

<template>
  <div class="app-img">
    <img
      :src="dataUrl"
      :alt="$attrs.alt || ''"
      v-bind="$attrs"
    />
  </div>
</template>

<script>
export default {
  inheritAttrs: false,

  props: {
    src: {
      type: String,
      required: true,
    },
  },

  computed: {
    dataUrl() {
      const { width, height } = this.$attrs
      if (!width || !height) return ""

      // create a tiny png with matching aspect ratio as img
      const w = 100
      const canvas = document.createElement("canvas")
      canvas.width = w
      canvas.height = (height / width) * w

      return canvas.toDataURL()
    },
  },

  mounted() {
    const { src, $el } = this

    const observer = new IntersectionObserver(([entry]) => {
      const img = $el.querySelector("img")

      if (entry.isIntersecting) {
        // Element is in viewport
        img.src = src
        observer.disconnect()
      }
    })
    observer.observe($el)

    this.$once("hook:beforeDestroy", () => {
      observer.disconnect()
    })
  },
}
</script>

<style>
.app-img {
  display: inline-block;
}
</style>

Alright, our image starts on the page as a transparent PNG, and when it enters the viewport, it loads the real image. The next thing we need to make it better some placeholder while it waits to load.

Adding visual placeholder content

We’ll add two props for the placeholder so it can either be a background color or a different (tiny) image. We’ll also add the placeholder as a <div> which will be absolutely positioned over the entire component.

We need to know the size of the original image so that we can stretch our placeholder to the same size. If we don’t know the width and height (we’ll cheat and use the dataUrl computed prop), we just won’t show the placeholder:

<template>
  <div class="app-img">
    <div
      v-if="dataUrl"
      :style="{ background }"
      class="app-img__placeholder"
    >
      <img :src="placeholder || dataUrl" alt="" v-bind="$attrs" />
    </div>
    <img
      :src="dataUrl"
      :alt="$attrs.alt || ''"
      v-bind="$attrs"
      class="app-img__img"
    />
  </div>
</template>

<script>
export default {
  inheritAttrs: false,

  props: {
    src: {
      type: String,
      required: true,
    },
    placeholder: String,
    background: String,
  },

  computed: {
    dataUrl() {
      const { width, height } = this.$attrs
      if (!width || !height) return ""

      // create a tiny png with matching aspect ratio as img
      const w = 100
      const canvas = document.createElement("canvas")
      canvas.width = w
      canvas.height = (height / width) * w

      return canvas.toDataURL()
    },
  },

  mounted() {
    const { src, $el } = this

    const observer = new IntersectionObserver(([entry]) => {
      const img = $el.querySelector(`.app-img__img`)
      const placeholder = $el.querySelector(`.app-img__placeholder`)

      img.onload = function() {
        delete img.onload
        if (placeholder) {
          placeholder.remove()
        }
      }
      if (entry.isIntersecting) {
        // Element is in viewport
        img.src = src
        observer.disconnect()
      }
    })
    observer.observe($el)

    this.$once("hook:beforeDestroy", () => {
      observer.disconnect()
    })
  },
}
</script>

<style>
.app-img {
  display: inline-block;
  position: relative;
}

.app-img__placeholder {
  position: absolute;
}
</style>

A couple of other things to note are the in the intersectionObserver. Once the main image has loaded, we want to remove the placeholder.

Adding final touches

Theres a few more things we can do to make it a better experience.

  • Avoid pixelation on the placeholder by applying a CSS blur filter.
  • Add a fade transition when the main image loads.
<template>
  <div class="app-img">
    <div
      v-if="dataUrl"
      :style="{ background }"
      class="app-img__placeholder"
    >
      <img :src="placeholder || dataUrl" alt="" v-bind="$attrs" />
    </div>
    <img
      :src="dataUrl"
      :alt="$attrs.alt || ''"
      v-bind="$attrs"
      class="app-img__img"
    />
  </div>
</template>

<script>
export default {
  inheritAttrs: false,

  props: {
    src: {
      type: String,
      required: true,
    },
    placeholder: String,
    background: String,
  },

  computed: {
    dataUrl() {
      const { width, height } = this.$attrs
      if (!width || !height) return ""

      // create a tiny png with matching aspect ratio as img
      const w = 100
      const canvas = document.createElement("canvas")
      canvas.width = w
      canvas.height = (height / width) * w

      return canvas.toDataURL()
    },
  },

  mounted() {
    const { src, $el } = this
    let timeOut

    const observer = new IntersectionObserver(([entry]) => {
      const img = $el.querySelector(`.app-img__img`)
      const placeholder = $el.querySelector(`.app-img__placeholder`)

      img.onload = function() {
        delete img.onload
        $el.classList.add(`app-img--loaded`)
        if (placeholder) {
          timeOut = setTimeout(() => {
            placeholder.remove()
          }, 300)
        }
      }
      if (entry.isIntersecting) {
        // Element is in viewport
        img.src = src
        observer.disconnect()
      }
    })
    observer.observe($el)

    this.$once("hook:beforeDestroy", () => {
      observer.disconnect()
      if (timeOut) {
        clearTimeout(timeOut)
      }
    })
  },
}
</script>

<style>
.app-img {
  display: inline-block;
  position: relative;
}

.app-img__placeholder {
  position: absolute;
  overflow: hidden;
}

.app-img__placeholder img {
  transform: scale(1.05);
  filter: blur(10px);
}

.app-img__img {
  opacity: 0;
  transition: opacity 300ms ease;
}

.app-img--loaded .app-img__img {
  opacity: 1;
}
</style>

We’ll accomplish most of this with CSS, but we do need one class added to the image once it has finished loading. And since we have a CSS transition, we also set a timeout to remove the placeholder. Any time we set a timeout, we also want to make sure we clear it when the component is destroyed.

Did you catch the bug?

Now, there is one small bug with the image loading that some of you may have caught. Earlier, we removed the src attribute so the image would not immediately start loading. One thing we did not account for is the srcset attribute. If this is set, the image will still start loading right away, which is not what we want.

Fortunately, the fix is easy. Simply hijack the srcset attribute by registering it as a prop, and then add the srcset to the image at the same time you add the src attribute.

The final code looks like this:

<template>
  <div class="app-img">
    <div
      v-if="dataUrl"
      :style="{ background }"
      class="app-img__placeholder"
    >
      <img :src="placeholder || dataUrl" alt="" v-bind="$attrs" />
    </div>
    <img
      :src="dataUrl"
      :alt="$attrs.alt || ''"
      v-bind="$attrs"
      class="app-img__img"
    />
  </div>
</template>

<script>
export default {
  inheritAttrs: false,

  props: {
    src: {
      type: String,
      required: true,
    },
    placeholder: String,
    background: String,
  },

  computed: {
    dataUrl() {
      const { width, height } = this.$attrs
      if (!width || !height) return ""

      // create a tiny png with matching aspect ratio as img
      const w = 100
      const canvas = document.createElement("canvas")
      canvas.width = w
      canvas.height = (height / width) * w

      return canvas.toDataURL()
    },
  },

  mounted() {
    const { src, srcset, $el } = this
    let timeOut

    const observer = new IntersectionObserver(([entry]) => {
      const img = $el.querySelector(`.app-img__img`)
      const placeholder = $el.querySelector(`.app-img__placeholder`)

      img.onload = function() {
        delete img.onload
        $el.classList.add(`app-img--loaded`)
        if (placeholder) {
          timeOut = setTimeout(() => {
            placeholder.remove()
          }, 300)
        }
      }
      if (entry.isIntersecting) {
        // Element is in viewport
        if (!!srcset) {
          img.srcset = srcset
        }
        img.src = src
        observer.disconnect()
      }
    })
    observer.observe($el)

    this.$once("hook:beforeDestroy", () => {
      observer.disconnect()
      if (timeOut) {
        clearTimeout(timeOut)
      }
    })
  },
}
</script>

<style>
.app-img {
  display: inline-block;
  position: relative;
}

.app-img__placeholder {
  position: absolute;
  overflow: hidden;
}

.app-img__placeholder img {
  transform: scale(1.05);
  filter: blur(10px);
}

.app-img__img {
  opacity: 0;
  transition: opacity 300ms ease;
}

.app-img--loaded .app-img__img {
  opacity: 1;
}
</style>

Closing thoughts

That’s it. Pretty neat! We can now very easily swap out HTML images with our component and our applications will load faster. Want to see a working example?

Edit Vue lazy-load image

This component is largely based on the VImg component from Vuetensils, which is slightly more robust. If this is the sort of thing you’re interested in, I would invite you to check out the project and let me know how you like it.




Thank you so much for reading. If you liked my work, and want to support it, the best ways are financial support, spreading the word, or following along on email or Bluesky.


Originally published on austingil.com.

Leave a Reply

Your email address will not be published. Required fields are marked *