Problem
You're emitting an image with $site->images->url(...) and the
browser downloads the same file at every viewport. You want a
responsive srcset so phones get a small thumbnail and desktops
get a sharper one, without writing four <source> elements by
hand on every page.
Recipe
ImageUrlBuilder::url() already generates and caches thumbnails
on demand; the recipe is to call it once per width and stitch the
results into a srcset attribute. One helper in the theme
collapses the boilerplate to one call site per <img>.
<?php
// themes/<your-theme>/_helpers.php
declare(strict_types=1);
use Scriptor\Boot\Frontend\Site;
/**
* Emit one <img> with srcset covering the given widths.
*
* @param array<string, mixed> $image field-value entry
* @param list<int> $widths e.g. [400, 800, 1200]
*/
function responsive_img(
Site $site,
array $image,
array $widths,
string $sizes = '100vw',
string $alt = '',
string $class = '',
): string {
if (($image['name'] ?? '') === '') return '';
$entries = [];
foreach ($widths as $w) {
$entries[] = $site->sanitizer->entities($site->images->url($image, $w, 0)) . ' ' . $w . 'w';
}
$largest = max($widths);
return sprintf(
'<img src="%s" srcset="%s" sizes="%s" alt="%s" loading="lazy"%s width="%d">',
$site->sanitizer->entities($site->images->url($image, $largest, 0)),
implode(', ', $entries),
$site->sanitizer->entities($sizes),
$site->sanitizer->entities($alt),
$class !== '' ? ' class="' . $site->sanitizer->entities($class) . '"' : '',
$largest,
);
}
Use it from a template:
<?php require_once __DIR__ . '/_helpers.php' ?>
<?php $hero = $site->page->images[0] ?? null ?>
<?php if ($hero !== null): ?>
<?= responsive_img(
$site,
$hero,
widths: [400, 800, 1200, 1600],
sizes: '(min-width: 1024px) 1024px, 100vw',
alt: 'Cover for ' . $site->page->name,
class: 'hero-image',
) ?>
<?php endif ?>
Three pieces worth flagging:
url()is called four times for four widths. Each call triggers at most one thumbnail generation per image+size pair; subsequent requests are filesystem-cache hits. The cost is amortised across every visitor, and only widths a real template asks for ever get generated.sizesis your job. The browser usessizesto pick a matchingsrcsetentry, so the value should describe the rendered width at each breakpoint.100vwis the lazy default for full-bleed; for a centered container,(min-width: 1024px) 1024px, 100vwis the canonical shape. Get it wrong and the browser downloads the wrong size.srcfalls back to the largest width. Browsers that don't understandsrcset(effectively only very old ones) get the highest-resolution variant, which renders correctly but defeats the responsive purpose. The opposite default (src=smallest) would over-pixelate on legacy browsers; the largest-width fallback is the safer choice.
Variants
<picture> with art direction
For images that should crop differently at different viewports
(not just scale), use <picture> with multiple <source>
elements pointing at different aspect ratios:
<picture>
<source media="(min-width: 1024px)"
srcset="<?= $site->sanitizer->entities($site->images->url($hero, 1600, 600)) ?>">
<source media="(min-width: 480px)"
srcset="<?= $site->sanitizer->entities($site->images->url($hero, 800, 400)) ?>">
<img src="<?= $site->sanitizer->entities($site->images->url($hero, 480, 320)) ?>"
alt="" loading="lazy">
</picture>
Each url() call here passes both width and height, so the
thumbnail is cropped to that aspect ratio rather than scaled. The
mobile crop is 3:2, desktop is more cinematic (~2.67:1); the
browser picks the matching source per the media query.
Listing thumbnails with explicit dimensions (CLS-safe)
Cumulative Layout Shift (CLS) drops when <img> carries width
and height attributes the browser can use to reserve space
before the image loads. When you generate a fixed-size thumbnail,
both dimensions are knowable up front:
<?php foreach ($posts as $post): ?>
<?php $cover = $post->images[0] ?? null ?>
<?php if ($cover !== null): ?>
<a href="<?= $site->sanitizer->entities($site->getPageUrl($post)) ?>">
<img src="<?= $site->sanitizer->entities($site->images->url($cover, 320, 180)) ?>"
width="320" height="180"
alt="<?= $site->sanitizer->entities($post->name) ?>"
loading="lazy">
</a>
<?php endif ?>
<?php endforeach ?>
The CLS guard only works when the thumbnail honours the requested
dimensions exactly (which happens when both width and height are
passed). For aspect-preserving calls (height = 0), the rendered
height is the source's natural ratio of the requested width; the
template has no way to know it without reading the file, so
either pass both dimensions or omit height and accept the CLS
hit.
Preset sizes registered as theme config
When the same widths appear in every template, pull them into a theme-config-property:
// scriptor-config.php
'theme_config' => [
'image_presets' => [
'hero' => [400, 800, 1200, 1600],
'listing' => [200, 400, 600],
'avatar' => [48, 96, 144],
],
],
// in a template
<?= responsive_img($site, $hero, $site->getTCP('image_presets')['hero']) ?>
getTCP() is the standard accessor for theme-config-properties;
the presets become a single source of truth that's easy to tune
without touching templates.
See also
ImageUrlBuilder: the underlyingurl()surface, including the legacy-prefix rewrite and on-disk thumbnail cache the helper relies onSite:$site->imagesis the container-bound instance every template reaches;getTCP()is the config accessor the presets variant usesSanitizer:entities()is the per-attribute escape every URL passes through before landing in HTML- Build a Theme: Page Templates:
the tutorial chapter that introduces
$site->imagesand the field-value array shape this recipe builds on