Responsive image wiring
Goal
Deliver responsive images for a Drupal site from a declared list of image use-cases, so every image field renders correctly-sized sources per breakpoint and per pixel density, with modern format optimization and a safe fallback, no bandwidth waste, and no untyped one-size-fits-all responsive style left in the project.
Opinion
Purpose-specific responsive image styles, one per use-case. A hero-context responsive style is never attached to a card-context field. Source: play drupal/best-practices/camoa/responsive-image-sizing-per-context.
Purpose-specific view modes, never the default. The default media view mode is an admin fallback, not a content render target. Each use-case gets its own view mode and its own entity view display. Source: play drupal/best-practices/camoa/media-view-mode-to-responsive-image-style.
Resolution switching is the default. Art direction is the exception. Use art direction only when the layout crop genuinely changes per breakpoint, not merely the size. Source: guide drupal/image-styles/art-direction-resolution.
Modern format with a safe fallback. Emit a modern format where the toolkit supports it and fall back automatically otherwise. The effect is one of the two the format guide names: image_convert_avif with a WebP fallback, or image_convert to WebP. formatPriority picks between them. The recipe does not state the version floor — that is the format guide's job. Source: guide drupal/image-styles/webp-avif-optimization.
Loading priority is per use-case. Above-the-fold use-cases get eager; below-the-fold get lazy. Source: guide drupal/image-styles/image-field-formatters.
Default to the active default theme's breakpoint group. For art direction, the breakpoint group defaults to the front-end default theme's group (system.theme.default → that theme's *.breakpoints.yml). An explicit breakpoint_group input is an override, not a requirement. Resolution switching always uses the responsive_image module's built-in viewport_sizing group regardless of theme.
Size for every breakpoint and every pixel density. Derive enough image styles to cover each breakpoint and each multiplier the group declares (1x, 2x, 3x) — read the multipliers, never assume 1x. For resolution switching, the width ladder extends to at least 2× the use-case max width so high-DPR screens have a real source to pick.
The chain
The three layers are built and referenced in this order, and the ordering is load-bearing:
- Image styles — the derivatives. Named effect chains producing files at specific sizes and formats. Not breakpoint-aware on their own.
- Responsive image style — the configuration that maps a breakpoint group (or a
sizesladder) to those image styles, with a fallback. Transforms nothing itself; it composes the image styles. - View mode display — uses the
responsive_imageformatter, which references the responsive image style.
The view mode references the responsive image style, never the raw image styles. The raw styles are only ever referenced by the responsive image style. You cannot reference a responsive image style until it exists, and it cannot exist until its image styles exist — hence the order.
Preconditions
- Drupal 10.3+ or 11.x;
image,responsive_image,breakpointenabled. - A breakpoint group is resolvable: the default theme declares one in
*.breakpoints.yml, or one is supplied via input. The module'sresponsive_image.viewport_sizinggroup exists by default and is the target for resolution switching. Seedrupal/image-styles/breakpoint-configuration. - The format-conversion capability the use-cases ask for is available on this site. The version/module floor for modern-format-with-fallback is stated in
drupal/image-styles/webp-avif-optimization; this recipe defers to that guide and checks availability at runtime rather than asserting a version here. - A list of image use-cases is supplied via the input contract.
- DDEV runs the site, and
ddev drushworks from the project root. The verifier's commands go throughddev.
Input contract
Generic list, source-agnostic, supplied by the caller (adapter skill, human operator, or orchestrator).
image_use_cases:
- name: string # machine name, snake_case, unique
aspectRatio: string # e.g. "16:9", "1:1", "auto"
maxWidth: integer # max display width in px
purpose: string # human description
strategy: string # "resolution_switching" | "art_direction"
# default: resolution_switching
loadingPriority: string # "eager" | "lazy" (default: lazy)
formatPriority: string # format strategy name; resolved against the
# format guide (default: modern-with-fallback)
focalPoint: string # crop anchor; "center-center" by default
# used whenever a crop is applied
sizesAttribute: string # required when strategy = resolution_switching
appliesTo: # optional; where this use-case renders
- entity_type: string # if present, step 5 wires deterministically
bundle: string # if absent, step 5 asks the operator, and the
field: string # caller passes the confirmed triples here
view_mode: string # for the verifier
breakpoint_group: string # optional; overrides the default theme's group
# (only relevant to art_direction use-cases)
Sequence
If invoked in dry-run mode, perform all reads and derivations but emit a preview instead of writing config. Dry-run is required.
-
Validate preconditions and resolve the breakpoint group. Confirm Drupal version and modules. Resolve the art-direction breakpoint group: use
breakpoint_groupif supplied, else the default theme's group. If neither resolves and any use-case needs art direction, surface and stop. Seedrupal/image-styles/breakpoint-configuration. -
Create the image styles each use-case needs. For each use-case, derive and create the derivatives its context requires; skip existing matches, flag conflicts, never overwrite.
- Resolution switching: a width ladder covering the use-case's rendered sizes, whose widest style is at least 2×
maxWidthfor high-DPR. Crop toaspectRatio(atfocalPoint) when a ratio is declared; scale otherwise. - Art direction: one derivative per breakpoint and per declared multiplier in the resolved group, cropped to the use-case aspect at the focal point.
-
For the effect chain and the format-conversion effect, see
drupal/image-styles/core-image-effectsanddrupal/image-styles/webp-avif-optimization. For UUID/file mechanics seedrupal/image-styles/creating-styles-config. -
Create the responsive image style per use-case. One
responsive_image.styles.<use_case>mapping the breakpoints (art direction) or thesizesladder (resolution switching) to the image styles from step 2, with a fallback to the smallest. This is the entity the formatter references. Seedrupal/image-styles/responsive-image-configanddrupal/image-styles/art-direction-resolution. Same idempotency contract. -
Create the purpose-specific view mode and display. For each use-case, an entity view mode and an entity view display using the
responsive_imageformatter pointed at the use-case's responsive image style, with the declared loading attribute. Seedrupal/image-styles/image-field-formattersand the view-mode play. Same idempotency contract. -
Bind use-cases to fields. For each use-case: if it declares
appliesTo, wire those(bundle, field, view_mode)triples to its view mode deterministically. If it does not, discover image-bearing fields in the data model and confirm with the operator which use-case each field/view-mode renders before wiring — the recipe does not guess this mapping. Seedrupal/image-styles/image-field-formatters. -
Rebuild caches.
drush cr, so config changes take effect and the breakpoint manager re-reads any added breakpoints. -
Emit summary. What was created, skipped as no-op, surfaced as conflict, or left for operator confirmation.
Data flow
input: image_use_cases (generic list, supplied by caller)
breakpoint_group (optional override; default = default theme's group)
reads project state:
system.theme.default + that theme's *.breakpoints.yml
image.style.* / responsive_image.styles.* config
core.entity_view_mode.* / core.entity_view_display.* config
the data model (image-bearing fields per bundle/view-mode)
format-conversion capability availability (per format guide)
applies opinion (plays):
per-context sizing · purpose-specific view modes
references atomic detail (guides):
drupal/image-styles/{ image-overview, image-style-schema,
creating-styles-config, core-image-effects, responsive-image-config,
breakpoint-configuration, art-direction-resolution,
image-field-formatters, webp-avif-optimization }
emits (built in chain order):
image.style.<name> (per derived style)
responsive_image.styles.<use_case> (per use-case)
core.entity_view_mode.<entity>.<use_case> (per use-case)
core.entity_view_display ... formatter -> responsive style
field formatters on bound (bundle, field, view_mode) triples
State-awareness contract
The recipe reads existing state before writing. For every emitted config object: absent → create; present and matching the derived spec → skip, log no-op; present and differing → conflict, do not overwrite, request operator review. A missing breakpoint group fails the precondition check (step 1). A field binding that already points at the correct use-case → skip; pointing at a different use-case → conflict.
Idempotent: running the recipe twice on identical input and identical project state produces no changes on the second run.
Verifier
Each entry is one command, run from the project root after the recipe ran. It is split on spaces and never run through a shell. A non-zero exit fails the entry, whatever pass says. stdout empty reads standard output only; a non-interactive ddev drush keeps Drush's messages on standard error. A stopped DDEV project prints its start-up text on standard output, so start the site before the verify run. Entries that call .aida/responsive-image-wiring/verify.php run the script in ## Files. It prints one line per violation, prints a violation when an input it needs is missing, and exits non-zero when it printed any.
verifier: - id: image-styles-cover-use-case kind: config-assert run: ddev drush php:script /var/www/html/.aida/responsive-image-wiring/verify.php -- image-styles {image_use_cases:json} pass: stdout empty - id: responsive-style-per-use-case kind: config-assert run: ddev drush php:script /var/www/html/.aida/responsive-image-wiring/verify.php -- responsive-styles {image_use_cases:json} {breakpoint_group:json} pass: stdout empty - id: view-mode-per-use-case kind: config-assert run: ddev drush php:script /var/www/html/.aida/responsive-image-wiring/verify.php -- view-modes {image_use_cases:json} pass: stdout empty - id: bindings-render-through-use-case kind: config-assert run: ddev drush php:script /var/www/html/.aida/responsive-image-wiring/verify.php -- bindings {image_use_cases:json} pass: stdout empty - id: rendered-markup kind: live-site run: ddev drush php:script /var/www/html/.aida/responsive-image-wiring/verify.php -- render {image_use_cases:json} {breakpoint_group:json} pass: stdout empty
What the entries do not prove, and where the checks went:
image-styles-cover-use-casereads every image style the use case's responsive style references, the fallback included. Each must end, by weight, inimage_converttowebp, or inimage_convert_avifwhose fallbackextensioniswebp. The entry does not readformatPriority: it accepts either effect. For resolution switching, the widestsizesstyle must be at least 2×maxWidth, measured from a very large source. The per-breakpoint and per-multiplier coverage of art direction is checked inresponsive-style-per-use-case.responsive-style-per-use-casechecks the fallback is the narrowest referenced style. For resolution switching, it checks theresponsive_image.viewport_sizinggroup and one non-empty mapping, of typesizes, whosesizesequalssizesAttribute. For art direction, it checks the resolved group, and animage_stylemapping for every breakpoint and multiplier the group declares. The group isbreakpoint_groupwhen given. Otherwise it is the default theme's group: the group named after the theme, or the only group the theme provides.view-mode-per-use-caseneeds a view mode named after the use case on some entity type. It also needs at least one display in that view mode with aresponsive_imagecomponent. Every such component must reference the use case's responsive style with the declared loading attribute. Animageformatter in that view mode fails the entry, because it references a raw image style.bindings-render-through-use-casereads eachappliesToentry's display. The field must useresponsive_imagewith the use case's style, or render the referenced entity, such as a media item, in the use case's view mode. A use case with noappliesTofails the entry: the caller passes the triples the operator confirmed in Sequence step 5.rendered-markuprenders the field in process, on the newest entity of eachappliesTobundle with the field filled. It fails when no such entity exists. Resolution switching needs an element whosesizesequalssizesAttributeand whosesrcsetholds a width candidate from the style's derivatives. Art direction needs a<source>for each breakpoint with a media query, holding a candidate for each multiplier.- The old check's browser half is not here: which derivative a small or a high-DPR viewport downloads needs a real browser. That belongs to AIDA surfaces, as a visual or end-to-end check.
- No entry checks that a crop matches
aspectRatioorfocalPoint. - No entry checks the Goal's "no untyped one-size-fits-all responsive style left". The input names only this recipe's use cases, so nothing tells a leftover generic style from one another recipe or the install profile made.
- The script in
## Filesis written into the project for the verify run and removed after it. Do not commit or edit it.
Files
The verifier's script. It runs through ddev drush php:script, which passes the arguments after -- to the script as $extra.
```php .aida/responsive-image-wiring/verify.php