Respon­sive images with Twig macros

This article will discuss responsive image markup and twig macros to automate generating that markup.

There are three things I hope you will get from this article:

  1. A quick overview of responsive images (and links to further resources).
  2. A useful twig macro for generating responsive image markup.
  3. Nerdy details about programming in twig, useful when you write your own twig macros.

Responsive Images

Here are some excellent articles with information on responsive images. If you know nothing about responsive images, read at least one of them, and then come back:

There are a number of responsive image use cases. This article will focus on two, both of which use the img tag (and not the picture tag).

Fixed-width images

The first use case is a fixed-width image (so not actually responsive...) that adapts to different device-pixel-ratios. Say you have a thumbnail image which is always displayed 200px wide, and you have versions for 1x (thumb.jpg) and 2x (thumb_2x.jpg). The markup for this is:

<img
  src="thumb.jpg"
  srcset="thumb_2x.jpg 2x"
  alt="thumb"
/>

Browsers that don't understand srcset will ignore that attribute, and download thumb.jpg. Browsers that do understand srcset will use thumb.jpg on devices with 1x resolution, and thumb_2x.jpg on devices with 2x resolution (or higher).

Variable-width images

The second use case is variable-width images with no art direction (so the only difference in the images is the resolution). Say you have an image that will be full-width on narrower screens (<= 30em), and 25em wide on larger screens. And you have this image available in three different sizes:

  • illo_small.jpg is 400px
  • illo_medium.jpg is 800px
  • illo_large.jpg is 1000px

The markup for this is:

<img
  src="illo_medium.jpg"
  srcset="illo_small.jpg 400w, illo_medium.jpg 800w, illo_large.jpg 1000w"
  sizes="(maxwidth 30em) 100vw, 25em"
  alt="illustration"
/>

Browsers that don't understand srcset and sizes will use the 800px version. Browsers that do understand srcset and sizes will know from the srcset attribute that the image is available in those three sizes, and from the sizes attribute that if the browser window is up to 30em the image will be sized at 100vw (which is 100% of the viewport width), and otherwise it will be 25em. At this point it is up to the browser to decide which one to download.

A very brief review of twig macros

Here is a simple twig macro definition:

{% macro greet(name='World') %}
  Hello, {{name}}!
{% endmacro %}

Every macro begins with the {% macro %} tag, and ends with the {% endmacro %} tag. This one takes one parameter (name), which has a default value of 'World'. To call it, you first need to import it.

{% macro greet(name='World') %}
  Hello, {{name}}!
{% endmacro %}

{# Import the macro into the file in which it is defined #}
{% import _self as self %}

{# Call your macro #}
{{ self.greet() }}

The example above will generate Hello, World!. To generate a different output, you can call the macro using a different name parameter.

{# This will generate `Hello, Dear Reader!` #}
{{ self.greet('Dear Reader') }}

If the macro is defined in a different file than the one where it is called, you import it (in the calling file):

{% import '_macros/_utils' as m_utils %}
{# Call an imported macro using the name you imported it with #}
{{ m_utils.greet() }}

What you name your macro files, and what you import them as are matters of convention. I use self when the macro is in the same file, and m_whatever when the macro is in the file _macros/_whatever.twig.

Twig macros for responsive images

A lot of the work in creating the responsive image markup can be automated. Rather than uploading the same image at different resolutions, we can use Craft's Image Transformations to generate the various image sizes.

I have a macro file, _img.twig that has macros for each of the two use cases (available on github). You pass these macros an image asset, and they generate markup for a responsive image.

One thing these macros do is try to be smart about which image transforms to ask Craft for. There is no point in Craft transforming an image larger than the original size. If you have a 400px image, and you need an 800px image, the browser should make that transformation. There is also no point in Craft transforming an image to the same size. If the image is originally 800px, do not transform it to 800px, just use the original image.

_img.twig defines two public macros (and a third for use only inside the file): - fixedSize() - Public - responsive() - Public - _classAttr() - Internal

Example of fixed-width image macro

fixedSize() is for the first use case - a fixed size image that adapts to different device-pixel-ratios. Here is an example of using it:

{% import '_macros/_img' as m_img %}
{% set thumbAsset = entry.assetField.first() %}

{# Example of a simple call to the macro #}
{{ m_img.fixedSize(thumbAsset, 200) }}

{# Example of more advanced call
   specifying alt attribute of 'some thumb' and class of 'thumb-class'
#}
{{ m_img.fixedSize(thumbAsset, 200, {alt: 'some thumb', class: ['thumb-class']}) }}

Example of responsive-width image macro

responsive() is for the second use case - a variable width image. Here is an example of using it:

{% import '_macros/_img' as m_img %}

{% set illoAsset = entry.assetField.first() %}

{# Example of a simple call to the macro #}
{{ m_img.responsive(illoAsset) }}

{# Example of another macro call using a different style #}
{{ m_img.responsive(thumbAsset, {style: 'thumb'}) }}

This is not quite all there is to using the responsive() macro. You also need to define what widths the image should be avaiable in (srcset), and how wide the image will be displayed (sizes). Details of that are below.

Taking a closer look at the _img.twig macro file

Here is an annotated version of the twig macro file, _img.twig. The annotations follow the code they describe.

The Class Attribute Macro: __classAttr()_

{% macro _classAttr(classes) %}
  {%- if (classes|length) -%}
    class="{{ classes|join(' ') }}"
  {%- endif -%}
{% endmacro %}

_classAttr is a macro intended to only be called from within _img.twig, which is why its name begins with _ (this is a convention of mine, not something the language enforces). If it turns out to have wider utility, I will factor it out into a file of utility macros (and rename it to classAttr).

_classAttr takes an array of class names, and returns a class attribute string. _classAttr(['foo', 'bar']) will return class="foo bar". _classAttr([]) will return an empty string. {%- and -%} are twig's tag level whitespace control, used to strip out whitespace the macro would otherwise generate. The join filter takes an array of strings and turns it into a string of space separated words.

The Fixed Size Macro: fixedSize()

{% macro fixedSize(asset, width, options={}) %}
{% import _self as self %}

This is the macro for the first use case. It is passed an asset, the width in px at which the image will be displayed, and [optionally] a hash of options.

Since we are going to use the _classAttr macro, defined in this file, we import it.

{% set options = {
  alt: asset.altText,
  class: []
}| merge(options) %}

We define default options, and merge them with the options which were passed in. Options passed in will override these default ones.

The two options are alt, and class. alt will be the alt text for the tag. All my image assets have a field altText, which is used as the default value. class is an array of class names, defaulting to none.

{% set transform = {
  mode: 'stretch'
} %}

We define the transform we will use. This transform is missing the width attribute. When we actually use it, we will merge in the width attribute. So to transform to 200: asset.getUrl(transform|merge({width: 200})). (The alternative would be asset.getUrl({mode: 'stretch', width: 200}), but I think using the transform variable is slightly easier to read).

{% set nativeWidth = asset.getWidth(false) %}

Set nativeWidth to the original, untransformed width of the image (getWidth(false) returns the original width).

<img
  {# src attr: transformed only if necessary #}
  {% if nativeWidth <= width %}
    src="{{ asset.getUrl(false) }}"
  {% else %}
    src="{{ asset.getUrl(transform|merge({width: width})) }}"
  {% endif %}

  {# srcset attr, but only if 2x <= nativeWidth #}
  {% if width*2 == nativeWidth %}
    srcset="{{ asset.getUrl(false) }} 2x"
  {% elseif width*2 < nativeWidth %}
    srcset="{{ asset.getUrl(transform|merge({width: width*2})) }} 2x"
  {% endif %}

  alt="{{options.alt}}"
  {{ self._classAttr(options.class) }}
/>
{% endmacro %}

Here we generate the img tag. First is the src attribute, which will be transformed down to the 1x size only if the nativeWidth is larger than that. Second is the srcset attribute. There are three cases here:

  • the nativeWidth exactly matches the 2x size - no transform
  • nativeWidth is larger than the 2x size - transform it down
  • nativeWidth is smaller than the 2x size - no srcset at all

Finally, we have our alt and class attributes.

The Responsive Macro: responsive()

{% macro responsive(asset, options={}) %}

{% import _self as self %}

  {% set options = {
    alt: asset.altText,
    class: [],
    style: 'default'
  }| merge(options) %}

  {% set transform = {
    mode: 'stretch'
  } %}

  {% set nativeWidth = asset.getWidth(false) %}

This is the macro for the second use case. It is passed an asset and an options hash. In addition to the alt and class values, there is a style (which defaults to default).

The style is used to pick the configuration for a particular style of responsiveness from the config hash (defined below).

We set options, transform, and nativeWidth just as in fixedSize().

{#
 # Here is where you configure the image styles.
 # You are going to have to modify this for your
 # individual site.
 #
 # config is a hash, where the key is the style,
 # and the value is another hash
 # of sizes, srcsetWidths, and defaultWidth.
 #
 # There should always be a 'default' style.
 # Redefine the 'default' to whatever makes sense
 # for you, and add other styles as needed.
 #
 # srcsetWidths: image widths that should appear
 #   in the srcset.
 # sizes: media queries that specify the widths
 #   of the image at different screen widths.
 #   The first one that matches is used.
 # defaultWidth: image width for the src image
 #   (fallback for browsers that don't understand
 #   srcset)
 #}

{% set config = {
  default: {
    srcsetWidths: [400, 800, 1000],
    sizes: [
      '(max-width: 30rem) 100vw',
      '25em'
    ],
    defaultWidth: 800
  },
  thumb: {
    srcsetWidths: [200, 400],
    sizes: [
      '200px'
    ],
    defaultWidth: 200
  }
} %}

config will need to be modified for your particular project. Here is where you specify the widths for srcset and the values for sizes. This default style is the same as the one from the illo example earlier in the article. The thumb style is another way of doing the 200px fixed width thumbs.

{% set params = config[options.style] %}

config defines multiple sets of style params. Fetch the one we will use.

{% set srcset = [asset.getUrl(false)~' '~nativeWidth~'w'] %}

srcset will be an array of strings (we will use join to turn them into one comma separated string later). Here we initialize the array with a single string for the nativeWidth image, which is always included. ~ is twig's concatenation operator. We concatenate the image url, a space, the width, and the string w, and set srcset to an array of just that string.

{% for width in params['srcsetWidths'] %}
  {% if width < nativeWidth %}
    {% set srcset
       = srcset|merge([asset.getUrl(transform|merge({width: width}))~' '~width~'w'])
    %}
  {% endif %}
{% endfor %}

Here we loop over the srcsetWidths, and add the width string to the srcset array for any that are less than the nativeWidth.

<img
  {# src attr: transformed only if necessary #}
  {% if nativeWidth <= params['defaultWidth'] %}
    src="{{ asset.getUrl(false) }}"
  {% else %}
    src="{{ asset.getUrl(transform|merge({width: params['defaultWidth']})) }}"
  {% endif %}

  srcset="{{ srcset|join(', ') }}"
  sizes="{{ params['sizes']|join(', ') }}"

  alt="{{options.alt}}"
  {{ self._classAttr(options.class) }}
/>
{% endmacro %}

Here we generate the img tag. First is the src attribute, which will be our defaultWidth if the image is large enough, and otherwise the nativeWidth. After that is the srcset attribute, where we join the srcset variable, this time putting ", " between the strings. Then the sizes attribute, similarly constructed with join. And finally the alt and class attributes.

Some Details

  • Responsive images are reasonably well supported by browsers (see caniuse data for details). Additionally, there is a polyfill which is very easy to use. Download it, and add this script tag:
<script src="{{siteUrl}}js/vendor/picturefill.min.js" async></script>
'generateTransformsBeforePageLoad' => true,

This generates the transform when getUrl() is called, rather than waiting for the browser to request the image. This will make the first page load a little bit slower, but it lets you cache the result of calling the macros.

  • You will need to specify the config styles for the responsive() macro, but you can wait to fill in the details of the styles until your site design is quite solid. And these values never have to be completely precise. Reasonably close is fine (though you probably want to get the breakpoint values exact).

  • Enjoy! Feel free to contact me with any questions: [email protected].

Level up in Craft CMS with practical examples, snippets, and patterns.
Craft The Planet emails are sent out several times a week.