How to Build Your Own Progressive Image Loader

Craig Buckler

A magician revealing an image via progressive image loader

You may have encountered progressive images on Facebook and Medium. A blurred low-resolution image is replaced with a full-resolution version when the element is scrolled into view:

progressive image example

The preview image is tiny – perhaps a 20px width highly-compressed JPEG. The file can be less than 300 bytes and appears instantly to give the impression of fast loading. The real image is lazy-loaded when required.

Progressive images are great but the current solutions are quite complex. Fortunately, we can build one with a little HTML5, CSS3 and JavaScript. The code will:

  • be fast and lightweight – just 463 bytes of CSS and 1,007 bytes of JavaScript (minified)
  • support responsive images to load alternative versions for larger or high-resolution (Retina) screens
  • have no dependencies – it will work with any framework
  • work in all modern browsers (IE10+)
  • be progressively enhanced to work in older browsers or when JavaScript or image loading fails
  • be easy to use.

Our Demo and GitHub Code

Here’s what our technique will look like:

See the Pen responsive-image by SitePoint (@SitePoint) on CodePen.

Download the code from GitHub


We’ll start with some basic HTML to implement our progressive image:

<a href="full.jpg" class="progressive replace">
  <img src="tiny.jpg" class="preview" alt="image" />


  • full.jpg is our large full-resolution image contained in the link href, and
  • tiny.jpg is our tiny preview image.

We already have a minimal working system. Without any JavaScript – or in older browsers where it may fail – the user can view the full image by clicking the preview.

Both images must have the same aspect ratio. For example, if full.jpg is 800 x 200, it has a resulting aspect ratio of 4:1. tiny.jpg could therefore be 20 x 5 but you should not use a 30px width which would require a fractionally impossible 7.5px height.

Note the classes used on the link and the preview image; these will be used as hooks in our JavaScript code.

To Inline or Not Inline Images

The preview image can also be inlined as a data URI, e.g.

<img src="data:image/jpeg;base64,/9j/4AAQSkZJ..."  class="preview" />

Inlined images appear instantly, require fewer HTTP requests and avoid additional page reflows. However:

  • it takes more effort to add or change an inline image (although build processes such as Gulp can help)
  • base-64 encoding is less efficient and is typically 30% larger than binary data (although this is offset by additional HTTP request headers)
  • inlined images cannot be cached. They will be cached in the HTML page but could not be used on another page without re-sending the same data.
  • HTTP/2 lessens the need for inline images.

Be pragmatic: inlining is a good option if the image is used on a single page or the resulting code is small, i.e. not much longer than a URL!


We start by defining the link container styles: {
  position: relative;
  display: block;
  overflow: hidden;
  outline: none;

This sets the essential container layout properties. The link can have other classes and styles applied to set the dimensions or position if required.

You could consider setting the exact dimensions or using the padding-top trick to enforce an intrinsic aspect ratio. This would ensure the container is sized before the image loads and reflows are avoided. However, it would be necessary to calculate the size and/or width:height ratios for every image. I’ve chosen to keep it simple:

  • the preview and large images must have an identical aspect ratio (see above)
  • the preview image will define the height of the container almost instantly because it is in-lined or loads quickly.

Again, be pragmatic. Performance will improve if you define container widths and heights but will be especially noticeable on pages with lots of images, e.g. a gallery (where all images are likely to have the same aspect ratio anyway).

The replace class on the container is removed when the full image loads and the click is disabled. Therefore, we can remove the standard link pointer: {
  cursor: default;

The preview and large images within the container are sized according to the container width: img {
  display: block;
  width: 100%;
  max-width: none;
  height: auto;
  border: 0 none;

Note that height: auto is required or IE10/11 may miscalculate the image’s height.

The preview image is blurred using a length of 2vw which ensures the blur amount looks similar regardless of the page dimensions. The overflow: hidden applied to the container provides a hard edge to the image. It is also scaled by 1.05 to prevent the page background color showing through the blurred outer edge of the image. This also means we can use a pleasing zoom effect to reveal the full image. img.preview {
  filter: blur(2vw);
  transform: scale(1.05);

Finally, we define styles and animations for the full image when it is revealed: img.reveal {
  position: absolute;
  left: 0;
  top: 0;
  will-change: transform, opacity;
  animation: reveal 1s ease-out;

@keyframes reveal {
  0% {transform: scale(1.05); opacity: 0;}
  100% {transform: scale(1); opacity: 1;}

The full image is positioned above the preview then has the opacity increased from 0 to 1 and the scale changed from 1.05 to 1 over one second. You can apply other transformation and/or filter effects as required.

The JavaScript

We’re practising responsible progressive enhancement so the JavaScript code initially checks whether the required browser APIs are available before adding a load event listener to the page:

// progressive-image.js
if (window.addEventListener && window.requestAnimationFrame && document.getElementsByClassName) window.addEventListener('load', function() {

The load event is fired when the page and all assets have completed loading. We do not want large images to start loading before essential resources such as fonts, CSS, JavaScript and preview images are ready (which could happen if we used the DOMContentLoaded events which fires when the DOM is ready).

Next, we fetch all image container elements with the class names progressive and replace:

var pItem = document.getElementsByClassName('progressive replace'), timer;

getElementsByClassName() returns a live array-like HTMLCollection which changes as associated elements are added and removed from the page. The benefits will become apparent shortly.

Next we’ll define an inView() function which determines whether each container is in the viewport by comparing its getBoundingClientRect position with the window.pageYOffset vertical scroll position:

// image in view?
function inView() {
  var wT = window.pageYOffset, wB = wT + window.innerHeight, cRect, pT, pB, p = 0;
  while (p < pItem.length) {

    cRect = pItem[p].getBoundingClientRect();
    pT = wT +;
    pB = pT + cRect.height;

    if (wT < pB && wB > pT) {
    else p++;

When the container is in view, its node is passed to a loadFullImage() function and the replace class is removed. This instantly removes the node from the pItem HTMLCollection so the container is never re-processed again.

The loadFullImage() function creates a new HTML Image() object and sets its values as necessary, i.e. by copying the container’s href to the src attribute and applying a reveal class:

// replace with full image
function loadFullImage(item) {
  if (!item || !item.href) return;

  // load image
  var img = new Image();
  if (item.dataset) {
    img.srcset = item.dataset.srcset || '';
    img.sizes = item.dataset.sizes || '';
  img.src = item.href;
  img.className = 'reveal';
  if (img.complete) addImg();
  else img.onload = addImg;

An internal addImg function is called once the image has loaded:

// replace image
  function addImg() {
    // disable click
    item.addEventListener('click', function(e) { e.preventDefault(); }, false);

    // add full image
    item.appendChild(img).addEventListener('animationend', function(e) {
      // remove preview image
      var pImg = item.querySelector && item.querySelector('img.preview');
      if (pImg) { = pImg.alt || '';

This code:

  • disables the click event on the container
  • appends the image to the page which starts the fade/zoom animation
  • waits for the animation to end using the animationend listener then copies the alt tag, deletes the preview image node and removes the reveal class from the full image. This step aids performance and also prevents some strange shearing problems when the Edge browser is resized.

Finally, we must call the inView() function to check whether any progressive image containers are visible on the page when it first runs:


We must also call the function when the page is scrolled or the browser is resized. Some older browsers (yes, primarily IE) can react to those events very rapidly so we’ll throttle the callback to ensure it cannot be called any more than once every 300 milliseconds:

window.addEventListener('scroll', scroller, false);
window.addEventListener('resize', scroller, false);

function scroller(e) {
  timer = timer || setTimeout(function() {
    timer = null;
  }, 300);

Note the call to requestAnimationFrame which runs inView prior to the next repaint.

Responsive Images

The HTML5 image srcset and sizes attributes define multiple images at different sizes and resolutions. The browser then selects the most appropriate version for the device.

The code above supports this feature – add data-srcset and data-sizes attributes to the link container, e.g.

<a href="small.jpg"
  data-srcset="small.jpg 800w, large.jpg 1200w"
  class="progressive replace">
  <img src="preview.jpg" class="preview" alt="image" />

After loading, the full image code will be:

<img src="small.jpg"
    srcset="small.jpg 800w, large.jpg 1200w"
    alt="image" />

Modern browsers will load large.jpg when the viewport width is 800px or higher. Older browsers and those with a smaller viewport width will receive small.jpg. For more information, refer to How to Build Responsive Images with srcset.

Usage Notes

I’ve kept the code small but feel free to use or improve it on any project. Enhancements to consider:

  • Horizontal scroll checking. Only vertical scrolling is checked so all images in the horizontal plane are replaced.
  • Dynamically adding progressive images. Progressive images added to a page using JavaScript are only replaced when a scroll or resize event occurs.
  • Firefox performance. The browser can struggle when replacing large images — you may see a noticeable flicker.

Please tweet me @craigbuckler if you find it useful.