Meridian handbook
Templates

Images

How to print responsive images from asset fields and theme files with img(), picture() and figure(), configure named transforms, breakpoints and art direction in config/images.yaml, and check what a page downloads.

Every image a template prints goes through named transforms: a shape (an aspect ratio and a fit), a ladder of widths, a list of formats and a default sizes. You declare the transforms once in config/images.yaml, and a template asks for one by name. The engine writes the srcset, sizes, width, height, alt and loading attributes, generates each derivative the first time it is requested, and never upscales.

A minimal example

# config/images.yaml
breakpoints:
  md: 48rem
  lg: 64rem

transforms:
  card:
    aspect: "3:2"
    widths: { min: 320, max: 1200 }
    sizes: "lg:33vw md:50vw 100vw"
  hero:
    aspect: "16:9"
    widths: { min: 640, max: 2560 }
    sizes: "100vw"
{# templates/_shared/article/entry.html #}
{% if article.heroImage %}
  {{ article.heroImage.figure("hero", priority=true, class="article__hero") }}
{% endif %}

{% for related in article.related %}
  {% if related.heroImage %}{{ related.heroImage.img("card") }}{% endif %}
{% endfor %}

figure("hero", priority=true) prints a <figure> holding a <picture> with a <source> per format, the <img> with its whole srcset, and the caption and credit in a <figcaption>. Because it is the page’s main image, it loads eagerly with fetchpriority="high", and the engine adds a preload for it to the <head>.

Run engine-dmo media transforms to see each transform resolved, with its ladder of widths and the formats this build serves.

Where image values come from

An image value is what each of these gives a template:

SourceExample
An asset field holding an imageentry.heroImage; with multiple: true, a list to loop over
A video’s posterentry.film.poster
A relation’s target, a promoted item, a list itemitem.heroImage
A file under the project’s public/ directoryimages.file("img/team.jpg")

An empty asset field is false, and its methods print nothing, so {% if entry.heroImage %} is only needed when you wrap the image in markup of your own. Printing the value itself, {{ entry.heroImage }}, prints the original file’s URL.

The field’s own configuration (which kinds of file an editor may choose, minimum dimensions, required alt text, per-use focal points and captions) is described in the asset field.

Methods

MethodPrints
img(transform, ...)An <img> with src, srcset, sizes, width, height, alt, loading and decoding="async"
picture(transform, ...)A <picture> with one <source> per format (and per art-direction source), then the <img>
figure(transform, ...)The picture inside a <figure>, with the caption and the credit line in a <figcaption>
transform(name, width=)A transform object, for markup you build by hand (below)
url(transform, width=, format=, absolute=)The address of one derivative; url read as a property is the original
link(text=, ...)Any asset: a download link announcing the type and size, such as “Annual report (PDF, 2.1 MB)”
audio(...)An audio asset: <audio controls> with a download link inside for a browser without audio

Options of img, picture and figure

OptionValuesDefaultEffect
sizesA shorthand such as "lg:33vw md:50vw 100vw", a full media list, or "auto"The transform’s sizesThe sizes attribute
prioritytrue or falsefalseMarks the page’s main image: loading="eager", fetchpriority="high" and a <link rel="preload" as="image"> with imagesrcset and imagesizes added to the head
loadinglazy or eagerlazyThe loading attribute, when priority is not set
fetchpriorityhigh, low or autononeThe fetchpriority attribute
placeholderdominant, blur or nonedefaults.placeholder in images.yaml, else dominantWhat paints the box before the image arrives
altA stringThe value’s alt textAlt text for this call only; alt="" marks it decorative here

Any other keyword becomes an attribute of the <img> (of the <figure>’s image for figure). Keywords may contain hyphens:

{{ entry.heroImage.picture("card", class="card__img", data-role="lead", aria-describedby="credit") }}

class is merged with the engine’s own classes. src, srcset, width, height and style belong to the helper and are refused, as are inline event handlers (onload=). A keyword one or two letters away from an option (sizez=) is reported as a misspelling.

The url method

OptionDefaultMeaning
first argumentnoneThe transform; with none, the original
widththe largest stepThe narrowest derivative at least this wide
formatthe transform’s fallback formatjpeg, png, webp or avif
absolutefalsetrue for an address on the site’s origin, which an Open Graph tag needs
<link rel="preload" as="image" href="{{ entry.heroImage.url('hero', width=1600) }}">
<meta property="og:image" content="{{ entry.heroImage.url('social', absolute=true) }}">

You rarely need the second line: head() writes the Open Graph image itself.

The transform object

transform(name, width=) returns the parts, for an <img> you write yourself:

Property or methodValue
srcThe fallback format’s derivative at the ladder’s middle width (with width=, the narrowest step at least that wide)
srcsetThe fallback format’s srcset
srcsetFor(format)One format’s srcset
sizesThe transform’s sizes, expanded from the shorthand
width, height, aspectThe output’s proportions for src
sourcesEach <source> a picture would print: type, media, srcset, sizes
placeholderThe blurred preview as a data URI
dominantThe dominant colour, #rrggbb
attributes(...)src, srcset, sizes, width and height as one attribute string, plus any keywords you pass
{% set t = entry.heroImage.transform("card") %}
<img {{ t.attributes(class="card__img") }} alt="{{ entry.heroImage.alt }}">

Prefer attributes(). A variable printed into src or srcset by hand must go through the url filter, and engine-dmo check warns about a hand-built <img> whose srcset has width descriptors and no sizes.

Properties

PropertyValue
urlThe original file’s URL
idThe asset’s id (empty for a theme file)
kindimage, document, audio or video
mime, extension, type, filename, titleThe media type, the lower-case extension, the type as a reader names it (JPG), the file name, the title (else the file name)
bytes, sizeThe size in bytes, and formatted for the request locale (2.1 MB)
width, height, aspect, orientationThe image’s dimensions after its crop, its width over its height, and landscape, portrait or square
alt, altLang, decorativeThe per-use alt text, else the asset’s for this site and locale; the locale it came from when it is a fallback; whether an empty alt is intended
caption, captionLangThe same for the caption
credit, creditRequiredThe rights credit line, and whether the licence requires it to be shown
focal{x, y}: the per-use focal point, else the asset’s
hotspot, crop{x, y, w, h} in fractions of the whole image, the per-use one else the asset’s, else empty
dominantThe dominant colour, #rrggbb

When the alt text or caption came from a fallback locale, img, picture and figure write it with a lang attribute, so a screen reader pronounces it correctly.

Credits

An image whose licence requires a credit (creditRequired) must show it. figure() always writes the credit. When you print an image with img() or picture(), engine-dmo check warns unless the same template prints the image’s credit:

{{ entry.heroImage.img("card") }}
{% if entry.heroImage.credit %}<p class="credit">{{ entry.heroImage.credit }}</p>{% endif %}

config/images.yaml

The install’s breakpoints, defaults and transforms. Without the file there are no transforms, and every image method needs one. A site may add or replace transforms in config/sites/<key>/images.yaml.

# config/images.yaml
breakpoints:                 # named min-widths, used by sizes shorthands and art direction
  md: 48rem
  lg: 64rem

defaults:
  formats: [avif, webp, jpeg]               # sources in this order; JPEG is the <img> fallback
  quality: { avif: 55, webp: 75, jpeg: 80 }
  widths: { min: 320, max: 2560, ratio: 1.25 }
  density: 2
  placeholder: dominant

transforms:
  thumb:
    width: 200                              # a fixed display width: 1x and 2x
    aspect: "1:1"
  card:
    aspect: "3:2"
    widths: { min: 320, max: 1200 }
    sizes: "lg:33vw md:50vw 100vw"
  hero:
    aspect: "16:9"
    widths: { min: 640, max: 2560 }
    sizes: "100vw"
  square:
    aspect: "1:1"
    widths: [480, 800, 1200]                # an explicit ladder
  hero-art:                                 # art direction: a square crop on narrow screens
    sources:
      - { below: md, transform: square }
      - { transform: hero }
  logo:
    width: 200
    fit: contain
    background: transparent
  social:
    width: 1200
    aspect: "1.91:1"
    formats: [jpeg]

breakpoints

A map of name to a positive CSS length in rem, em or px. Each is a min-width. from: lg in an art-direction source means (min-width: 64rem); below: md means (max-width: 47.99rem) (0.02 under for px).

defaults

KeyTypeBuilt-in defaultMeaning
formatslist of avif, webp, jpeg, png[avif, webp, jpeg]The formats each transform produces. A picture lists them as sources best first (AVIF, WebP, then the rest); the <img> fallback is JPEG when the list has it, else PNG, else the last format listed
qualitymap of format to integer{ avif: 55, webp: 75, jpeg: 80 }Per format; PNG is lossless
widths{ min, max, ratio }{ min: 320, max: 2560, ratio: 1.25 }The generated ladder
densityinteger2The highest pixel density a fixed-width transform serves
placeholderdominant, blur or nonedominantThe placeholder every image paints unless a call says otherwise

density and placeholder are defaults only; a transform cannot set them.

A transform

KeyTypeMeaning
aspectstring "W:H"The output’s proportions, such as "3:2" or "1.91:1"; without it, the source’s own
fitcover, contain or insidecover (the default) crops to the aspect around the hotspot and focal point; contain fits inside the box and pads with background; inside fits inside and keeps the source’s proportions
background#rrggbb or transparentThe padding colour for fit: contain; transparent padding becomes white in JPEG. With any other fit it is a warning
widthintegerA fixed display width, served at 1x up to densityx with x descriptors; such a transform writes no sizes
heightintegerOnly with width and no aspect; sets the aspect to width:height
widths{ min, max, ratio } or a list of integersThe ladder, as a range or used as written
formatslistReplaces the default formats
qualitymap of format to integerOver the default qualities
sizesstringThe default sizes, as a shorthand or a full media list
animatedfirst or keepAn animated GIF keeps its first frame (first, the default) or is served as the original GIF (keep)
sourceslist of { below or from, transform }Art direction (below). A transform with sources takes only sources and sizes

A transform has exactly one of width, widths (a list) or a range (widths: { ... }, else the default range). Widths are between 16 and 8,192 pixels. Names are lower-case letters, digits, hyphens and underscores; names starting with an underscore are reserved for the engine’s own transforms.

The width ladder

  • A range generates widths from min to max, each ratio times the last, rounded to a multiple of 8. A step within half a step of max is dropped in favour of max.
  • A list is used as written.
  • Nothing is upscaled. Widths above the widest box the source can fill are dropped, and that width joins the ladder as its top step, so a 1,000 pixel photograph in a 2,560 ladder serves at most 1,000 pixels.
  • A fixed-width transform serves its width at 1x, 2x and so on up to density.

engine-dmo media transforms --source-width 1000 shows each ladder for a source of that width.

The sizes shorthand

"lg:33vw md:50vw 100vw" names breakpoints from the largest down and ends with the default. It expands to (min-width: 64rem) 33vw, (min-width: 48rem) 50vw, 100vw. A string that is already a full media list is used as written. sizes="auto" on a lazy image writes sizes="auto, <the transform's sizes>", which browsers that support it size from the layout.

Art direction

A transform with sources renders a different crop per breakpoint:

transforms:
  hero-art:
    sources:
      - { below: md, transform: square }
      - { transform: hero }

picture("hero-art") prints a <source media="(max-width: 47.99rem)"> per format with the square crop, then the hero crop as the <img>. Only the last source may have no breakpoint; a source may not name another art-direction transform or the same breakpoint twice. With priority=true, the preload is the <img>’s own crop.

Per-site transforms

# config/sites/town/images.yaml
transforms:
  card:
    aspect: "4:3"
    widths: { min: 320, max: 1200 }
    sizes: "lg:33vw md:50vw 100vw"

A site’s transform replaces the install’s transform of the same name whole, not key by key: restate every key you need. Breakpoints and defaults are the install’s.

What check reports

engine-dmo check reports every problem in images.yaml together, each at its line and column: an unknown key, a breakpoint that is not a length, a breakpoint used and not declared, a ratio at or below 1, a min above max, an aspect that does not parse, an art-direction source naming an unknown transform or a breakpoint twice, a reserved name, and a transform whose largest output exceeds media.max_pixels. In templates it reports a literal transform name or sizes breakpoint that the site does not define (with a “did you mean”), an attribute the helper owns, an unknown keyword, and a second priority=true on the same page as a warning.

Formats and the build you run

What a build can encode depends on its image processor. The image pipeline line of engine-dmo version names it and what it encodes:

BuildEncodesNotes
The -vips image (libvips)AVIF, WebP, JPEG, PNGConverts to sRGB, strips metadata, sharpens lightly after downscaling
A build with the Go image processor (pure-go)JPEG, PNGConfigured AVIF and WebP are left out of the markup, never failed; engine-dmo check prints a note naming them

The note reads note: the image transforms name avif and webp, which this build cannot encode; it serves the rest, and the vips image serves all of them. It is not a warning and changes nothing: the page serves JPEG and PNG. A transform without formats inherits the default [avif, webp, jpeg], so every such transform names AVIF and WebP. To silence the note on a build that will only ever serve JPEG, set defaults: { formats: [jpeg] } (or formats: [jpeg] on each transform); leave the default if production runs the -vips image.

An SVG asset is never rasterised: its methods serve the original with no srcset or sources.

Placeholders

dominant sets the image’s background colour to its dominant colour; blur sets a 16 pixel blurred preview as the background image. Both are written as a style attribute by the engine, so a content security policy must allow inline style attributes (style_src_attr: ["'unsafe-inline'"]) for them to show. Templates themselves can never write a style attribute. none writes nothing.

Theme images

Images that belong to the design rather than to content (a team photograph, a background illustration) live under public/ and go through the same pipeline:

{% set team = images.file("img/team.jpg") %}
{{ team.figure("card", alt=t("about.team_alt")) }}

images.file(path) reads a file under the project’s public/ directory (refusing a path that leaves it) and returns an image value with every method above. It has no alt text of its own, so pass alt=. Its derivatives are cached by the file’s content hash.

images.breakpoints lists the breakpoints (name, value, from, below) and images.transforms the site’s transforms (name, aspect, fit, sizes, formats, width or widths, artDirected), for a template that needs them.

How derivatives are made and served

  • A derivative is generated the first time it is requested, once even under concurrent requests and across replicas, then stored. If generation fails, the request is redirected to the original (not cached), and the next request tries again.
  • Its address is named by what it is (the asset, its version, its framing and the transform’s resolved definition), not by the transform’s name. Renaming a transform reuses every derivative; changing one produces new addresses on the next render, with no purge.
  • Derivatives and originals are served with Cache-Control: public, max-age=31536000, immutable.
  • Publishing a document renders its pages and generates the derivatives they reference in the background. engine-dmo media warm does the same on demand, and engine-dmo warm --media queues it for every published page after a deploy.
  • Derivatives not served for media.derivatives.keep_unused (90 days by default) are pruned nightly; engine-dmo media prune runs it by hand.

Checking what a page downloads

  • engine-dmo render <path> --json adds an images report: each image on the page with its transform, the sizes written, the ladder, the formats, and each derivative’s bytes and whether it is stored yet. Without --json it prints a short table on standard error. It needs a database (Testing).
  • Under engine-dmo serve --dev, a development script compares each image’s drawn width with the candidate the browser chose and reports an image that downloads far more pixels than it shows, with a suggested sizes, in the browser console and in engine-dmo dev status.