By Craig Buckler

How to Build Your Own Progressive Image Loader

By 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.

  • Sean Elliott

    Nice post, this is something that I have been keen on implementing for sometime in every project I do.

    One thing i think should change is the disabling of the click. I dont think its the right move from an accessibility point of view. It doesn’t hurt still having the ability to open the bigger image from a users point of view. At least keeping it enabled wouldnt be confusing for those navigating with the keyboard or using screen readers. Just a thought and it doesnt really change your code much :)

    • M⃠ ⃠S⃠ ⃠i⃠ ⃠N⃠ ⃠L⃠u⃠n⃠d⃠

      In that case, only keep the click if the image is significantly bigger than whats already shown.

      I hate when you click an image only to be presented with that exact same image again.

      • Sean Elliott

        Thats a pet hate for yourself, but the broader picture needs to be considered here. A screen reader will read out that there is a link on the page, the user has know way of knowing the click action has been disabled via javascript which creates a bad user experience, unfortunately there is no ‘disabled’ attribute we can use on links.

        • M⃠ ⃠S⃠ ⃠i⃠ ⃠N⃠ ⃠L⃠u⃠n⃠d⃠


          Yes of course you don’t have a link that doesn’t go anywhere.
          You make the image non-clickable by not having an a-tag around it.
          I thought that was obvious.

    • Craig Buckler

      Thanks Sean and squiggle(?!). There are three potential use-cases:

      1. The image is content and shown at near full-size
      You therefore want the link to be clickable if lazy-loading fails and disabled when it works. The code could go further and replace the anchor with a div or similar element.

      2. The image is content and shown much smaller than full-size
      In that case, you want the image to remain clickable. We could make that an option in the code, e.g. add a new “clickable” class which does not stop the click event.

      3. The image is decorative, such as a hero image
      You don’t need the image to be clickable even if loading fails. The code above can be adapted to permit any container tag – such as a div or figure – so there’s no inherent click. I’d also suggest using a data-href attribute in that case.

      I’ll consider writing a follow-up article if we get further suggestions too.

  • M⃠ ⃠S⃠ ⃠i⃠ ⃠N⃠ ⃠L⃠u⃠n⃠d⃠

    But please dont teach people to add unnecessary delays in the form of animated fade-in on images or other elements
    As it looks now, this code makes the page noticeably slower to load.

    No-one who lived trough the age of modems, would want that “feature” back.

    • Craig Buckler

      All the animation and timings are handled by CSS so it’s easy to change. That said, an abrupt switch from blurred to focused could be jarring. It needs something subtle but not distracting.

      • M⃠ ⃠S⃠ ⃠i⃠ ⃠N⃠ ⃠L⃠u⃠n⃠d⃠

        Who on earth thinks its jarring?
        Content appear in front of you after you clicked a link?
        – Holy crap! All the images on the page wasn’t supposed to be blurry?!

        By that logic, the whole page should fade in slowly too.
        Just to not cause a stampede among more sensitive visitors.

        Actually, i have seen that on some sites.
        Incredibly annoying.

        • Craig Buckler

          I said it! A flicker from one image to another is still an effect and could distract more than a smoother, subtler fade-in. But you can choose whatever effect you like – have it appear instantly or bounce around the screen for 30 seconds. It’s up to the developer!

  • Ross Z-Trigger Clutterbuck

    As with so many things these days, it strikes me as a solution looking for a problem. Loading up a site with all this extra JS and CSS on the assumption that not having 150% of your content ready within nanoseconds of hitting a page is somehow an actual issue. And yet devs complain about page weight. I know the PlayStation generation get a lot of flak for their supposed short attention spans and impatience, but given the weight and loading times of Instagram, Flickr and Facebook on mobile I don’t think anybody’s too bothered by having to wait a bit for content to appear.

    Still, the technique for what it is is a nice one which I’m sure will serve as a foundation for something more practical, and that inView function is sweet as a nut. Totally using that!

    • Craig Buckler

      The biggest cause of website obesity is images. It’s normally around two-thirds of the total payload. Average page weight has reached almost 2.5MB – it’s not a matter of waiting a few nanoseconds on a mobile connection. Or hotel wifi. Or at an airport. Or in a busy office. Or even an average broadband pipe.

      The majority of pages load every asset whether you need them or not. You may never see that 400Kb image in the page footer yet it’s downloading anyway. It may even be costing you.

      Another factor is performance. Some pages wait for every asset to download before JavaScript functionality triggers. Even if that’s not the case, the browser is still processing images in the background which uses bandwidth, battery and processing capacity.

      This solution adds less than 1.5KB of code but lazy-loading images could save many hundred KB of bandwidth. The page layout drawn quicker and will be responsive sooner. It addresses a real problem we have today.

  • Alfredo Riveroll Oney

    Thank you for the information. Is this method working with background images?

    • Craig Buckler

      No – this is designed to work with content images. It would be possible to adapt it but may not be as useful. Background images tend to be used in your template so they’re used on many pages.

Get the latest in Front-end, once a week, for free.