Art Directed Responsive Images with Craft

A while ago I wrote an article on how to use responsive images with Craft CMS.

The point of that article was to allow you to serve a small image to a small screen, and a big image to a big screen, while only uploading one image to the CMS.

This article is going to expand on that idea to allow a designer to upload a different image for each breakpoint that they want to, and also touch on bits I’ve reworked since the last article.

Here we go.

What’s the point?

You’re on a desktop. You have an image that’s 1440px wide. It’s had loads of detail and it’s really cool.

You go on your mobile. You still have a 1440px wide image, but it’s been squashed down. That’s very uncool because you’re downloading all these pixels you’re not even seeing.

Also, that image with all it’s lovely detail now is too busy for the screen size and doesn’t really look very good. The designer didn’t want the image to look like this, but the developer said

Sorry, images get cropped centre-centre automatically.

Responsive images.

HTML has a way of tackling the challenge of serving big images to big screens and small images to small screens. There are many ways to go about it, but my method of choice is the picture element.

<picture>
<source srcset="big-image.jpg" media="(min-width: 1041px)">
<source srcset="medium-image.jpg" media="(min-width: 801px)">
<source srcset="small-image.jpg" media="(min-width: 301px)"></picture>

But that doesn’t integrate at all with the CMS, unless you want to upload big-image.jpg, medium-image.jpg and small-image.jpg for every image you want on the site.

Define your transforms.

The first thing to do is move the definitions of your image transforms out of the CMS and into the codebase. Assuming the developers were the only one changing those values anyway, this is a totally fine thing to do.

I wrote a small plugin called JsonTransforms which allows you to define your transforms as a json file in this format:

{
"default" : {
"quality" : 82,
"position" : "center-center",
"mode" : "crop"
},
"example-transform" : {
"default" : {
"format" : "png"
},
"large" : {
"width" : 1440
},
"medium" : {
"width" : 800
},
"small" : {
"width" : 400
}
}
}

So you’ve got your default settings (82% quality, crop centre-centre)

You’ve got a base transform (example-transform), which can have it’s own default overrides (format: png).

And within each transform you’ve got more settings for each size, so example-transform on a small screen you only want to be 400px wide.

When you ask for the small version of the example-transform, it merges the necessary arrays together and returns you a transform object. For example

craft.JsonTransforms.getTransform("example-transform", "small")

will return you

{
"quality" : 82,
"position" : "center-center",
"mode" : "crop",
"format" : "png",
"width" : 400
}

which you can now use with Craft’s standard getUrl() function. Putting that together in a nice macro, you get

{% macro getTransform(image, baseTransform, size) %}
    {% set thisTransform = craft.JsonTransforms.getTransform("example-transform", "small") %}
    {{ image.getUrl(thisTransform) }}
{% endmacro %}

Going back to our picture element, we can use that macro for each size we define.

<picture>
<source srcset="{{ macros.getTransform(image, "example-transform", "large") }}" media="(min-width: 1041px)">
<source srcset="{{ macros.getTransform(image, "example-transform", "medium") }}" media="(min-width: 801px)">
<source srcset="{{ macros.getTransform(image, "example-transform", "small") }}" media="(min-width: 301px)">
</picture>

Happy days. :D

Adding in some direction

Wicked, so we upload one image to the CMS and are able to generate big versions for big screens and small versions for small screens.

Now your designer says that the automatic centre-centre crop you’re using for the mobile screen looks all wrong. It’s fair enough for a variety of reasons; the image is too busy for that screen size, the focal point of the photo is not bang on centre, the darker parts we were overlaying text on are now cropped out. Or just visually it looks imbalanced.

Over to the CMS.

To tackle this, we’ll create a new matrix field called Alternative Images. Well, that’s what I called mine.

Each block in that matrix field has a context and an image. The context is a dropdown (well, I used a dropdown) of your breakpoints — in this example, “large”, “medium”, “small”. And the image is a place to upload the image you want to appear in that context.

We’ll assign the Alternative Images field to each image asset. Then the designer can go into the CMS and add an alternative “small” image to an asset if they want.

Back to the code.

I’m making two assumptions here:

  1. We’re working desktop first. So we’re thinking large-medium-small, not small-medium-large.
  2. If we define an alternative image, we want to use that one from now on unless specified otherwise. For example, we define a new medium image, so we want to use that image on our small size as well, unless we also specify an alternative small image.

But these things are easily adapted. This is just how my brain works.

So we need to map the contexts from the CMS to real life breakpoint values — pixels, ems, whatever you want.

{% set breakpoints = [{'large':'1440px', 'medium':'800px', 'small':'400px'}] %}

then we need to loop through those, grabbing any alternative images that match each breakpoint, and create an object.

{% set imageMap = [] %}
{% set currentImage = image %}
{% set altImages = image['alternativeImages'] ?? [] %}
{% for key,value in breakpoints %}
{% for altImage in altImages %}
{% if altImage.context.value == key %}
{% set currentImage = altImage.image.first() ?? currentImage %}
{% endif %}
{% endfor %}
{% set map = {(''~key) : currentImage} %}
{% set imageMap = imageMap|merge(map) %}
{% endfor %}

Will create you an array of contexts and their images, like so:

[
'large' => 'bear.jpg',
'medium' => 'bear.jpg',
'small' => 'small-bear.jpg'
]

Now using this with our picture element, and adding in a fun loop:

<picture>
{% for key,value in breakpoints %}
{% set image = imageMap[key] ?? image %}
    <source srcset="{{ core.getTransform(image, baseTransform, key) }}" media="(min-width: {{ value }})">
  {% endfor %}
</picture>

So yeah.

We’re now serving big images to big screens and small images to small screens, saving valuable data.

We’re allowing designers to artwork images per screen size if they want to, keeping everyone happy.

We’ve got a bunch of stuff that used to be in the admin now in code, so it can be version controlled.

Hooray!

If you want to see the full code for this, head over to my Craft Core project, paying particular attention to the picture partial, the getTransform macro, and the transforms json, which depends on you having the JsonTransforms plugin.