Implementing Low-Quality Image Placeholders (LQIP) in Astro

Martijn null

Martijn

7 min read , ~2 ~hours to implement

A popular technique to enhance user experience is to display a low-quality, blurred version of an image while the full-resolution version loads. In this article, you'll learn how to implement this effect in Astro, without relying on the overhead of JavaScript frameworks like React or Vue.

Implementing Low-Quality Image Placeholders (LQIP) in Astro

What you will learn:

  • On page load, all images should show a blurry, low quality version of the image that will be loaded
  • Once the full resolution images are loaded, they will override the low quality previews
  • We want to do this in Astro, without having to load the JavaScript of a framework like React or Vue.

After that, the result will look like this: ✨ Live example can be found here: https://pinelab.studio/examples/blurry-placeholder-loading

⚠️ Disclaimer: Use blurry image previews primarily to improve perceived user experience, such as on pages behind a login. There is a tradeoff when SEO matters:

  • If the original image isn't present in the HTML, it can hurt SEO and image indexing.
  • Extra processing (e.g., decoding base64 hashes) during load can negatively impact Core Web Vitals.

Prerequisites

  • You already have a generated placeholder hash for your images. We are using Daniel Biegler's top notch Vendure plugin to generate hashes on the backend in Vendure.
  • We used ThumbHash to create the hashes, so we will also need the ThumbHash decoding script to create the correct base64 image.

How do blurry preview hashes work?

On the server, tiny hashes are generated for all your assets. These hashes represent blurry versions of your images. Before you can actually use them, we need to decode (expand) them into a data URL that your browser can load:

  1. The browser loads your HTML document.
  2. The images in your HTML contain a hash, like VtcZFQJoiI+Hp4dnh3Z4h4iAgwhY
  3. On page load on the client, we will decode the hash VtcZFQJ.... into a base64 image your browser can render: data:image/png;base64,iVBOR...

These base64 images are much larger than the initial hash, that is why we decode them client side rather than server side.

However, there is a catch! This decoding requires JavaScript. So, there is a tradeoff: For just one image, you might as well decode on the server, because the script will just as large as the blurry image preview.

In this example:

  • A decoded blurry preview is 3kb
  • The minified version of the script we use is 2.5kb

For multiple images on a page, this definitely saves a lot of KB's, as we only load a hash for each image.

The solution

What we will do is the following:

  1. Set the hashes on images in our HTML serverside, like so: <img data-placeholder-hash="VtcZFQJoiI+Hp4dnh3Z4h4iAgwhY" >
  2. Include an inline script that finds all images with data-placeholder-hash, decodes the hashes to base64, and sets the base64 images as src on the images
  3. When the original image is loaded, we replace the blurry preview with the original image.

Set hashes on img elements in your HTML

This should be the HTML created by your server, either with SSR or SSG.

  <img
    data-placeholder-hash="VtcZFQJoiI+Hp4dnh3Z4h4iAgwhY"
    data-src="https://images.unsplash.com/photo-1607348318282-6d2148bb296c?q=80&w=408&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
    src="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>"
    width="500"
    height="500"
  />
  • data-placeholder-hash holds the hash we will decode
  • data-src is the original image we will load in the background
  • src is just an empty SVG to prevent the image showing a border. That seems to happen with images without a src tag.

Include an inline script to decode images

This script should be placed at the bottom of your <body>, because it directly executes.

<script is:inline>
  // Find all images with data-hash-placeholder attribute
  const images = document.querySelectorAll("img[data-placeholder-hash]");

  images.forEach((img) => {
    const placeholderHash = img.dataset.placeholderHash;
    const original = img.dataset.src;

    // Decode hash 
    const buffer = Uint8Array.from(atob(placeholderHash), (v) => v.charCodeAt(0));
    const placeholder = thumbHashToDataURL(buffer);

    // Set placeholder as initial src
    img.src = placeholder;

    const fullImage = new Image();
    fullImage.src = original;

    fullImage.onload = async () => {
      img.src = original;
      img.classList.add("loaded"); // Optional for transition
    };
  });

  // Copied the decoding functions from the ThumbHash repo, because we want to inline the script.
  // Source can be found here: https://github.com/evanw/thumbhash/blob/a652ce6ed691242f459f468f0a8756cda3b90a82/js/thumbhash.js

  function thumbHashToDataURL(hash) {
       ... (omitted for brevity) 
   }
</script>

The complete script can be viewed here: view-source:https://pinelab.studio/examples/blurry-placeholder-loading

Now, when you open your page in the browser, you will see a blurry preview of the before the original is loaded.

ℹ️ Set your network to 3G, and disable cache with dev tools, to see the effect. Your Wi-Fi is probably too fast.

Why inline?

We want the blurry previews to show as soon as possible. The script above will decode the image as soon as the HTML content is received by your browser. Without is:inline, Astro will package the JS in an external script, which first has to be loaded by your browser.

Why not have the script in React?

Even though it would code-wise be cleaner to package the JS in a React component, there are a few reasons not to do this:

  • In Astro, you can include framework components without every loading their JS. This means only the HTML is shipped to the browser, and the blurry preview is never set.
  • Forcing the JS to be loaded with client:load or client:idle, will require your entire framework JS to be loaded before being able to execute the JS to decode the hashes. For React, this will be at least 10kb gzipped.
  • Even if that is acceptable, loading the React script will require another round trip, because it is loaded as an external script.

TL;DR: Mainly performance.

Caveats

  • Do not extract the inline script into its own Astro Image component and import it multiple times. Inline script will be repeated as is, meaning that the entire decode script will be duplicated! See the https://docs.astro.build/en/guides/client-side-scripts/#opting-out-of-processing for more information.
  • You should only use this method for loading multiple images on a page. The minified decoding script is about 2.5kb, so if you only have one or two images, it's more beneficial to just directly include the decoded blurry image. The decoded blurry image from this example is 3.2kb.
  • Hydration warnings: The React component sets suppressHydrationWarning on the img element, because hydration errors are expected here. Our inline script will modify the DOM, and this will make the client side DOM differ from what React expects.

Next steps

To get this production worthy, you should:

  • Move the <img> and its styling into a component, e.g. React (see example below)
  • Use a minified version of the inline script. See minified script below, or minify it yourself with something like https://minify-js.com/, because don't trust minified scripts you find in blog posts.

Code snippets used

ImageWithPlaceholder.tsx React component

// ImageWithPlaceHolder.tsx
import { useEffect, useState } from "react";

interface ImageWithPlaceholderProps {
  placeholderHash: string;
  srcImage: string;
  width?: number;
  height?: number;
  alt?: string;
  className?: string;
  loading?: "lazy" | "eager";
}

export default function ImageWithPlaceholder({
  placeholderHash,
  srcImage,
  width = 500,
  height = 500,
  alt = "",
  loading = "eager",
}: ImageWithPlaceholderProps) {

  // Placeholder SVG until the placeholder hash is decoded.
  const [src, setSrc] = useState(
    "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>",
  );
  useEffect(() => {
    // On mount in browser, set the src.
    // This is to make sure an image is loaded when this component is used with client:only
    setSrc(srcImage);
  });
  return (
    <div>
      <style>
        {`
            img[data-placeholder-hash] {
              transition: opacity 0.3s ease;
              border-radius: 10px;
              opacity: 0.999;
            }
  
            img[data-placeholder-hash].loaded {
              opacity: 1;
            }
          `}
      </style>
      <img
        // Suppress hydration warning, because the external script will set the decoded placeholder hash,
        // before any React code is loaded. So, an hydration warning is expected.
        suppressHydrationWarning
        src={src}
        data-placeholder-hash={placeholderHash}
        data-src={srcImage}
        loading={loading}
        width={width}
        height={height}
        alt={alt}
      />
    </div>
  );
}

In your Astro files, or other React components, you'd then use it like this:

  <ImageWithPlaceholder
    placeholderHash="VtcZFQJoiI+Hp4dnh3Z4h4iAgwhY"
    srcImage="https://images.unsplash.com/photo-1607348318282-6d2148bb296c?q=80&w=408&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
  /> 

Minified ThumbHash decode script

Minified version of the decoding functions from https://github.com/evanw/thumbhash/blob/a652ce6ed691242f459f468f0a8756cda3b90a82/js/thumbhash.js

const images=document.querySelectorAll("img[data-placeholder-hash]");function thumbHashToDataURL(t){let e=thumbHashToRGBA(t);return rgbaToDataURL(e.w,e.h,e.rgba)}function thumbHashToRGBA(t){let{PI:e,min:a,max:o,cos:r,round:l}=Math,s=t[0]|t[1]<<8|t[2]<<16,h=t[3]|t[4]<<8,n=(63&s)/63,f=(s>>6&63)/31.5-1,c=(s>>12&63)/31.5-1,u=(s>>18&31)/31,i=s>>23,m=(h>>3&63)/63,d=(h>>9&63)/63,b=h>>15,p=o(3,b?i?5:7:7&h),g=o(3,b?7&h:i?5:7),A=i?(15&t[5])/15:1,R=(t[5]>>4)/15,T=i?6:5,H=0,U=(e,a,o)=>{let r=[];for(let l=0;l<a;l++)for(let s=l?0:1;s*a<e*(a-l);s++)r.push(((t[T+(H>>1)]>>((1&H++)<<2)&15)/7.5-1)*o);return r},L=U(p,g,u),w=U(3,3,1.25*m),y=U(3,3,1.25*d),D=i&&U(5,5,R),x=thumbHashToApproximateAspectRatio(t),C=l(x>1?32:32*x),B=l(x>1?32/x:32),G=new Uint8Array(C*B*4),I=[],S=[];for(let t=0,l=0;t<B;t++)for(let s=0;s<C;s++,l+=4){let h=n,u=f,m=c,d=A;for(let t=0,a=o(p,i?5:3);t<a;t++)I[t]=r(e/C*(s+.5)*t);for(let a=0,l=o(g,i?5:3);a<l;a++)S[a]=r(e/B*(t+.5)*a);for(let t=0,e=0;t<g;t++)for(let a=t?0:1,o=2*S[t];a*g<p*(g-t);a++,e++)h+=L[e]*I[a]*o;for(let t=0,e=0;t<3;t++)for(let a=t?0:1,o=2*S[t];a<3-t;a++,e++){let t=I[a]*o;u+=w[e]*t,m+=y[e]*t}if(i)for(let t=0,e=0;t<5;t++)for(let a=t?0:1,o=2*S[t];a<5-t;a++,e++)d+=D[e]*I[a]*o;let b=h-2/3*u,R=(3*h-b+m)/2,T=R-m;G[l]=o(0,255*a(1,R)),G[l+1]=o(0,255*a(1,T)),G[l+2]=o(0,255*a(1,b)),G[l+3]=o(0,255*a(1,d))}return{w:C,h:B,rgba:G}}function rgbaToDataURL(t,e,a){let o=4*t+1,r=6+e*(5+o),l=[137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,t>>8,255&t,0,0,e>>8,255&e,8,6,0,0,0,0,0,0,0,r>>>24,r>>16&255,r>>8&255,255&r,73,68,65,84,120,1],s=[0,498536548,997073096,651767980,1994146192,1802195444,1303535960,1342533948,-306674912,-267414716,-690576408,-882789492,-1687895376,-2032938284,-1609899400,-1111625188],h=1,n=0;for(let t=0,r=0,s=o-1;t<e;t++,s+=o-1)for(l.push(t+1<e?0:1,255&o,o>>8,255&~o,o>>8^255,0),n=(n+h)%65521;r<s;r++){let t=255&a[r];l.push(t),h=(h+t)%65521,n=(n+h)%65521}l.push(n>>8,255&n,h>>8,255&h,0,0,0,0,0,0,0,0,73,69,78,68,174,66,96,130);for(let[t,e]of[[12,29],[37,41+r]]){let a=-1;for(let o=t;o<e;o++)a^=l[o],a=a>>>4^s[15&a],a=a>>>4^s[15&a];a=~a,l[e++]=a>>>24,l[e++]=a>>16&255,l[e++]=a>>8&255,l[e++]=255&a}return"data:image/png;base64,"+btoa(String.fromCharCode(...l))}function thumbHashToApproximateAspectRatio(t){let e=t[3],a=128&t[2],o=128&t[4];return(o?a?5:7:7&e)/(o?7&e:a?5:7)}images.forEach((t=>{const e=t.dataset.placeholderHash,a=t.dataset.src,o=thumbHashToDataURL(Uint8Array.from(atob(e),(t=>t.charCodeAt(0))));t.src=o;const r=new Image;r.src=a,r.onload=async()=>{t.src=a,t.classList.add("loaded")}}));