Responsive Images

Pixel manipulation happens on the Image chain; the moment you want markup you call
img(), which resolves presentation intent into an
element you print. The sections below build up from a bare srcset
to art-directed <picture> elements.

Srcset

Generate width variants with widths(), then enter markup with img():

$el = $image->cover(800, 600)->widths(800, 600, 400)
    ->img(alt: 'Photo', sizes: '(min-width: 800px) 800px, 100vw');

echo $el;
<img src="photo_800.jpg"
     srcset="photo_800.jpg 800w, photo_600.jpg 600w, photo_400.jpg 400w"
     sizes="(min-width: 800px) 800px, 100vw"
     width="800" height="600"
     alt="Photo" loading="lazy" decoding="async" />

You rarely need to spell the widths and sizes out — a layout
knob derives both from one declaration:

echo $image->cover(800, 600)->img(alt: 'Photo', layout: 'constrained');

Width range

widths() takes any list, so a linear range is just range():

$image->widths(...range(400, 1200, 200));   // 400, 600, 800, 1000, 1200

Prefer a layout for real responsive delivery — a linear scale
over-provisions the small end (file size grows roughly with the square of width),
and on local providers every candidate is a real encoded, cached variant.

Density-descriptor srcset with densities()

For DPR-aware delivery without media queries, use densities():

$el = $factory->image('photo.jpg')->width(400)->densities(1, 2)
    ->img(alt: 'Photo');

echo $el;
// <img srcset="photo.jpg?w=800 2x, photo.jpg?w=400 1x" width="400" alt="Photo" … />

The browser picks the asset from devicePixelRatio alone — no sizes attribute
needed. layout: 'fixed' builds this set for you from densities(1, 2).

Use densities() when the display size is fixed and you only care about pixel
density; use widths() (or a constrained / full-width layout) when the box
scales with the viewport.

Format fallback

Enter markup with img(), then fan out to a <picture> with formats():

$picture = $image->cover(800, 600)
    ->img(alt: 'Photo')
    ->formats(Format::Webp);

echo $picture;
<picture>
  <source srcset="photo_800.webp" type="image/webp" />
  <img src="photo_800.jpg" alt="Photo" loading="lazy" decoding="async" />
</picture>

Combine with widths() for responsive + format — img() sits between the bytes
chain and the picture composition:

$image->cover(800, 600)->widths(800, 400)
    ->img(alt: 'Photo', sizes: '100vw')
    ->formats(Format::Avif, Format::Webp);

Art direction

Serve different crops per viewport. img() produces the fallback element; at()
adds a <source> for a media condition:

$mobile  = $factory->image('hero.jpg', $meta)->cover(400, 400);
$desktop = $factory->image('hero.jpg', $meta)->cover(1200, 400);

$picture = $mobile->img(alt: 'Hero')
    ->at('(min-width: 768px)', $desktop)
    ->formats(Format::Webp);

echo $picture;

at() and formats() commute — declare them in any order, the markup is the same.

Breakpoint names and self-contained sources

at() accepts a screens name in place of a raw
query ('md'(min-width: 768px), 'max-md'(max-width: 767.98px)), and a
closure that receives the pristine root of the base image — the image as the
factory produced it, with none of the fallback’s manipulations — so an
art-directed source never accidentally inherits the fallback’s crop:

$factory->image('hero.jpg', $meta)
    ->cover(400, 400)                                  // mobile fallback
    ->img(alt: 'Hero', sizes: ['md' => '50vw'])
    ->at('md', fn (Image $img) => $img->cover(960, 640)->widths(960, 1920))
    ->at('lg', fn (Image $img) => $img->cover(1440, 600)->widths(1440, 2880))
    ->formats(Format::Webp);

When every source uses a named min-width breakpoint, sources are sorted
min-width descending (the ordering hand-written markup gets wrong). Any raw or
max-* media present preserves insertion order.

Declarative (for Twig)

picture() is the declarative twin of the fluent chain — the same intent, with
sources as a media-keyed op map. alt comes first, then the sources and formats:

$picture = $image->picture(
    alt: 'Hero',
    sources: [
        'lg' => ['cover' => [1440, 600], 'widths' => [1440, 2880], 'sizes' => '50vw'],
        'md' => ['cover' => [960, 640]],
    ],
    formats: ['webp'],
);

Each entry builds from the pristine root; the reserved sizes key is lifted out
before dispatch, and any other key is a manipulation (an unknown one throws). This
is the shape the Twig |picture filter uses.

Data URIs

Inline images as base64 (useful for small placeholders):

$dataUri = $image->crop(100, 100)->toDataUri();
// → data:image/jpeg;base64,...

For a placeholder shown while the real image loads, prefer the
placeholder argument to img() — it wraps a hash, color, or
tiny image as a CSS background and degrades gracefully.

HTML output

img() is the single entry to markup. The elements it produces —
ImgElement and PictureElement — implement MarkupInterface, so
casting to string emits HTML:

echo $image->img(alt: 'Photo', attrs: ['class' => 'hero']);
echo $set->img(alt: 'Photo', sizes: '100vw');
echo $picture;

Image::render() / Srcset::render() remain as a zero-config shortcut (they build
a default element with no alt). Stringifying an Image or a set still returns the
URL / candidate list — the descriptor types stringify to URL grammar, the element
types to HTML.

To customize the markup — lazy load, web components, <figure> wrappers, JSON
output — supply your own renderer to ImageFactory; see Rendering.