Component case study: Generating responsive images with ProcessWire and Twig

Modern web design and development is rarely done in terms of finished pages. Instead, current trends lean towards a module- or component-based approach. This can be seen in the tooling available for web design (Figma, Sketch) and in the popular frameworks for web development (React, Vue, etc). The tutorial on Content Sections demonstrates one such approach to building pages with dynamic content sections. Beyond that, working in terms of reusable components can be a major productivity boost, especially when you’re building base components that can be used in multiple sites.

This tutorial is a case study that demonstrates how to build a flexible, developer-friendly component for a responsive image component (the first section contains an introduction to responsive images in HTML). The component will be generic enough to use in most sites.

Introduction to responsive images and requirements #

If you want an in-depth explanation, there’s an excellent article about responsive images on MDN. The short version is that a responsive image tag is simply an <img>-tag that includes a couple of alternative image sources with different resolutions for the browser to choose from. This way, smaller screens can download the small image variant and save data, whereas high-resolution displays can download the extra-large variants for a crisp display experience. The standard defines two HTML attributes which inform the browser about the available image sizes and help them pick the one:

This is what a complete responsive image tag may look like:

<img srcset="/images/responsive-image.300x0.jpg 300w,
/images/responsive-image.600x0.jpg 600w,
/images/responsive-image.900x0.jpg 900w,
/images/responsive-image.1200x0.jpg 1200w,
/images/responsive-image.1800x0.jpg 1800w,
/images/responsive-image.2400x0.jpg 2400w"

sizes="(min-width: 1140px) 350px,
(min-width: 992px) 480px,
(min-width: 576px) 540px,
100vw"

src="/images/responsive-image.1200x0.jpg" alt="A responsive image">

This tells the browser that there are six different sources for this image available, ranging from 300px to 2400px wide variants. It also tells the browser how wide the space for the image will be:

The sizes queries are checked in order of appearance and the browser uses the first one that matches. This way, the browser can calculate how large the image needs to be and then select the best fit from the srcset list to download. Because it’s up to the browser to decide which image to pick, it can factor in variables like window size, device pixel ratio and network conditions. For browsers that don’t support responsive images, a medium-sized variant is included as the normal src attribute.

A helpful responsive image component will do the following:

The component will consist of two parts: A PHP function that generates the image variants and a Twig template that calls this function with the appropriate arguments and renders the <img> tag. This way, any Twig template can include the component with arbitrary arguments.

Writing a PHP function to generate image variations #

At the core of the component is a function that physically generates all the image variants listed in the srcset on the server. It will accept the following arguments:

The combination of base size and scaling factors are only one possible approach. Another (maybe slightly more intuitive) solution would be to have the function accept an array of sizes to generate. I prefer scaling factors, because it’s easier to provide sensible defaults for those, as I will demonstrate below.

Here’s a function that accepts the arguments listed above, generates the image variants and returns a srcset attribute string listing all the available variants. See below for more explanations.

/**
* Returns a srcset string containing a list of sources for the passed image,
* based on the provided width, height, and variant factors. All required images
* will be created automatically.
*
* @param Pageimage $img The base image. Must be passed in the largest size available.
* @param int|null $standard_width The standard width for the generated image. Use NULL to use the inherent width of the passed image.
* @param int|null $standard_height The standard height for the generated image. Use NULL to use the inherent height of the passed image.
* @param array $variant_factors The multiplication factors for the alternate resolutions.
* @return string
*/

public function getSrcset(
Pageimage $img,
?int $standard_width = null,
?int $standard_height = null,
array $variant_factors = [0.25, 0.5, 0.75, 1, 1.5, 2]
): string {
// use inherit dimensions of the passed image if standard width/height is empty
if (empty($standard_width)) {
$standard_width = $img->width();
}
if (empty($standard_height)) {
$standard_height = $img->height();
}

// get original image for resizing
$original_image = $img->getOriginal() ?? $img;

// build the srcset attribute string, and generate the corresponding widths
$srcset = [];
foreach ($variant_factors as $factor) {
// round up, srcset doesn't allow fractions
$width = (int) ceil($standard_width * $factor);
$height = (int) ceil($standard_height * $factor);
// we won't upscale images
if ($width <= $original_image->width() && $height <= $original_image->height()) {
$current_image = $original_image->maxSize($width, $height);
$srcset[] = $current_image->url() . " {$width}w";
}
}
$srcset = implode(', ', $srcset);

return $srcset;
}

// usage example
$image = $page->my_image_field;
// build the srcset string and generate the missing images
// this will generate the following sizes: 200x200, 400x400, 800x800
$srcset = getSrcset($image, 400, 400, [0.5, 1, 2]);

Things to note:

There’s one problem remaining. Often times your templates will need to support different aspect ratios of images, especially for CMS-driven content. For a normal multi-column layout, you will only know the width of the column the image will go into (which can of course vary between different sizes), but the height depends on the aspect ratio of the uploaded image. The problem is that if you only specify a width in the example above, the image will be cropped based on the intrinsic height of the image. This can be solved by adding two additional functions that take only a width or a height argument, respectively, and determine the other one based on the aspect ratio of the passed image (the example code shows only the width function, check below for a full code example).

/**
* Shortcut for getSrcset that only takes a width parameter.
* Height is automatically generated based on the aspect ratio of the passed image.
*
* @param Pageimage $img The base image. Must be passed in the largest size available.
* @param int|null $standard_width The standard width for this image. Use NULL to use the inherent size of the passed image.
* @param array $variant_factors The multiplication factors for the alternate resolutions.
* @return string
*/

public function getSrcsetByWidth(
Pageimage $img,
?int $standard_width = null,
array $variant_factors = [0.25, 0.5, 0.75, 1, 1.5, 2]
): string {
if (empty($standard_width)) {
$standard_width = $img->width();
}
// automatically fill the height parameter based
// on the aspect ratio of the passed image
$factor = $img->height() / $img->width();
$standard_height = ceil($factor * $standard_width);

return getSrcset(
$img,
$standard_width,
(int) $standard_height,
$variant_factors
);
}

With this set of base functions that generate all required image variants and return the srcset, the rest of the component can be written in Twig. Don’t forget to add the PHP functions to the Twig environment:


$twigEnvironment->addFunction(
new \Twig\TwigFunction('getSrcset', 'getSrcset')
);
$twigEnvironment->addFunction(
new \Twig\TwigFunction('getSrcsetByWidth', 'getSrcsetByWidth')
);
$twigEnvironment->addFunction(
new \Twig\TwigFunction('getSrcsetByHeight', 'getSrcsetByHeight')
);

Writing a responsive image component in Twig #

While the functional core of the component is done in PHP, the template can be written in Twig. In addition to passing the required parameters to the PHP method, the Twig component will accept some additional optional parameters that make it as reusable as possible:

The component can look something like this:

{# blocks/responsive-image.twig #}

{#-
# Renders a responsive image tag.
#
# @var \ProcessWire\Pageimage image The image to display.
# @var string alt Optional alt text to use for this image.
# @var string title Optional title text.
# @var array classes Optional classes for the image.
# @var int width The standard / fallback width for the image.
# @var int height The standard height for the image.
# @var array sizes The sizes queries for the responsive image.
# @var array variant_factors The variant factors for the responsive image.
# @var bool lazy Lazy load this image?
-#}


{%- set alt = alt|default(image.description) -%}
{%- set title = title|default(null) -%}
{%- set classes = classes|default([]) -%}
{%- set sizes = sizes is defined and sizes is not empty ? sizes|join(', ') : '100vw' -%}
{%- set variant_factors = variant_factors|default([0.25, 0.5, 0.75, 1, 1.5, 2]) -%}
{%- set lazy = lazy|default(false) -%}

{%- if width and height -%}
{%- set base_image = image.maxSize(width, height) -%}
{%- set srcset = getSrcset(image, width, height, variant_factors) -%}
{%- elseif width -%}
{%- set base_image = image.maxWidth(width) -%}
{%- set srcset = getSrcsetByWidth(image, width, variant_factors) -%}
{%- elseif height -%}
{%- set base_image = image.maxHeight(height) -%}
{%- set srcset = getSrcsetByHeight(image, height, variant_factors) -%}
{%- else -%}
{%- set base_image = image -%}
{%- set srcset = getSrcset(image, null, null, variant_factors) -%}
{%- endif -%}

<img srcset="{{ srcset }}" sizes="{{ sizes }}" src="{{ base_image.url }}"
{%- if alt %} alt="{{ alt }}"{% endif -%}
{%- if title %} title="{{ title }}"{% endif %}
{%- if lazy %} loading="lazy"{% endif -%}
{%- if classes is not empty %} class="{{ classes|map(c => c is not empty)|join(' ') }}"{% endif %}>

Some points to note:

Now that the component is finished, we can actually start to use it in our templates.

How to use the component and determine the sizes queries #

Twig templates can be included using the unsurprisingly named include function. It takes the name of the template to include, as well as any number of arguments to pass to the included template. Arguments are provided as associative arrays (see Hash literals), which is nice because it allows us to only specify the arguments we need. Here’s a barebones call that only passes an image:

{{ include('blocks/responsive-image.twig', {
image: page.my_image_field,
}) }}

But we can override the defaults by passing all the parameters we want:

{{ include('blocks/responsive-image.twig', {
image: page.my_image_field,
width: 500,
sizes: ['(min-width: 1200px) 33vw', '(min-width: 768px) 50vw', 'calc(100vw - 30px)'],
variant_factors: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.75, 0.9, 1, 1.25, 1.5, 2],
lazy: true,
classes: ['my-image-field', 'img-fluid']
}) }}

The last thing I want to mention here is how to determine the correct arguments for the sizes attribute. It’s important to realize that this is completely independent of the actual size of the image in most cases. Instead, it depends on the slot in your template where the image is going to be placed, and the size of that slot.

For example, let’s consider a full-width multi-column layout with the following column count per breakpoint:

In a full-page layout, the column widths can be described with viewport width units. Remember that 100vw is the full width of the viewport, so in a three-column layout each column will have a width of 33vw (rounded down). So the correct sizes attribute for this layout looks like this: (min-width: 1200px) 33vw', '(min-width: 768px) 50vw', 'calc(100vw - 30px). For the smallest breakpoint, the image needs to fill the full width of the viewport minus the fixed padding (15px to either side, so 30px total). This is accounted for with the calc function.

If you instead have a fixed-width container with a predefined width per breakpoint (like a normal container in Bootstrap), the slot for the image will have a fixed width (per breakpoint) as well, depending on the amount of columns it spans. In this case, the individual sizes should be specified in px instead of vw units.

Conclusion #

This finishes the case study on the responsive image. Now you might be thinking that all this was way too much work just for a simple responsive image component. But it’s going to be worth it once you start using responsive images everywhere. No more boilerplate code, difficult calculations of aspect ratios or repetitive loops. Just a single declarative include statement in your Twig template and you’re done.

The point is this: If you invest the time to build your components in the most generic way that’s possible and practical for your use case, it will pay off once you utilize it a couple of times. The general consideration for those components should be:

Happy designing!