Replacing an HTML image path with an image_picker setting is only the first step. A storefront also needs an appropriately sized image for each viewport, intrinsic dimensions to reduce layout shift, meaningful alternative text, and loading behaviour that matches where the section appears.
This tutorial builds a complete image-with-text section. It uses Shopify's image_url filter to request a maximum source size and image_tag to produce the image element and responsive candidates. The section works in a one-column mobile layout and a two-column desktop layout without hard-coding a CDN URL.
Prerequisites
You need an Online Store theme with a sections/ directory and an image available in Shopify Files or the theme editor. Familiarity with section settings is helpful. Shopify's official image_url reference documents the required width or height argument, and the image_tag reference covers widths, sizes, loading, preload, and other image attributes.
Start with the static HTML
Suppose a design handoff contains this card:
<section class="image-story">
<div>
<p class="eyebrow">Materials</p>
<h2>Designed for repeat wear</h2>
<p>Breathable layers with straightforward care instructions.</p>
</div>
<img src="story.jpg" alt="Folded shirts in neutral colours">
</section>
Copying story.jpg into a theme asset can work for a fixed design, but a merchant-editable section should use image_picker. The rendered ImageDrop can then pass through Shopify image filters.
Complete responsive section
Create sections/image-story.liquid:
{% style %}
#shopify-section-{{ section.id }} .image-story {
display: grid;
gap: 1.5rem;
align-items: center;
padding-block: 3rem;
}
#shopify-section-{{ section.id }} .image-story__media {
min-width: 0;
}
#shopify-section-{{ section.id }} .image-story__image {
display: block;
width: 100%;
height: auto;
border-radius: 0.75rem;
}
@media (min-width: 750px) {
#shopify-section-{{ section.id }} .image-story {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 3rem;
}
}
{% endstyle %}
{% assign image_loading = 'lazy' %}
{% if section.index == 1 %}
{% assign image_loading = 'eager' %}
{% endif %}
<section class="image-story page-width" aria-labelledby="ImageStoryHeading-{{ section.id }}">
<div class="image-story__content">
{% if section.settings.eyebrow != blank %}
<p>{{ section.settings.eyebrow | escape }}</p>
{% endif %}
{% if section.settings.heading != blank %}
<h2 id="ImageStoryHeading-{{ section.id }}">
{{ section.settings.heading | escape }}
</h2>
{% endif %}
{% if section.settings.body != blank %}
<div class="rte">{{ section.settings.body }}</div>
{% endif %}
</div>
<div class="image-story__media">
{% if section.settings.image != blank %}
{% assign image_alt = section.settings.image.alt | default: section.settings.heading %}
{{
section.settings.image
| image_url: width: 1800
| image_tag:
class: 'image-story__image',
widths: '360, 540, 720, 900, 1200, 1500, 1800',
sizes: '(min-width: 750px) 50vw, 100vw',
loading: image_loading,
alt: image_alt
}}
{% else %}
{{ 'image' | placeholder_svg_tag: 'placeholder-svg' }}
{% endif %}
</div>
</section>
{% schema %}
{
"name": "Image story",
"settings": [
{
"type": "text",
"id": "eyebrow",
"label": "Eyebrow",
"default": "Materials"
},
{
"type": "text",
"id": "heading",
"label": "Heading",
"default": "Designed for repeat wear"
},
{
"type": "richtext",
"id": "body",
"label": "Text",
"default": "<p>Breathable layers with straightforward care instructions.</p>"
},
{
"type": "image_picker",
"id": "image",
"label": "Image"
}
],
"presets": [
{
"name": "Image story"
}
]
}
{% endschema %}
What image_url does
image_url: width: 1800 asks Shopify for a transformed image URL whose width is at most 1800 pixels, subject to the original image dimensions. The filter requires a width, a height, or both. It does not improve a small original: requesting 1800 pixels from a 700-pixel upload cannot create real detail.
The requested width also acts as the upper bound for the widths generated by the following image_tag. Keep the largest value in the widths list at or below that bound. Shopify currently documents a maximum width and height of 5760 pixels, but the right production size depends on the rendered slot, source crop, and expected display density rather than the platform maximum.
What image_tag adds
image_tag creates the <img> and can add a srcset from widths. The browser compares those candidates with the sizes hint and its viewport density, then downloads a suitable source. Shopify's filter also supplies width and height attributes based on the selected image, which helps the browser reserve space before the file arrives.
The sizes value describes the approximate rendered slot, not the source file. In this example the image occupies roughly half the viewport on desktop and the full content width on mobile. If your theme's page-width container is much narrower than the viewport, use a more precise calc(...) expression and verify the browser's chosen candidate in the Network panel.
Loading behaviour needs page context
Most below-the-fold content should keep native lazy loading. An image that is actually above the fold may need eager loading, and image_tag also supports a preload option for carefully selected important images. Preloading every section image wastes bandwidth and can compete with the genuine largest-content image.
The example uses section.index as a modest signal: the first statically rendered section loads eagerly and later sections load lazily. Treat that as a starting policy, not proof that the image is visible. Theme layout, announcement bars, and template composition all affect the fold. Test the final template with its real sections.
Alternative text decisions
The image picker stores image alt text, so that is the first choice. The heading is only a fallback to avoid an unlabelled informative image in this reproducible example. In production, a duplicated heading may be redundant. Decide whether the image communicates information:
- Write concise alt text when the image adds content not already conveyed nearby.
- Use an empty alt value for a truly decorative image.
- Do not stuff product keywords or repeat the filename.
- Do not put essential instructions only in the image.
Expected output and responsive checks
On a narrow screen, content appears above the image and the sizes hint advertises a full-width slot. At 750 pixels and above, the grid becomes two columns and advertises an approximately half-viewport image slot. The height: auto rule keeps the intrinsic aspect ratio, while the width and height attributes generated by image_tag let the browser calculate that ratio before loading.
In DevTools, inspect the rendered image's currentSrc. Resize the viewport and reload with the cache disabled. The selected URL width should generally rise for wider or high-density slots, but it should not default to the largest source on every phone.
Common mistakes and debugging
- Liquid error from
image_url: supplywidthorheight. - Only one source downloads everywhere: confirm
widthsandsizesreachimage_tagand inspect the renderedsrcset. - A tiny image looks soft: upload a source large enough for its maximum rendered size.
- Every image loads immediately: remove blanket eager loading or preload flags from below-the-fold sections.
- Layout jumps: do not strip the intrinsic width and height attributes; also avoid CSS that changes the aspect ratio after load.
- Broken crop:
image_urlcrop options and CSSobject-fitsolve different problems. Decide which composition must be preserved. - Alt text shows raw markup: alternative text is plain text; do not source it from a rich-text setting.
Development-theme test plan
- Run
shopify theme devand add the section near the top and then near the bottom of a template. - Upload landscape, portrait, small, and large source images.
- Inspect
src,srcset,sizes,width,height,loading, andaltin the rendered DOM. - Test 320, 768, and 1440 pixel viewports and at least one high-density device emulation.
- Disable images to confirm the heading and body still explain the section.
- Run Lighthouse as a diagnostic, then inspect the network waterfall rather than relying only on one score.
- Test with reduced bandwidth to watch for reserved space and content movement.
Use the HTML converter to map a static image to an image setting, then review its source widths and page position manually. The Liquid cheatsheet has shorter filter examples, while HTML-to-Liquid best practices covers the wider conversion review.
Manual verification checklist
- The image comes from an
image_picker, not a copied CDN URL. -
image_urlincludes a width or height. - Candidate widths match the actual layout and source quality.
- The
sizeshint describes mobile and desktop slots. - Width and height remain on the rendered image.
- Loading is eager only when page position justifies it.
- Alt text is purposeful, concise, and not a filename.
- Empty image state is usable in the theme editor.
- The section remains readable at narrow and wide widths.
Conclusion
Responsive Shopify image output is a chain of decisions: choose an editable image, request an appropriate transformed source, describe candidate widths and layout sizes, keep intrinsic dimensions, and set loading from real page context. image_url and image_tag provide the mechanics, but the theme developer still owns those decisions and the final tests.