Skip to content

Simple and Stylish: Improving PageSpeed on HubSpot with Blur-Up Images

Are images slowing down your HubSpot CMS website? Sure, images may be worth a thousand words, but delays in your website could be costing your business thousands of dollars. There are many ways to optimize the loading of the pages on your website, and this is one of our favorites: blur-up images. Popularized by Instagram and Medium, blur-up images aren't only a pleasing reveal transition as users scroll, but also are a great strategy to improve your pagespeed and Core Web Vital (CWV) scores.

TLDR: Blur-up images will quickly load a tiny placeholder version of your image and blur it so that it does not appear pixelated. When a user scrolls and the image becomes visible, the full-resolution version takes its place and the blur is removed. You can start using blur-up images on HubSpot CMS today using this module code.

How HubSpot Renders Images By Default

We love developing on HubSpot because they include so many built-in optimization tools. From a built-in CDN, to automatic code minification and optimization, HubSpot includes a variety of utilities to speed-up your site. one of the more unique optimization tools that come with HubSpot is there an image resizing API. This tool allows you to load multiple sizes of the same image automatically, without a designer or developer having to optimize every image file itself.

For example, whenever an image is added to a page and includes a width and height attribute, HubSpot will automatically generate a "source set," which browsers can use to download the most appropriate version of your image that will still display sharply for the user. This source set will define image sources for various resolutions of the image, ranging from 0.5x-3x the pixel dimensions defined on the element.

Marketer with coffee taking notes in her home office

However, this does not mean that images will not slow down your site if not used correctly. Firstly, it's common to not declare a width/height on your image if that element will responsively resize within your layout, especially if the image is acting as a hero image or other full-width element. In this case, not only will no source set be generated, but because the height is unknown, the image may introduce layout shift, an important CWV metric. Additionally, for any images "below the fold," lazy loading will help, but if the image is large it may still introduce layout shift or otherwise take time to render (especially on mobile connections). Blur-up images can solve both of these problems when implemented correctly.

Building on HubSpot's Foundation for Performance

By knowing what HubSpot does in the background, we can mimic this behavior and expand on it for even more performance gains. Firstly, as previously mentioned, we know HubSpot automatically generates images from 0.5x-3x resolution by default for images, so we can do the same thing manually using their resize API. We can also use the same controls as the default HubSpot module to handle loading: either eager (load the image as quickly as possible) or ideally lazy if the image appears "below the fold."

Going a step beyond HubSpot's strategies, we can also take advantage of "preload" and "prefetch" techniques to instruct browsers on how to best download our images. Preloading an image will tell the browser to download the file as quickly as possible and be ready to display the image soon, similar to "eagerly" loading an image. Prefetching, on the other hand, tells the browser that an image will eventually be needed (after some scrolling), and once the browser has downloaded everything else essential for the page, to get a head start on these files. These methods correspond closely with loading an image "eagerly" or "lazily," and therefore it is logical to use this control from the default HubSpot module to determine which method to use: if an image is present "above the fold," load it eagerly and preload it, otherwise, load it lazily and prefetch.

Worker taking notes in notebook while on computer

One caveat to note: loading an image lazily by default WITHOUT prefetching may be slightly more performant for your users compared to this method. When an image is prefetched, once the browser is inactive it will begin downloading the prefetched image in the background so it is ready for use when it enters the user's viewport, regardless of whether the user reaches that section of the page or not. In our opinion, using prefetching is worth using because it delivers the best possible loading experience while also ensuring that even if unnecessary data is loaded in the background when the page would otherwise be inactive.

Finally, we can put these strategies together with some basic CSS and JavaScript to provide a smooth, graceful transition when the images become visible. Here's the full approach we are looking for:

  1. We will instruct browsers to download the full-size image as efficiently as possible, early in the page document before the image tag is even evaluated in the body
  2. If we know the image dimensions, we will load (and preload/prefetch) a source set of responsive images by default
  3. If the dimensions are unknown or fluid, we will load a small version of the image as a "placeholder," and only load the full-resolution image when visible on the user's screen
  4.  For all cases, we will blur and desaturate the image (which will hide the transition from a placeholder to a full-resolution image), and then remove this effect when a user scrolls to an image

Building the Blur-Up Image Module: Jumping Into the Code

Let's break down the code here, starting from the simplest section to the most complicated.

CSS

CSS

/* 
By Default, blur and desaturate the placeholder image, 
then transition to sharp and saturated when the image 
loads and is in-view 
*/

img[data-blur-up] {
    filter: blur(1rem) saturate(0.5);
    overflow: hidden;
    transition: all 0.75s ease;
}

img[data-loaded] {
    filter: blur(0px) saturate(1);
}

The styling code here is pretty self-explanatory: for every image with the blur-up data attribute, add a blur and saturate filter, and for every loaded image, remove that filter. The overflow property helps keep edges crisp, but admittedly this is not yet a perfect solution and I have run into some bugs. If you want to help fix this, check out the repo for this module and contribute!

HTML/HubL

HubL

  <!-- module html  -->
  {% if module.target == true %}
    {% set target = "_blank" %}
  {% else %}
    {% set target = "_parent" %}
  {% endif %}


  {# 
  Do not prepend // when usign a personalization token, 
  the link already has // or starts with /, the link is mailto, 
  the link is an anchor, or the link is not defined 
  #}

  {% unless (module.link is string_containing "{{") or (module.link is string_containing "//") or (module.link is string_startingwith "/") or (module.link is string_startingwith "mailto") or (module.link is string_startingwith "#") or !module.link %}
    {% set link = "//" ~ module.link %}
  {% else %}
    {% set link = module.link || "" %}
  {% endunless %}

  {# If there is a link, create the 'a' element #}
  {% if module.link %}
    <a href="{{link}}" target="{{target}}" style="border-width: 0px; border: 0px;">
  {% endif %}
  

This section is almost entirely boilerplate from HubSpot's default image module, however with the added section to add a link wrapper around the image (HubSpot's example uses a tag).

HubL

  {% if module.img.src %}
    {% set lazy_load = module.img.loading %}
    {% set loadingAttr = lazy_load != 'disabled' ? 'loading="{{ module.img.loading }}"' : '' %}
      {% set sizeAttrs = 'width="{{ module.img.width }}" height="{{ module.img.height }}"' %}
    {% set resolutions = [0.5, 1, 1.5, 2, 2.5, 3] %}
  

Here, we load our attributes for loading and sizing based on what the user inputs into the module, again similar to the default. We also declare a variable for the range of resolutions we want to generate in our source set, here ranging from 0.5x to 3x the base resolution. This follows HubSpot's best practice but could be customized if needed.

HubL

  {# If the image is auto-sized/responsive (we'll mostly handle this type with JS) #}
	{% if module.img.size_type == 'auto' %}
		{% set sizeAttrs = 'style="max-width: 100%; height: auto;"' %}

    {# Load a low-resolution placeholder image as the 'src', and the full-resolution base image as the 'data-src' #}
    <img src="{{ resize_image_url(module.img.src, 50) }}" data-src="{{ module.img.src }}" alt="{{ module.img.alt }}" data-blur-up data-auto-size {{ loadingAttr }} {{ sizeAttrs }}>
  {# Else, if the image is responsive but has a set max-width #}
	{% elif module.img.size_type == 'auto_custom_max' %}
		{% set sizeAttrs = 'width="{{ module.img.max_width }}" height="{{ module.img.max_height }}" style="max-width: 100%; height: auto;"' %}

    {# Because HS knows the width and height, it will auto-generate a srcset for this image #}
    {% require_head %}

      {# 
        If loading is set to 'eager' or 'disabled,' we will preload the srcset (forcing it to load as early as possible).
        Otherwise, we will prefetch, which instructs the page to download the srcset in the background at a low-priority  
      #}
      <link rel="{{ (lazy_load == "disabled" || lazy_load == "eager") ? "preload" : "prefetch" }}" as="image" imagesrcset="{% for i in resolutions %}{{ resize_image_url(module.img.src, module.img.max_width*i)}} {{(module.img.max_width*i)}}w{% if not loop.last %},{% endif %}{% endfor %}" imagesizes="(max-width: {{module.img.max_width}}px) 100vw, {{module.img.max_width}}px">
    {% end_require_head %}
    <img data-blur-up src="{{ module.img.src }}" srcset="{% for i in resolutions %}{{ resize_image_url(module.img.src, module.img.max_width*i)}} {{(module.img.max_width*i)}}w{% if not loop.last %},{% endif %}{% endfor %}" sizes="(max-width: {{module.img.max_width}}px) 100vw, {{module.img.max_width}}px" alt="{{ module.img.alt }}" {{ loadingAttr }} {{ sizeAttrs }}>
	{# Else, we know the intended width and height for the image, so rendering is simple #}
  {% else %}
    {% require_head %}
      <link rel="{{ (lazy_load == "disabled" || lazy_load == "eager") ? "preload" : "prefetch" }}" as="image" imagesrcset="{% for i in resolutions %}{{ resize_image_url(module.img.src, module.img.width*i)}} {{(module.img.width*i)}}w{% if not loop.last %},{% endif %}{% endfor %}" imagesizes="(max-width: {{module.img.width}}px) 100vw, {{module.img.width}}px">
    {% end_require_head %}
    <img data-blur-up src="{{ module.img.src }}" alt="{{ module.img.alt }}" {{ loadingAttr }} {{ sizeAttrs }}}}>
  {% endif %}
  

Here's the meat and potatoes of the module logic, but it's pretty straight forward when broken down. Here we are handling the three main ways images are declared using the default HubSpot image module fields:

  1. Fully responsive images (no width/height declared, so all sizing is relative to other elements on the page)
  2. Responsive images with a max-width (same as case one, but we can assume the image will not ever load larger than the max-width delared)
  3. Images with a set width/height within the layout

For responsive images, we will use the HubSpot resize_image_url function to first generate a small placeholder image for the module. Here I've gone with 50px wide, as it strikes a good balance between file size and still loading enough detail that the transition is still pleasing for the user. We also then assign the full-resolution image source to a data-attribute for our JS along with a data-auto-size attribute for targeting in our script.

With max-width images, we go a step further in this section by implementing our prefetch and preload attributes as well as generating a custom source set. For the 'pre' section, we choose which method is appropriate based on the users loading attribute choice (lazy or eager) and place a element in the document head via the require_head function. Both the link and image tag also receive a generated source set based on our resolution variables declared above.

Finally, when we know the width and height for the image, all we need to do is add the preload/prefetch logic, because HubSpot will automatically generate the source set for any image with a defined width and height.

You can view the full HTML file on GitHub.

JavaScript

JavaScript

// Get all data-blur-up and data-auto-size elements
const blurUpImages = document.querySelectorAll('img[data-blur-up]');
const autoSizeImages = document.querySelectorAll('*[data-auto-size]');

// Set all blur-up image parents to overflow:hidden, so that the blur filter doesn't go beyond the bounds of the image itself
blurUpImages.forEach(image => {
    image.parentNode.style.overflow = "hidden";
});

// Determine the correct size the image *should* be based on the page layout, then apply the correct aspect ratio to get an optimized image
autoSizeImages.forEach(image => {
    const imageAspectRatio = image.height / image.width;
    const dataSrc = image.getAttribute("data-src");
    const fileName = dataSrc.split("/")[dataSrc.split("/").length - 1];
    // If the image is linked, the 'a' element will not have an inherent scroll width/height, so get the next parent.
    let parentWidth = 0;
    if (image.parentNode.scrollWidth > 0) {
        parentWidth = image.parentNode.scrollWidth;
    } else {
        parentWidth = image.parentNode.parentNode.scrollWidth;
    }
    // replate "hubsf" with "hub" to take advantage of HS's image resizing API, then get the image at 2x the intended size (for retina displays)
    const newSrc = dataSrc.replace('hubfs', 'hub') + `?width=${Math.round(parentWidth * 2)}&name=${fileName}`;
    image.setAttribute('width', parentWidth);
    image.setAttribute('height', Math.round(parentWidth * imageAspectRatio));
    image.setAttribute('data-src', newSrc);
})

After setting up some base variables looking for our blur-up and auto-sized (or responsive) images, we loop through each image and hide any overflow from ourside their parent element. This compliments the earlier styles applied in the CSS section, but also applys the style to the anchor element in addition to the image if a link is applied at the page level.

Next, for all the responsive images, we calculate what the intended aspect ratio of the image is (width by height) and look to the image's parent to see how wide the image should be. Using these two variables, we can then load a properly sized image in the data-src attribute for use later.

JavaScript

// Watch for when images enter the viewport, then replace the source if needed and then add the "data-loaded" attribute
function watchImages() {
    let imageObserver = new IntersectionObserver((entries, imageObserver) => { 
    entries.forEach(entry => {
    if(entry.isIntersecting){
        if (entry.target.hasAttribute("data-src")) {
            entry.target.setAttribute("src", entry.target.getAttribute("data-src"));
        }
        setTimeout(() => {
            entry.target.setAttribute('data-loaded', '');
        }, 500)
        imageObserver.unobserve(entry.target);
    }
    });
    }, {rootMargin: "0px 0px -200px 0px"});
    blurUpImages.forEach(section => { imageObserver.observe(section) });
}

This is a fun section, using a relatively newer JavaScript API called Intersection Observer. Intersection Observer is a way to trigger events when certain elements are visible on the users screen as they scroll.

Here, we create a function for whenever an image scrolls into view, if it is a responsive image we will replace the placeholder source with the full-resolution source, and for all images add a data-loaded attribute which will remove the blur applied in the CSS section.

JavaScript

// On load, watch the images and add prefetch links for the auto-sized images
window.addEventListener("load", () => {
    watchImages();
    autoSizeImages.forEach(image => {
        const prefetchLink = document.createElement("link");
        prefetchLink.href = image.getAttribute("data-src");
        prefetchLink.rel = "prefetch";
        prefetchLink.as = "image";
        document.head.appendChild(prefetchLink);
    });
})

Finally, when the document is loaded, we will run the watch function we declared above and also prefetch all the responsive image sources so they are ready to be loaded as early as possible.

Final Thoughts on the HubSpot Blur-Up Image Effect

We've built this module as a drop-in replacement for the default HubSpot image module, but that doesn't mean the technique needs to stop there! Both the CSS and JS that power this effect are built to be global, so you could easily add that code to your global files. This would allow you to even take advantage of the effect even within a blog post for example (as we've done in this post!), by adding data-blur-up to the image element via the 'view source' tool.

We hope you'll give this module a try on your HubSpot CMS website. We love pushing the CMS Hub to its limits in terms of both performance and UX, and if you're interested in more content like this, be sure to join our mailing list using the form below!