🌟🖼️ Shiny image loading effect with Vue
Tags: vue, css, webdev, javascript
Published: 2020-12-22 18:19:53

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:

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

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

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

```html
<style scoped>
img {
  max-width: 100%;
}
</style>
```

This component can be used like this:

```html
<!-- 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>
```

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:

```html
<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>
```

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:

```javascript
// 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)
}
```

(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:

```javascript
  // ...

  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()
  },

  // ...
```

And set this on the placeholder:

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

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:

```css
.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;
  }
}
```

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):

{% codesandbox vue-shiny-box-loading-effect-f3y2y %}

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* ☕](http://buymeacoffee.com/pthormeier) *or* [*follow me on Twitter* 🐦](https://twitter.com/pthormeier)*!*

[![Buy me a coffee button](https://dev-to-uploads.s3.amazonaws.com/i/x55hp6jopwyy161d8e2u.png)](http://buymeacoffee.com/pthormeier)