by Georg Ledermann

Progressive image loading with BlurHash

Recipe for use with Ruby on Rails and Vue.js

Photo by Jen Theodore on Unsplash

Photo by Jen Theodore on Unsplash

A BlurHash is a compact representation of a placeholder for an image. It can be used to display a preview while the browser is loading the entire image. In this article, I want to show how to encode the BlurHash in a Ruby on Rails application and how to use it for progressive image loading in a Vue.js frontend.

To start off, here is an example of a BlurHash string:

LlNA}44TN{kqyEtls:xux^tRRjRi

Because it’s a small string (typically about 30 chars), it can be stored inside the database right along with other image metadata like width, height, geocoding, etc.

The implementation requires two steps: First, the BlurHash string needs to be encoded from the original image and then the string representation can be decoded to draw the placeholder. It looks like this:

What is a BlurHash?

There are open-source libraries in several languages for both steps.

The Rails backend: Encoding the BlurHash

First, we need to add the blurhash gem to the Rails application:

1
bundle add blurhash

In this example, I want to use Active Storage for file storage, so extracting file metadata is done with analyzers. For images, there is a built-in analyzer which relies on MiniMagick for image processing. To keep this example simple, I’ve built a custom analyzer based on this class. For better performance, I recommend to use ruby-vips and rewrite the analyzer from scratch.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# app/analyzers/my_image_analyzer.rb

class MyImageAnalyzer < ActiveStorage::Analyzer::ImageAnalyzer
  def metadata
    read_image do |image|
      if rotated_image?(image)
        { width: image.height, height: image.width }
      else
        { width: image.width, height: image.height }
      end.merge blurhash(image)
    end
  end

  private

  def blurhash(image)
    # Create a thumbnail first, otherwise the BlurHash encoding is very slow
    thumbnail = image.resize('200x200>').auto_orient

    {
      blurhash: BlurHash.encode(
        thumbnail.width,
        thumbnail.height,
        thumbnail.get_pixels.flatten
      )
    }
  rescue MiniMagick::Invalid => e
    logger.error "Error while encoding BlurHash: #{e}"
    {}
  end
end

The new analyzer needs to be registered, which is typically done with an initializer:

1
2
3
4
5
6
7
8
9
10
# config/initializers/active_storage.rb

require 'my_image_analyzer'

Rails.application.configure do
  config.active_storage.analyzers = [
    MyImageAnalyzer,
    ActiveStorage::Analyzer::VideoAnalyzer
  ]
end

When the analyzer processes a file, it adds the BlurHash string to the file metadata, which is stored in the active_storage_blobs table of the database. Try it out:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Post < ApplicationRecord
  has_one_attached :image
end

post = Post.create!
post.image.attach(
  io: URI.open('https://images.unsplash.com/photo-1451153378752-16ef2b36ad05'),
  filename: 'example.jpg'
)
post.image.analyze
post.image.metadata

# => {
#   identified: true,
#   width: 3872,
#   height: 2592,
#   blurhash: 'LlNA}44TN{kqyEtls:xux^tRRjRi',
#   analyzed: true
# }

That’s it for the backend. The next step is how to use the BlurHash string in the frontend.

The Vue.js frontend: Using the BlurHash as a placeholder while loading

For decoding pixels from a BlurHash string, we need to install the blurhash package:

1
yarn add blurhash

Now we can build a simple Vue component that draws the pixels on a canvas. It takes a hash and an aspect ratio as props. To keep things performant, a minimal canvas (32 × 32 pixels) is used and resized via CSS. For styling stuff, I chose the wonderful Tailwind CSS library.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<!--
  BlurhashImg.vue
  Based on https://github.com/fpapado/blurhash-img
-->

<template>
  <div
    class="relative h-0"
    :style="`padding-bottom: ${aspectRatio * 100}%`"
  >
    <canvas
      ref="canvas"
      class="absolute top-0 left-0 right-0 bottom-0 w-full h-full"
      width="32"
      height="32"
    />
  </div>
</template>

<script>
import { decode } from 'blurhash'

export default {
  props: {
    hash: {
      type: String,
      required: true
    },

    aspectRatio: {
      type: Number,
      default: 1
    }
  },

  mounted() {
    const pixels = decode(this.hash, 32, 32)
    const imageData = new ImageData(pixels, 32, 32)
    const context = this.$refs.canvas.getContext('2d')
    context.putImageData(imageData, 0, 0)
  }
}
</script>

Now we can build a LazyImage component, which takes an image URL, a BlurHash string, and width & height. Via the IntersectionObserver API, it starts loading the image when it scrolls into the viewport. While loading, the BlurHash placeholder is displayed.

First, I add vue-intersect which simplifies handling the observer:

1
yarn add vue-intersect

This is the central part of the implementation: The <img> component comes without the src attribute, which will be added later when the image enters the viewport. With opacity transition, the placeholder is faded into the fully loaded image:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
<!-- LazyImage.vue -->

<template>
  <intersect @enter.once="onEnter">
    <div class="relative">
      <!-- Show the placeholder as background -->
      <blurhash-img
        :hash="blurhash"
        :aspect-ratio="height / width"
        class="absolute top-0 left-0 transition-opacity duration-500"
        :class="isLoaded ? 'opacity-0' : 'opacity-100'"
      />

      <!-- Show the real image on the top and fade in after loading -->
      <img
        ref="image"
        :width="width"
        :height="height"
        v-bind="$attrs"
        class="absolute top-0 left-0 transition-opacity duration-500"
        :class="isLoaded ? 'opacity-100' : 'opacity-0'"
      >
    </div>
  </intersect>
</template>

<script>
import BlurhashImg from 'BlurhashImg'
import Intersect from 'vue-intersect'

export default {
  components: {
    Intersect,
    BlurhashImg
  },

  inheritAttrs: false,

  props: {
    src: {
      type: String,
      required: true
    },
    blurhash: {
      type: String,
      required: false,
      default: null,
    },
    width: {
      type: Number,
      default: 1
    },
    height: {
      type: Number,
      default: 1
    }
  },

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

  methods: {
    onEnter() {
      // Image is visible (means: has entered the viewport),
      // so start loading by setting the src attribute
      this.$refs.image.src = this.src

      this.$refs.image.onload = () => {
        // Image is loaded, so start fading in
        this.isLoaded = true
      }
    }
  }
}
</script>

Using the LazyImage component is simple:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- Example.vue -->

<template>
  <lazy-image
    src="https://images.unsplash.com/photo-1451153378752-16ef2b36ad05"
    blurhash="LlNA}44TN{kqyEtls:xux^tRRjRi"
    :width="3872"
    :height="2592"
  />
</template>

<script>
import LazyImage from 'LazyImage'

export default {
  components: { LazyImage }
}
</script>

More

I have pushed the full source code to a GitHub repository and created a live demo.

Changelog

  • 2021-04-15: I’ve updated the frontend example to Vue.js 3 and removed the vue-intersect library. Please look at the commit in the GitHub repository for the changes.