Skip to content
Theodo logo

Medium-like Image Loading with Vue.js (part 2)

Louis Zawadzki5 min read

Quick summary of part 1

I’m quite fond of the way Medium displays its images while they’re loading.
At first they display a grey placeholder, then displays a small version of the image - something like 27x17 pixels.

The trick is that most browsers will blur a small image if it is streched out.
Finally, when the full-size image is downloaded, it replaces the small one. You can see a live demo of what I had done on this Codepen.

In this post I intend to make a component that is as close as possible to what Medium actually does, as it is explained on this excellent post by José M. Perez.
And I have also switched from Vue 1 to Vue 2 ;)

Adding a placeholder behind the images

Let’s add the first element which we wait for the images to be loaded: a grey placeholder.

waiting

In our template we have 3 elements:

The javascript logic will set the sources of the images and display the right element depending on which images are already loaded.
To load the images we’ll use the mounted function of the component.
To set the “state” of the component, we’ll use a data called currentSrc that will be initialized to null and will take the value of the source of the image that should be displayed.

This far, the Vue component should look like this:

<template>
  <div v-show="currentSrc === null" class="placeholder"></div>
  <img v-show="currentSrc === hiResSrc" :src="lowResSrc"></img>
  <img v-show="currentSrc === hiResSrc" :src="hiResSrc"></img>
</template>

<style scoped>
  img, .placeholder {
    height: 600px;
    width: 900px;
    position: absolute;
  }
  .placeholder {
    background-color: rgba(0,0,0,.05);
  }
</style>

<script>
  export default {
    props: [
      'hiResSrc',
      'loResSrc'
    ],
    data: function() {
      return {
        currentSrc: null // setting the attribute to null to display the placeholder
      }
    },
    mounted: function () {
      var loResImg, hiResImg, that, context;
      loResImg = new Image();
      hiResImg = new Image();
      that = this;

      loResImg.onload = function(){
        that.currentSrc = that.loResSrc; // setting the attribute to loResSrc to display the lo-res image
      }
      hiResImg.onload = function(){
        that.currentSrc = that.hiResSrc; // setting the attribute to hiResSrc to display the hi-res image
      }
      loResImg.src = that.loResSrc; // loading the lo-res image
      hiResImg.src = that.hiResSrc; // loading the hi-res image
    }
  }
</script>

Adding transitions

transition

Then we need to add some transitions when the value of currentSrc changes.
To be more accurate, we want to fade-in/out every element as they appear/disappear.
Vue.js lets you handle CSS transitions in a pretty easy way by adding and removing classes.

As we have multiple elements to transition between we have to use a transition group:

<template>
  <transition-group name="blur" tag="div">
    <div v-show="currentSrc === null" key="placeholder" class="placeholder blur-transition"></div>
    <img v-show="currentSrc === loResSrc" :src="loResSrc" key="lo-res" class="blur-transition"></canvas>
    <img v-show="currentSrc === hiResSrc" :src="hiResSrc" key="hi-res" class="blur-transition"></img>
  </transition-group>
</template>

Here is how Vue.js handles the transition when the value of currentSrc changes from null to loResSrc:

  1. the ‘blur-leave’ class is added to the placeholder, thus triggering the transition for the placeholder
  2. the ‘blur-enter’ class is added to the low resolution image
  3. the placeholder is hidden and the image is shown
  4. on the next frame, the ‘blur-enter’ and ‘blur-leave’ classes are removed, thus triggering the transition for the image

Knowing this we can make the following changes in our style:

<style scoped>
  img, .placeholder {
    height: 600px;
    width: 900px;
    position: absolute;
  }
  .placeholder {
    background-color: rgba(0,0,0,.05);
  }
  .blur-transition {
    transition: opacity linear .4s 0s;
    opacity: 1;
  }
  .blur-enter, .blur-leave {
    opacity: 0;
  }
</style>

That way the images and placeholders will fade in and out when they appear and disappear.

You can see a live demo here: http://codepen.io/zkilo/pen/wgdxWq.

Well, it looks good but there is a slight difference with what it actually looks like on Medium.
Can you spot it?

Using canvas

If you’ve looked well at the previous Codepen you may have found out a little issue.
When we change the opacity of the low resolution image, we can see the ugly pixels because browsers aren’t able to blur the image while its opacity changes.

But don’t worry, there’s an easy way to solve this!
We’re going to use canvas, because browsers can actually blur canvas while their opacity changes with a little trick!

So, let’s change our template:

<template>
  <transition-group name="blur" tag="div">
    <div v-show="currentSrc === null" class="placeholder blur-transition" key="placeholder"></div>
    <canvas v-show="currentSrc === loResSrc" height="17" width="27" key="canvas" class="blur-transition"></canvas>
    <img v-show="currentSrc === hiResSrc" :src="hiResSrc" key="image" class="blur-transition"></img>
  </transition-group>
</template>

And our style:

<style scoped>
  img, canvas, .placeholder {
    height: 600px;
    width: 900px;
    position: absolute;
  }
  .placeholder {
    background-color: rgba(0,0,0,.05);
  }
  canvas {
    filter: blur(10px);
  }
  .blur-transition {
    transition: opacity linear .4s 0s;
    opacity: 1;
  }
  .blur-enter, .blur-leave {
    opacity: 0;
  }
</style>

You can see that we’ve set the height and weight attributes of our canvas in the template and streched it out in our style.
You might also have spotted the little trick: we can add a blur filter attribute on the canvas and it will still be here even if the opacity of our element changes!
I’ve set it to 10px empirically, but you can learn more about the canvas filter here.

Once we’ve done this, we need to draw our low resolution image inside the canvas once it is loaded:

<script>
  export default {
    props: [
      'hiResSrc',
      'loResSrc'
    ],
    data: function() {
      return {
        currentSrc: null
      }
    },
    mounted: function () {
      var loResImg, hiResImg, that, context;
      loResImg = new Image();
      hiResImg = new Image();
      that = this;
      context = this.$el.getElementsByTagName('canvas')[0].getContext('2d'); // get the context of the canvas

      loResImg.onload = function(){
        context.drawImage(loResImg, 0, 0);
        that.currentSrc = that.loResSrc;
      }
      hiResImg.onload = function(){
        that.currentSrc = that.hiResSrc;
      }
      loResImg.src = that.loResSrc;
      hiResImg.src = that.hiResSrc;
    }
  }
</script>

You can see the final result on this Codepen: http://codepen.io/zkilo/pen/ZLyweL.

And that’s it!

brad

You can find the component as a .vue file on this Github repository.

Liked this article?