A production-ready Statamic 6 addon that renders responsive <picture> elements from a single Antlers tag. Format negotiation (AVIF → WebP → fallback) is handled by the browser, intrinsic dimensions are always set to prevent CLS, and a tiny inline LQIP is rendered behind the image while it loads.
Srcset generation follows the next/image model: a pool of candidate widths built from device_sizes ∪ image_sizes, capped at the source image's intrinsic width.
Requirements
- PHP 8.2+
- Statamic 6
- Statamic Glide enabled (default)
Installation
composer require massif/statamic-responsive-images
The addon auto-registers via its service provider. Publish the config if you want to tweak defaults:
php artisan vendor:publish --tag=responsive-images-config
Quickstart
{{ responsive_image src="hero.jpg" alt="A sunset over the coast" }}
Output (simplified):
<picture> <source type="image/avif" srcset="/img/.../avif 640w, ..." sizes="100vw" /> <source type="image/webp" srcset="/img/.../webp 640w, ..." sizes="100vw" /> <img src="/img/.../828w" srcset="..." sizes="100vw" width="1600" height="900" alt="A sunset over the coast" loading="lazy" decoding="async" fetchpriority="auto" style="background-size:cover;background-image:url('data:image/jpeg;base64,...')" /></picture>
With an asset field:
{{ responsive_image :src="hero" ratio="16/9" sizes="(min-width: 1024px) 50vw, 100vw" }}
Parameters
| Param | Type | Description |
|---|---|---|
src |
string|Asset | Required. URL, assets::id, or Asset instance. Empty values render nothing. |
alt |
string | Alt text. Falls back to the asset's alt field. Missing alt is logged as a warning. |
sizes |
string | sizes attribute. Defaults to default_sizes from config. |
widths |
array|csv | Override the srcset pool. E.g. widths="400,800,1200". |
ratio |
string | Force an aspect ratio. Accepts 16/9 or 16:9. Drives crop height on every srcset entry. |
width |
int | Explicit intrinsic width. |
height |
int | Explicit intrinsic height. |
fit |
string | Glide fit mode. Defaults to glide.default_fit (crop_focal) when a ratio is set. Without a ratio, URLs use fit=contain so Glide scales proportionally. |
class |
string | Class for the outermost rendered element. Lands on the wrapper when figure or ratio_wrapper is used, otherwise on the <img>. |
img_class |
string | Class applied directly to the <img>. Merges with class when there's no wrapper. |
loading |
string | lazy (default), eager. |
decoding |
string | async (default), sync, auto. |
fetchpriority |
string | auto (default), high, low. |
preload |
bool | Push a <link rel="preload" as="image" …> for the top enabled format onto the Antlers head stack. Works out of the box — Statamic's default head partial already renders that stack. See Preload below. |
quality |
int | Override the quality for all formats on this render. Defaults to per-format quality from config. |
formats |
csv|array | Limit which formats are emitted. E.g. formats="webp,fallback". Valid entries: avif, webp, fallback. |
blur |
int | Glide blur passthrough. |
brightness |
int | Glide brightness passthrough (-100..100). |
contrast |
int | Glide contrast passthrough (-100..100). |
sharpen |
int | Glide sharpen passthrough (0..100). |
gamma |
float | Glide gamma passthrough. |
pixelate |
int | Glide pixelate passthrough. |
filter |
string | Glide filter passthrough (e.g. sepia, greyscale). |
flip |
string | Glide flip passthrough (h, v, both). |
orient |
int|string | Glide orient passthrough (exif value or 0/90/180/270). |
bg |
string | Glide background colour passthrough (hex, rgb, rgba). |
placeholder |
bool | Set false to disable the inline LQIP for this tag. |
figure |
bool | Wrap output in <figure>. |
caption |
string|bool | Caption text (only rendered in figure mode). When omitted, the figure auto-captions from the resolved alt text. Pass caption="false" to disable the auto-caption. |
ratio_wrapper |
bool | Wrap output in a <div style="aspect-ratio:…">. |
sources |
array | Art-direction sources. See below. |
Classes
class targets the outermost rendered element. When you wrap the output in a <figure> or a ratio <div>, the class lands on that wrapper; otherwise it lands on the <img>. If you also pass img_class in the wrapperless case, both are merged on the <img>.
{{# No wrapper → class goes on the <img>. #}}{{ responsive_image :src="hero" class="rounded shadow" }} {{# Wrapper present → class goes on the <figure>, img_class on the <img>. #}}{{ responsive_image :src="hero" figure="true" class="card" img_class="object-cover" }}
Captions
In figure mode, the tag auto-captions from the resolved alt text (which itself falls back to the asset's alt field). Explicit caption wins. Pass caption="false" to render a <figure> without a <figcaption>.
{{# Auto-caption from alt (figure=true) #}}{{ responsive_image :src="hero" alt="Sunset over the coast" figure="true" }}{{# → <figure>…<figcaption>Sunset over the coast</figcaption></figure> #}} {{# Explicit caption #}}{{ responsive_image :src="hero" figure="true" caption="Shot in Lisbon" }} {{# Figure without caption #}}{{ responsive_image :src="hero" figure="true" caption="false" }}
Focal point → object-position
When the source is a Statamic asset with a focal point set in the CP, the tag emits an inline object-position: x% y% on the <img> so CSS-cropped layouts (e.g. object-fit: cover on a fixed-aspect container) keep the subject in frame. This is on by default, is a no-op when no focal point is set, and costs nothing when the CSS doesn't use object-fit.
Tag alias
For brevity, the addon ships a short alias {{ pic }} alongside the canonical {{ responsive_image }}. Both tags share every behavior, parameter, and wildcard form:
{{ pic :src="hero" alt="A sunset" }}{{ pic:hero alt="A sunset" }}
The alias handle is configurable:
// config/responsive-images.php'tag_alias' => 'pic', // set to any handle, or null to disable
Wildcard form
Resolve src from the template context by field name:
{{ responsive_image:hero }}{{ pic:hero alt="Custom alt" }}
The tag suffix (after the :) is read from $this->context, so any field, augmented asset, or template variable on the current scope is usable.
Preload
For above-the-fold images (LCP candidates), set preload="true":
{{ pic :src="hero" alt="…" preload="true" }}
The tag pushes a <link rel="preload" as="image" imagesrcset=… imagesizes=… type="image/avif" fetchpriority="high"> onto the Antlers head stack. Statamic's default head partial (vendor/statamic/cms/resources/views/partials/head.blade.php) already renders that stack, so preload works out of the box on a stock layout.
If you've replaced the default head partial with a fully custom one, make sure it still renders the stack:
<head> {{ stack name="head" }}</head>
When the stack is absent, Statamic silently discards the push — no error, but also no preload link in the output.
When preload="true" is set, the tag also:
- Sets
loading="eager"on the<img>(unless you passedloading="..."explicitly). - Sets
fetchpriority="high"on the<img>(unless you passedfetchpriority="..."explicitly).
Both auto-behaviors are togglable in config:
'preload' => [ 'auto_eager' => true, 'auto_priority' => true,],
Format selection. The preload link targets the highest-priority enabled format (AVIF → WebP → fallback). Browsers that can't decode the format (e.g. older browsers on an AVIF link) skip the preload — safe, because type= is set.
Limitations.
- Per-breakpoint preload for art-directed sources is not supported in v1 — the preload targets the primary
src. - Needs a rendered
headstack in your layout. Statamic's default partial provides this; only custom layouts that omit it would need to wire it in manually.
SVG and GIF
SVG (image/svg+xml) and GIF (image/gif) sources skip the Glide pipeline entirely. The tag emits a plain <img> with the original URL, width/height from metadata when available, class, loading, decoding, and aria-hidden="true" when alt is empty. No <picture>, no srcset, no re-encoding — raster transforms would either produce meaningless output (SVG) or lose animation (GIF).
Glide passthrough params (blur, sharpen, etc.) are ignored for these sources.
Art direction
Pass an array of entries via the sources parameter. Each entry becomes its own block of <source> elements with the given media query. Entries earlier in the array win (the browser picks the first matching <source>).
Antlers limitation — sources must be a variable, not an inline literal. Antlers' expression parser chokes on inline array literals whose string values contain colons (e.g. (max-width: 768px)), so you cannot pass :sources="[{...}]" directly in a template. Build the array outside the template and pass it by name. The cleanest options:
-
Blueprint field. Add a
replicatororgridfield calledimage_sourceswithsrc,media,sizes, andratiosubfields, then:{{ responsive_image :src="hero_desktop" :sources="image_sources" }} -
Template variable via a view composer, augmenter, or controller. Share a
hero_sourcesarray from PHP and reference it the same way:{{ responsive_image :src="hero_desktop" :sources="hero_sources" }}
Each entry accepts src (required), media, sizes, and ratio. The last entry's src is used as the <img> fallback when no breakpoint matches.
Config
config/responsive-images.php:
return [ 'device_sizes' => [640, 750, 828, 1080, 1200, 1920, 2048, 3840], 'image_sizes' => [16, 32, 48, 64, 96, 128, 256, 384], 'default_sizes' => '(min-width: 1280px) 640px, (min-width: 768px) 50vw, 90vw', 'tag_alias' => 'pic', 'fallback_width' => 828, 'formats' => [ 'avif' => ['enabled' => true, 'quality' => 50], 'webp' => ['enabled' => true, 'quality' => 75], 'fallback' => ['quality' => 82], ], 'placeholder' => [ 'enabled' => true, 'width' => 32, 'blur' => 40, 'quality' => 40, ], 'preload' => [ 'auto_eager' => true, 'auto_priority' => true, ], 'glide' => [ 'default_fit' => 'crop_focal', ], 'cache' => [ 'store' => null, 'prefix' => 'respimg', 'metadata_ttl' => 7_776_000, 'sentinel_ttl' => 60, ],];
device_sizes vs image_sizes. Device sizes cover full-width images at common device breakpoints. Image sizes cover small images (thumbnails, icons). The final srcset pool is their union, deduped, sorted, and capped at the source image's intrinsic width — the browser then picks the best candidate based on sizes.
Format quality. AVIF defaults to 50, WebP to 75, fallback to 82. Lower values ship smaller bytes; tune per project.
Placeholder integration with daun/statamic-placeholders. If you install the daun/statamic-placeholders addon, its placeholder data (ThumbHash, BlurHash, or Average color — whichever you've configured on the asset's placeholder field) is auto-detected and used in preference to the built-in Glide LQIP. When the asset has no placeholder data or when src is a raw URL, we silently fall back to the Glide LQIP — output shape is unchanged (still a base64 data URI on background-image). Provider choice lives entirely in that addon; we don't expose a provider knob, since mismatching our override against the blueprint's placeholder_type would silently miss and fall back. Disable the integration by setting placeholder.statamic_placeholders.enabled to false.
Performance notes
- Metadata is cached by
{prefix}:meta:{id}:{mtime}. Changing the source file invalidates automatically. - LQIPs are cached by
{prefix}:lqip:{id}:{mtime}and inlined as a base64 data URI (no extra request). - Glide does the heavy lifting — transformed variants are cached on disk by Statamic's Glide pipeline. Cold requests do the work once.
- No
Acceptsniffing. Format negotiation is entirely browser-side via<picture>, so the response is cacheable by any CDN.
Caveats
-
AVIF encoding is slow. First request per (image, width) can take a few seconds. Warm critical pages at deploy time if this matters.
-
AVIF requires Imagick with libheif or a recent GD build. If your image driver can't emit AVIF, disable it:
'formats' => ['avif' => ['enabled' => false, 'quality' => 50]], -
Non-image assets are skipped. Unresolvable
srcvalues log a warning and render nothing. -
Plain URL
srcvalues bypass mtime-based cache invalidation. Only Statamic assets (assets::idor asset instances) carry an mtime; URL strings cache under a zero mtime, so replacing a file at the same URL will not invalidate metadata or LQIPs. Clear the cache manually (or bump the configcache.prefix) after such replacements. -
Missing alt text is logged but does not throw. Fix the warning or pass
alt=""explicitly for decorative images. -
Color profiles are normalized to sRGB on the Imagick driver with the
lcmsdelegate. Source images in Adobe RGB, Display P3, or CMYK are color-managed (not blindly tagged) so the browser gets true sRGB output. The manipulator silently no-ops on GD, non-Imagick drivers, and Imagick builds withoutlcms— delivery continues unchanged in those cases. Confirmlcmsavailability withconvert -list configure | grep DELEGATES. After upgrading, clearstorage/statamic/glideso existing cached transforms get regenerated with the profile applied.
License
MIT