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:
| Source | Example |
|---|---|
An asset field holding an image | entry.heroImage; with multiple: true, a list to loop over |
| A video’s poster | entry.film.poster |
| A relation’s target, a promoted item, a list item | item.heroImage |
A file under the project’s public/ directory | images.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
| Method | Prints |
|---|---|
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
| Option | Values | Default | Effect |
|---|---|---|---|
sizes | A shorthand such as "lg:33vw md:50vw 100vw", a full media list, or "auto" | The transform’s sizes | The sizes attribute |
priority | true or false | false | Marks the page’s main image: loading="eager", fetchpriority="high" and a <link rel="preload" as="image"> with imagesrcset and imagesizes added to the head |
loading | lazy or eager | lazy | The loading attribute, when priority is not set |
fetchpriority | high, low or auto | none | The fetchpriority attribute |
placeholder | dominant, blur or none | defaults.placeholder in images.yaml, else dominant | What paints the box before the image arrives |
alt | A string | The value’s alt text | Alt 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
| Option | Default | Meaning |
|---|---|---|
| first argument | none | The transform; with none, the original |
width | the largest step | The narrowest derivative at least this wide |
format | the transform’s fallback format | jpeg, png, webp or avif |
absolute | false | true 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 method | Value |
|---|---|
src | The fallback format’s derivative at the ladder’s middle width (with width=, the narrowest step at least that wide) |
srcset | The fallback format’s srcset |
srcsetFor(format) | One format’s srcset |
sizes | The transform’s sizes, expanded from the shorthand |
width, height, aspect | The output’s proportions for src |
sources | Each <source> a picture would print: type, media, srcset, sizes |
placeholder | The blurred preview as a data URI |
dominant | The 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
| Property | Value |
|---|---|
url | The original file’s URL |
id | The asset’s id (empty for a theme file) |
kind | image, document, audio or video |
mime, extension, type, filename, title | The media type, the lower-case extension, the type as a reader names it (JPG), the file name, the title (else the file name) |
bytes, size | The size in bytes, and formatted for the request locale (2.1 MB) |
width, height, aspect, orientation | The image’s dimensions after its crop, its width over its height, and landscape, portrait or square |
alt, altLang, decorative | The 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, captionLang | The same for the caption |
credit, creditRequired | The 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 |
dominant | The 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
| Key | Type | Built-in default | Meaning |
|---|---|---|---|
formats | list 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 |
quality | map 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 |
density | integer | 2 | The highest pixel density a fixed-width transform serves |
placeholder | dominant, blur or none | dominant | The placeholder every image paints unless a call says otherwise |
density and placeholder are defaults only; a transform cannot set them.
A transform
| Key | Type | Meaning |
|---|---|---|
aspect | string "W:H" | The output’s proportions, such as "3:2" or "1.91:1"; without it, the source’s own |
fit | cover, contain or inside | cover (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 transparent | The padding colour for fit: contain; transparent padding becomes white in JPEG. With any other fit it is a warning |
width | integer | A fixed display width, served at 1x up to densityx with x descriptors; such a transform writes no sizes |
height | integer | Only with width and no aspect; sets the aspect to width:height |
widths | { min, max, ratio } or a list of integers | The ladder, as a range or used as written |
formats | list | Replaces the default formats |
quality | map of format to integer | Over the default qualities |
sizes | string | The default sizes, as a shorthand or a full media list |
animated | first or keep | An animated GIF keeps its first frame (first, the default) or is served as the original GIF (keep) |
sources | list 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
mintomax, eachratiotimes the last, rounded to a multiple of 8. A step within half a step ofmaxis dropped in favour ofmax. - 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:
| Build | Encodes | Notes |
|---|---|---|
The -vips image (libvips) | AVIF, WebP, JPEG, PNG | Converts to sRGB, strips metadata, sharpens lightly after downscaling |
A build with the Go image processor (pure-go) | JPEG, PNG | Configured 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 warmdoes the same on demand, andengine-dmo warm --mediaqueues 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 pruneruns it by hand.
Checking what a page downloads
engine-dmo render <path> --jsonadds animagesreport: each image on the page with its transform, thesizeswritten, the ladder, the formats, and each derivative’s bytes and whether it is stored yet. Without--jsonit 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 suggestedsizes, in the browser console and inengine-dmo dev status.
Related
- The asset field: what editors can choose, alt text and framing.
- Media: the library, uploads, rights and the commands.
- Images configuration reference.
Components
How to write, call and override a template component with a declared contract of props, slots and attributes that engine-dmo check enforces at every call site.
Links and URLs
How a template builds links with path(), url(), siteUrl() and localeUrl(), prints an entry's address, renders link and asset fields, and why every variable in href or src goes through the url filter.