Next on my list of optimizations I wanted to accomplish (and eager to learn) was Lazy-loading of images and using a “modern” image format for the web.
After Materializing the site, I’ve been doing a lot of cleanup of CSS to help make the site more responsive. I referenced some W3C and Bootstrap best practices and changed as many px
to rem
/em
as seemed reasonable (I think all that remains is the base font size and some 1px shifts for minor things). I’ve also added a scroll to top button, table of contents and a few other quality of life changes.
With the responsive design changes in CSS, it’s time to look into applying that to the images.
Removing unused CSSMinifying/Removing FontAwesome- Minimizing requests to 3rd parties (fonts, metrics, etc)
- Remove Google Fonts while still looking appealing
- Minimizing use of JavaScript
Converting Images to WebP or some other optimized formats
Choosing Image Formats
The base format for images I am using is going to be png
. It’s the format I’m most familiar with and with 99% of my images coming from a digital nature (ie not from a camera) it makes the most sense for me.
For the “modern” image formats there was a few options, but really only one logical choice.
Format | Browser Support |
---|---|
WebP | ~93.5% |
AVIF | ~63.0% |
JPEG 2000 | ~18.5% |
JPEG XL | 0% |
Maybe in the future AVIF
will be more widespread, but right now WebP
is the safe choice to start with.
Lazy Load Javascript
First thing to start with is the Javascript as it will help explain how I am setting up the Picture
elements later to work with Lazyloading.
We will be adding loading="lazy"
to our images, so we need to query all of those
const lazyImages = document.querySelectorAll('img[loading="lazy"]');
Next is the function to “load” the image. It doesn’t really load it, but instead the src is swapped and defers to the browser to do it’s thing. In the partials that are created later on, we initially have the img.src
set to a blurry placeholder, so we need to swap in the real image. There is a field added to the source
element (data-srcset
) and one to the img
element (data-src
).
So the function is basically moving
source.data-srcset
->source.srcset
img.data-src
->img.src
function loadImg(img)
{
const source = img.parentElement.querySelector('source');
source.srcset = source.getAttribute('data-srcset');
img.src = img.getAttribute('data-src');
}
Next we need to check if the browser has native lazy loading support. As of writing, ~74% browsers support it.. The main browser lacking the support is Safari, but it’s apparently close to being done.
With native lazy loading, we can just instantly defer all the images to the browser itself as it will handle the lazy loading of the images for us. We just need to iterate through the images and swap the srcs to the actual images.
if ('loading' in HTMLImageElement.prototype) {
lazyImages.forEach(img => { loadImg(img); })
}
For the browsers that don’t support native lazy loading, anything that is set in the src
and srcset
are instantly loaded. So what we want is to wait until the image is in the viewport before it loads the full sized image.
To achieve this we use the IntersectionObserver
. When the image has even a pixel on the screen, isIntersecting
is true and we swap the src
. While it’s deferred to the browser like in native lazy loading, it is actually fully loaded.
else {
const imgObserver = new IntersectionObserver((entries, self) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
self.unobserve(entry.target);
loadImg(entry.target);
}
});
});
lazyImages.forEach(img => { imgObserver.observe(img); })
}
And here is the script in total
//Lazy-load
function loadImg(img)
{
const source = img.parentElement.querySelector('source');
source.srcset = source.getAttribute('data-srcset');
img.src = img.getAttribute('data-src');
}
const lazyImages = document.querySelectorAll('img[loading="lazy"]');
if ('loading' in HTMLImageElement.prototype) {
lazyImages.forEach(img => { loadImg(img); })
}
else {
const imgObserver = new IntersectionObserver((entries, self) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
self.unobserve(entry.target);
loadImg(entry.target);
}
});
});
lazyImages.forEach(img => { imgObserver.observe(img); })
}
Making WebP and Placeholder Images
I downloaded the latest precompiled cwebp and placed in my ./bin
folder of my repo (obviously feel free to place somewhere else).
I then made a script that will search all my folders for png
files, ignoring favicons and placeholders.
It will then check if an existing image exists and if not create one for the following
- Full-sized
webp
version of the png - Placeholder
png
(width set to 16px - height auto) - Placeholder
webp
(width set to 16px - height auto)
#!/usr/bin/env bash
var=`find . -not -path "./public/*" -not -name "apple-touch-icon*" -not -name "favicon*" -not -name "*-placeholder*" -name "*.png" | sed 's/.png//'`
for fileName in $var
do
echo "$fileName"
if [ ! -f "$fileName".webp ]; then
./bin/cwebp -preset picture -q 100 -m 6 -lossless -z 9 "$fileName".png -o "$fileName".webp //digital
#./bin/cwebp -preset photo -q 100 -m 6 "$fileName".png -o "$fileName".webp //analog
fi
if [ ! -f "$fileName"-placeholder.png ]; then
convert "$fileName".png -resize 16 -quality 00 png8:"$fileName"-placeholder.png
fi
if [ ! -f "$fileName"-placeholder.webp ]; then
./bin/cwebp "$fileName"-placeholder.png -o "$fileName"-placeholder.webp
fi
done
I did a lot of testing and went with lossless compression on the pngs. It actually compressed the best and looks great. I am guessing it’s due to most of my images being digital screencaps. 🤷
HUGO Partials
Now that we got the images, its time to create the two partials that we will need. A normal partial and a special partial that Hugo hooks into.
render-image.html
We’ll start with the one that Hugo hooks into as it’s fairly simple. There are 3 of these that hugo uses (read more).
This allows the pretty great markdown image short code in Hugo to use the partial to render.
![Some Image Caption](image.png)
So create the file layouts/_default/_markup/render-image.html
//layouts/_default/_markup/render-image.html
<figure>
{{ $image := .Page.Resources.GetMatch (printf "%s" (.Destination | safeURL)) }}
{{ partial "image" (dict "path" .Destination "alt" .Text "width" $image.Width "height" $image.Height)}}
<figcaption>
<p class="caption">{{ .Text }}</p>
</figcaption>
</figure>
The figure
stuff can be ignored, the only things that really matter are
{{ $image := .Page.Resources.GetMatch (printf "%s" (.Destination | safeURL)) }}
{{ partial "image" (dict "path" .Destination "alt" .Text "width" $image.Width "height" $image.Height)}}
This will grab the image stats and pass them to the image
partial we’re going to create next. To breakdown the options a bit
- “path” .Destination
- Where the Image png is
- “alt” .Text
- Alt text to be placed on the image
- “width” $image.Width
- Image Width
- “height” $image.Height
- Image Height
image.html
Now for the main partial file. Create a ./layouts/partials/image.html
.
As talked about before, one of the things being passed in is path
. We need to use this to construct all the other image paths for the images we created (webp, placeholders) in the script.
{{ $webpPath:= (replace .path ".png" ".webp")}}
{{ $webpPlaceholderPath:= (replace .path ".png" "-placeholder.webp")}}
{{ $pngPlaceholderPath:= (replace .path ".png" "-placeholder.png")}}
We create a source
element and set the normal srcset
to the placeholder image, and a custom data-srcset
that points to the full-size webp.
<source
srcset="{{ $webpPlaceholderPath | safeURL }}"
data-srcset="{{ $webpPath | safeURL }}"
type="image/webp" >
We then create an img
element and set the normal src
to the placeholder image, and a custom data-src
that points to the full-size png. Also set some other good practice attributes and add loading="lazy"
.
<img
src="{{ $pngPlaceholderPath | safeURL }}"
data-src="{{ .path | safeURL }}"
type="image/png"
loading="lazy"
alt="{{ .alt }}"
width="{{ .width }}"
height="{{ .height }}" >
And here is the partial in full.
<picture class="{{ .class }}">
{{ $webpPath:= (replace .path ".png" ".webp")}}
{{ $webpPlaceholderPath:= (replace .path ".png" "-placeholder.webp")}}
<source
srcset="{{ $webpPlaceholderPath | safeURL }}"
data-srcset="{{ $webpPath | safeURL }}"
type="image/webp" >
{{ $pngPlaceholderPath:= (replace .path ".png" "-placeholder.png")}}
<img
src="{{ $pngPlaceholderPath | safeURL }}"
data-src="{{ .path | safeURL }}"
type="image/png"
alt="{{ .alt }}"
loading="lazy"
width="{{ .width }}"
height="{{ .height }}" >
</picture>
Now this partial can be used around the site similar to how the render-image.html
uses it. Here is a snippet of how I use it for the Avatar on the homepage.
{{ partial "image" (dict "path" "images/avatar.png" "class" "avatar" "alt" "avatar" "width" "200" "height" "200")}}
Pretty easy.
Responsive Images
So. I wanted to do responsive images, where it serves smaller images depending on the screen size, but ran into some issues.
I have a working modified partial that handles it great. But no matter what tool I used to resize the images, the smaller (in dimension) images were either larger (in file size) than the original or had terrible artifacts.
Here is a breakdown and order of stuff I tried. For stats on these I will compare the original ~1000px width webp image (~25kb) to a target image with width of 640px.
- Webp resize caused terrible banding on solid colors.
- Added Dithering prior to Webp resize. Was still somewhat noticeable plus the file size increased (~40kb)
- Resized PNG using ImageMagick and then converted to Webp. Artifacts gone but Webp was large (~120kb)
- Resized PNG using ImageMagick, compressed png with pngquant and then converted to Webp. Better, but still large (~60kb)
- Resized PNG using ImageMagick, compressed png with optipng and then converted to Webp. Better, but still large (~70kb)
For each step, I tried a variety of different webp
settings to reduce the size. I also tried a bunch of settings for ImageMagick
and the compressors but just couldn’t get it to work.
So while I would have loved to have responsive images, I was spending too much time to get it to work. At the end of the day, I think a ~25kb for a full sized image is actually really good, so I am not going to be spending more time on it for now.
Summary
Getting the base webp structure and lazy loading working was pretty simple to do and were fun topics to read up on. These are the kind of things I love to read up on.
I didn’t know about the Picture
tag before doing this and how browsers try to load webp images and have basically a fail-over if it doesn’t support webp.
Reading up on the native and all the different js lazy-loading techniques of elements was very interesting as well.
A next step for future me to look into would maybe adding AVIF
support on top of this logic and maybe cropping the width of images that are too large at max size. But that’s lower on my list right now.