creating a photo gallery in hugo

Jan 11, 2020

It started during a trip through Europe. I kept noticing interesting doors, and struck by the mixture of functionality mixed with art. Doors are definitive transition points from one space to another, which further sparks the intrigue. They are physical pieces we use every day that litter our language with metaphorical and spiritual leanings.

I have nearly a hundred of them on my camera roll, and this project was iterated on while attempting to display them on my Hugo site without destroying page load times. You can see the result at my doors gallery.

The problem

A photo gallery with 90+ high-resolution images is brutal on page load. Even with compressed JPEGs, you’re looking at tens of megabytes of data that the browser tries to fetch all at once. Most of those images aren’t even visible until the user scrolls down.

The solution: lazy loading

Lazy loading defers image loading until the image is about to enter the viewport. The browser loads a tiny placeholder initially, then swaps in the full image only when the user scrolls near it. This means initial page load is fast, and bandwidth is only used for images the user actually sees.

Implementation

This is a naive, manual approach to lazy loading. At the time I built this (early 2020), the native loading="lazy" attribute had only just landed in Chrome (July 2019) and Firefox (April 2020), and Safari wouldn’t support it until March 2022. Rolling your own was still the norm.

My approach has three parts: data management in Hugo, HTML structure with placeholder images, and JavaScript to handle the swap.

1. Photo data in YAML

Instead of hardcoding image paths in templates, I keep photo metadata in a YAML data file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Collection: doors
Photos:
- Door:
  URL: /doors/eze01
  Date: 02/02/2018
  Description: Eze, France
- Door:
  URL: /doors/bruge01
  Date: 22/01/2018
  Description: Bruges, Belgium

This makes it easy to add new photos without touching templates.

2. Hugo partial with lazy attributes

The partial template generates images with the lazy class and data-src attributes:

1
2
3
4
5
6
<img class="lazy"
     src="{{$url}}_lazy.jpg"
     data-src="{{$url}}.jpg"
     data-srcset="{{$url}}.jpg 1x"
     height=400
     width=300 />

The src points to a tiny placeholder image (I create these by resizing originals to ~20px wide and heavily compressing them). The data-src holds the real image URL. Setting explicit height and width prevents layout shift as images load.

3. JavaScript lazy loader

The JavaScript watches for scroll, resize, and orientation change events. When an image with the lazy class enters the viewport, it swaps data-src into src:

 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
document.addEventListener("DOMContentLoaded", function () {
  let lazyImages = [].slice.call(document.querySelectorAll("img.lazy"));
  let active = false;

  const lazyLoad = function () {
    if (active === false) {
      active = true;

      setTimeout(function () {
        lazyImages.forEach(function (lazyImage) {
          if ((lazyImage.getBoundingClientRect().top <= window.innerHeight &&
               lazyImage.getBoundingClientRect().bottom >= 0) &&
               getComputedStyle(lazyImage).display !== "none") {
            lazyImage.src = lazyImage.dataset.src;
            lazyImage.srcset = lazyImage.dataset.srcset;
            lazyImage.classList.remove("lazy");

            lazyImages = lazyImages.filter(function (image) {
              return image !== lazyImage;
            });
          }
        });
        active = false;
      }, 200);
    }
  };

  document.addEventListener("scroll", lazyLoad);
  window.addEventListener("resize", lazyLoad);
  window.addEventListener("orientationchange", lazyLoad);

  // Load images already in viewport on page load
  lazyLoad();
});

The active flag and setTimeout act as a simple throttle to prevent the function from firing too frequently during scroll.

Creating placeholder images with sips

For the placeholders, I needed a way to batch-create tiny, low-quality versions of each image. On macOS, there’s a built-in command called sips (Scriptable Image Processing System) that handles this without installing anything.

You can check out the full manual with man sips, but the key options are:

Here’s the script I used to create placeholders for all my door photos:

1
2
3
4
for img in *.jpg; do
  sips -z 40 30 "$img" --out "${img%.jpg}_lazy.jpg"
  sips -s formatOptions 20 "${img%.jpg}_lazy.jpg"
done

This resizes each image to 40x30 pixels and compresses it to 20% quality. The resulting placeholders are typically under 1KB each, so the entire gallery loads almost instantly while the full images lazy load in the background.

What I’d do differently

If I were starting fresh today, I’d just use the browser’s native loading="lazy" attribute. It’s a single attribute on the img tag and all major browsers support it now. But this manual approach taught me how lazy loading actually works under the hood: viewport detection with getBoundingClientRect(), event throttling, and the data attribute swap pattern that was standard before native support arrived.