DEV Community

Cover image for 🌟🖼️ Shiny image loading effect with Vue
Pascal Thormeier
Pascal Thormeier

Posted on

🌟🖼️ Shiny image loading effect with Vue

You've seen it at least a couple of times already: Those shiny box effects when images take a little longer to load. They're on news sites, blogs, shops, you name it. In this article, I'll explain how to build a component in Vue that offers this effect!

Some scaffolding

I'll start with a new component I call LoadingBoxImage.vue - this will be a single file component, so it can be used within any Vue or Nuxt app afterwards. First, I'll basically wrap the <img> tag:

<!-- LoadingBoxImage.vue -->
<template>
  <img :src="src" :alt="alt" />
</template>

<script>
export default {
  props: {
    src: {
      type: String,
      required: true
    },
    alt: {
      type: String,
      required: true
    }
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

Now I'll add some styling to this, so it behaves well and more responsive:

<style scoped>
img {
  max-width: 100%;
}
</style>
Enter fullscreen mode Exit fullscreen mode

This component can be used like this:

<!-- App.vue -->
<template>
  <div id="app">
    <loading-box-image 
      src="http://via.placeholder.com/3200x2400" 
      alt="Some image"
    />
  </div>
</template>

<script>
import LoadingBoxImage from './components/LoadingBoxImage'

export default {
  components: {
    LoadingBoxImage
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

So far, so good. I've replicated the standard image tag as a Vue component.

Next up: Adding a box and removing it again.

The placeholder

The placeholder box will be an aria-hidden div next to the image. I'll hide it once the image has loaded via the native load event and a flag:

<template>
  <div class="image-container">
    <img :src="src" :alt="alt" @load="loaded" />
    <div 
      class="image-placeholder" 
      :class="{ hidden: isLoaded }"
      aria-hidden="true" 
    />
  </div>
</template>

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

  data() {
    return {
      isLoaded: false
    }
  },

  methods: {
    loaded() {
      this.isLoaded = true
    }
  }
}
</script>

<style scoped>
.image-container, img {
  max-width: 100%;
}
.image-placeholder.hidden {
  display: none;
}
</style>
Enter fullscreen mode Exit fullscreen mode

I also needed to add a container around the image and its placeholder and did some adjustments to the styling.

Now, ideally, the placeholder should be the same size as the image, right? I've got two options here: Use fixed dimensions or try to fetch them before the image has fully loaded. Since the latter sounds a lot fancier, I'll implement this first.

At some point, the image will have nativeWidth and nativeHeight available, so I can use those to calculate an aspect ratio:

// LoadingBoxImage.vue, script part

mounted() {
  const img = this.$refs.img

  // Constantly poll for the naturalWidth
  const sizeInterval = setInterval(() => {
    if (img.naturalWidth) {
      // As soon as available: Stop polling
      clearInterval(sizeInterval)

      // Calculate image ratio
      this.loadedAspectRatio = img.naturalWidth / img.naturalHeight
    }
  }, 10)
}
Enter fullscreen mode Exit fullscreen mode

(I also added ref attributes to the original <img> tag and the placeholder to be able to fetch the necessary data)

I can use that now to calculate the placeholder's height. To make it more responsive, I'm updating the client width on the window's resize event and set it once when mounted:

  // ...

  data() {
    return {
      isLoaded: false,
      loadedAspectRatio: null,
      clientWidth: 0,
    };
  },

  methods: {
    loaded() {
      this.isLoaded = true;
    },
    updateClientWidth() {
      this.clientWidth = this.$refs.placeholder.clientWidth;
    }
  },

  computed: {
    /**
     * Calculates the height of the placeholder
     * via the images nativeWidth and nativeHeight.
     */
    placeholderHeight() {
      if (!this.loadedAspectRatio) {
        return 0;
      }

      return this.clientWidth / this.loadedAspectRatio;
    },
  },

  mounted() {
    const img = this.$refs.img;
    const sizeInterval = setInterval(() => {
      if (img.naturalWidth) {
        clearInterval(sizeInterval);
        this.loadedAspectRatio = img.naturalWidth / img.naturalHeight;
      }
    }, 10);

    window.addEventListener('resize', this.updateClientWidth)
    this.updateClientWidth()
  },

  // ...
Enter fullscreen mode Exit fullscreen mode

And set this on the placeholder:

<!-- ... -->
    <div
      class="image-placeholder"
      :class="{ hidden: isLoaded }"
      aria-hidden="true"
      ref="placeholder"
      :style="{ height: `${placeholderHeight}px` }"
    />
<!-- ... -->
Enter fullscreen mode Exit fullscreen mode

Ok, now I've got a placeholder the same size as the image! Awesome! Now I can start to...

Add the shiny box effect

This can be done with CSS keyframes and linear gradient:

.image-placeholder {
  background: rgba(0,0,0,.2);
  background-image: linear-gradient(
    120deg, 
    rgba(255,255,255,0) 0%, 
    rgba(255,255,255,0) 40%,
    rgba(255,255,255,0.8) 50%, 
    rgba(255,255,255,0) 60%,
    rgba(255,255,255,0) 100%
  );
  background-position-x: -100vw;
  background-repeat: no-repeat;
  animation: shiny 1.5s infinite;
}

@keyframes shiny {
  0% {
    background-position-x: -100vw;
  }
  10% {
    background-position-x: -100vw;
  }
  75% {
    background-position-x: 100vw;
  }
  100% {
    background-position-x: 100vw;
  }
}
Enter fullscreen mode Exit fullscreen mode

This will add a single reflection that moves periodically from left to right to an otherwise grayed element.

And that's it!

Here's a Codesandbox to see it in action (I'm not hiding the placeholder for you to see what it would look like):

I'm sure that the gradient and the timing can still be tweaked, though. Also some use cases, like smaller images and complete accessibility are missing, but I'm certain that this can be used as a starting point.

Happy holidays!


I hope you enjoyed reading this article as much as I enjoyed writing it! If so, leave a ❤️ or a 🦄! I write tech articles in my free time and like to drink coffee every once in a while.

If you want to support my efforts, please consider buying me a coffee or follow me on Twitter 🐦!

Buy me a coffee button

Discussion (0)