Skip to main content

Build responsive layouts that perform well on mobile

A responsive layout that performs well serves appropriately sized images, avoids downloading hidden resources, and minimizes DOM complexity across all screen sizes. The biggest gains come from Shopify-specific tools: the sizes attribute on image_tag, conditional rendering with section.index, and art direction with image_url in <picture> elements.


Poor responsive implementation creates three categories of waste:

  • Oversized images on mobile: without correct sizes attributes, the browser downloads images sized for desktop even on a phone. On a product grid that switches from 4 columns to 2, this can mean downloading images twice as large as needed for every card.
  • Downloading hidden resources: hiding desktop-only elements with display: none still downloads their images, fonts, and other resources. The browser fetches everything referenced in the HTML regardless of CSS visibility.
  • Duplicate DOM: rendering separate mobile and desktop markup, such as two navigation trees or two hero sections, doubles the DOM nodes the browser must parse, style, and keep in memory. This affects LCP, CLS, and INP.

Anchor to Write correct ,[object Object], attributes for your layoutsWrite correct sizes attributes for your layouts

The sizes attribute is the single most effective responsive performance lever in Shopify themes. It tells the browser how wide the image is at each viewport, so it can select the smallest sufficient source from srcset before layout occurs.

When sizes is wrong or missing, the browser defaults to assuming the image is 100vw wide and downloads the largest source. On a 4-column product grid, that means downloading images 4 times larger than needed.

Match your sizes attribute to your CSS layout breakpoints. Account for padding and gaps.

Full-width hero that spans the viewport:

{{ section.settings.image
| image_url: width: 1600
| image_tag:
widths: '600, 900, 1200, 1600',
sizes: '100vw'
}}

2-column product grid, 1 column on mobile:

{{ product.featured_image
| image_url: width: 800
| image_tag:
widths: '300, 400, 600, 800',
sizes: '(min-width: 768px) calc(50vw - 2rem), calc(100vw - 2rem)'
}}

4-column collection grid, 2 columns on tablet and 1 on mobile:

{{ product.featured_image
| image_url: width: 600
| image_tag:
widths: '200, 300, 400, 600',
sizes: '(min-width: 1200px) calc(25vw - 2rem), (min-width: 768px) calc(50vw - 2rem), calc(100vw - 2rem)'
}}

The formula for each breakpoint is calc(<column-fraction>vw - <total-horizontal-padding>). If your grid has a max-width container, then use a fixed pixel value for larger viewports instead of vw.

Anchor to Use ,[object Object], for lazy-loaded imagesUse sizes: 'auto' for lazy-loaded images

For images with loading="lazy", you can skip the hand-written breakpoint list and set sizes to auto. The browser then uses the image's own layout width, which stays correct when the CSS changes:

{{ product.featured_image
| image_url: width: 600
| image_tag:
loading: 'lazy',
widths: '200, 300, 400, 600',
sizes: 'auto'
}}

Shopify injects a polyfill for browsers that don't support sizes="auto" natively, so it's safe to use today.

Caution

auto applies only to images with loading="lazy". Eager images, including the LCP image, still need an explicit sizes value, because the browser has to pick a source before layout runs.

When mobile and desktop need different image crops, not just different sizes, use the <picture> element with image_url. A common case is a hero banner: landscape on desktop, and portrait or a tighter crop on mobile.

<picture>
<source
media="(max-width: 749px)"
srcset="
{{ section.settings.mobile_image | image_url: width: 400 }} 400w,
{{ section.settings.mobile_image | image_url: width: 600 }} 600w,
{{ section.settings.mobile_image | image_url: width: 800 }} 800w
"
sizes="100vw"
width="{{ section.settings.mobile_image.width }}"
height="{{ section.settings.mobile_image.height }}"
>
{{ section.settings.desktop_image
| image_url: width: 1600
| image_tag:
widths: '800, 1200, 1600',
sizes: '100vw',
loading: 'eager',
fetchpriority: 'high'
}}
</picture>

The <picture> element lets the browser choose the correct source before downloading anything. Give every <source> its own width and height, and let image_tag emit them on the fallback <img>. The mobile crop usually has a different aspect ratio than the desktop one, so without dimensions on the <source> the browser reserves the desktop aspect ratio and the layout shifts when the mobile image loads. For a full walkthrough, refer to Use the <picture> element when you have separate mobile and desktop images.

If you have only one image, with no separate mobile crop, then you don't need <picture>. The sizes attribute on image_tag handles resolution switching on its own.

Anchor to Avoid hiding content with CSS aloneAvoid hiding content with CSS alone

Hiding elements with display: none or visibility: hidden doesn't prevent the browser from downloading their resources. If you render a desktop-only promotional banner and hide it on mobile with CSS, then mobile customers still download its images.

The following markup sends both hero variants to every device, so every customer downloads both images no matter which one they see:

{% comment %}
Anti-pattern: both variants are in the HTML, and CSS hides one of them.
Both images are downloaded.
{% endcomment %}
<div class="hero--mobile">
{{ section.settings.mobile_image | image_url: width: 800 | image_tag: sizes: '100vw' }}
</div>
<div class="hero--desktop">
{{ section.settings.desktop_image | image_url: width: 1600 | image_tag: sizes: '100vw' }}
</div>

Liquid runs on the server and can't tell which device is making the request, so a Liquid conditional can't choose between a mobile and a desktop crop. For images, use <picture> with a media attribute: the markup for both crops is present, but the browser downloads only the source that matches:

<picture>
<source
media="(max-width: 767px)"
srcset="{{ section.settings.mobile_image | image_url: width: 800 }}"
width="{{ section.settings.mobile_image.width }}"
height="{{ section.settings.mobile_image.height }}"
>
{{ section.settings.desktop_image
| image_url: width: 1600
| image_tag:
widths: '800, 1200, 1600',
sizes: '100vw',
loading: 'eager',
fetchpriority: 'high'
}}
</picture>

Use a Liquid conditional when the decision is one the server can actually make, such as a merchant setting, the template, or the customer's login state. In that case the excluded markup is never sent to the client:

{% comment %}
The promotional banner is a merchant setting, so the server knows whether
it's needed. When it's off, its markup and images never reach the client.
{% endcomment %}
{% if section.settings.show_promo_banner %}
<div class="promo-banner">
{{ section.settings.promo_image
| image_url: width: 1200
| image_tag: loading: 'lazy', sizes: '100vw'
}}
</div>
{% endif %}

For content that's only conditionally needed, such as a promotional banner or a secondary call-to-action, consider using the Section Rendering API to load it on demand with JavaScript instead of including it in the initial HTML.

Anchor to Minimize DOM duplication for mobile and desktopMinimize DOM duplication for mobile and desktop

The most common source of DOM bloat in Shopify themes is rendering two complete navigation trees: one for mobile and one for desktop. This doubles the number of DOM nodes from the menu, which slows HTML parsing, increases style calculation cost, and raises memory usage.

Prefer a single navigation structure that adapts with CSS. If mobile and desktop navigations are too different to share markup, then render only the desktop nav in Liquid and load the mobile nav on demand:

{%- comment -%}
Render the desktop nav in the initial HTML.
The mobile nav loads through the Section Rendering API when the menu button is tapped.
{%- endcomment -%}
<nav class="desktop-nav" aria-label="Main">
{% for link in linklists.main-menu.links %}
<a href="{{ link.url }}">{{ link.title }}</a>
{% endfor %}
</nav>

<button
class="mobile-menu-toggle"
aria-label="Open menu"
data-menu-section="{{ section.id }}"
>
{% render 'icon-menu' %}
</button>

For detailed patterns, see Reduce mega-menu DOM overhead.


Anchor to Product card grid with correct ,[object Object], and conditional loadingProduct card grid with correct sizes and conditional loading

{% for product in collection.products %}
{%- liquid
# Test the below-the-fold case positively so a nil section.index stays eager.
assign image_loading = 'lazy'
unless section.index > 2
if forloop.index <= 4
assign image_loading = 'eager'
endif
endunless
-%}

<div class="product-card">
{{ product.featured_image
| image_url: width: 600
| image_tag:
loading: image_loading,
widths: '200, 300, 400, 600',
sizes: '(min-width: 1200px) calc(25vw - 2rem), (min-width: 768px) calc(50vw - 2rem), calc(100vw - 2rem)'
}}
<h3>{{ product.title }}</h3>
<p>{{ product.price | money }}</p>
</div>
{% endfor %}

This example combines three performance techniques: sizes matches the grid layout at each breakpoint, section.index limits eager loading to above-the-fold sections, and forloop.index limits eager loading to the first row of products.

The section.index test is written as unless section.index > 2 rather than if section.index <= 2. section.index is nil in static sections, in the online store editor, and in Section Rendering API responses, and comparisons against nil are falsey, so if section.index <= 2 would lazy-load the first row in exactly those contexts. See Use section.index for position-aware optimizations.

Anchor to Hero with art direction and position-aware loadingHero with art direction and position-aware loading

{%- liquid
# Test the below-the-fold case positively so a nil section.index stays eager.
if section.index > 2
assign hero_loading = 'lazy'
assign hero_fetchpriority = 'auto'
else
assign hero_loading = 'eager'
assign hero_fetchpriority = 'high'
endif
-%}

<picture>
<source
media="(max-width: 749px)"
srcset="
{{ section.settings.mobile_image | image_url: width: 400 }} 400w,
{{ section.settings.mobile_image | image_url: width: 600 }} 600w,
{{ section.settings.mobile_image | image_url: width: 800 }} 800w
"
sizes="100vw"
width="{{ section.settings.mobile_image.width }}"
height="{{ section.settings.mobile_image.height }}"
>
{{ section.settings.desktop_image
| image_url: width: 1600
| image_tag:
loading: hero_loading,
fetchpriority: hero_fetchpriority,
widths: '800, 1200, 1600',
sizes: '100vw'
}}
</picture>

  • Responsive Image Linter (browser extension): highlights images where sizes doesn't match the actual rendered size, showing exactly how much bandwidth is wasted.
  • DevTools Network panel at different viewports: resize the viewport and reload. Check that the image URLs change and that smaller images are requested at narrower widths.
  • DevTools device mode: throttle the connection to 3G and test on a phone-sized viewport. Oversized images become obvious when load times spike.
  • Lighthouse: run audits in both mobile and desktop mode. The Properly size images audit flags images where the downloaded size exceeds the rendered size.


Was this page helpful?