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.
  • sizes is your job. The browser uses sizes to pick a matching srcset entry, so the value should describe the rendered width at each breakpoint. 100vw is the lazy default for full-bleed; for a centered container, (min-width: 1024px) 1024px, 100vw is the canonical shape. Get it wrong and the browser downloads the wrong size.
  • src falls back to the largest width. Browsers that don't understand srcset (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 underlying url() surface, including the legacy-prefix rewrite and on-disk thumbnail cache the helper relies on
  • Site: $site->images is the container-bound instance every template reaches; getTCP() is the config accessor the presets variant uses
  • Sanitizer: 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->images and the field-value array shape this recipe builds on