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?
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.