---
title: Build responsive layouts that perform well on mobile
description: >-
  Use Shopify's responsive image tools, conditional Liquid rendering, and
  targeted CSS to serve fast pages across all screen sizes.
source_url:
  html: >-
    https://shopify.dev/docs/storefronts/themes/best-practices/performance/implement-responsive-design
  md: >-
    https://shopify.dev/docs/storefronts/themes/best-practices/performance/implement-responsive-design.md
api_name: liquid
---

# 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.

***

## Why

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.

***

## How

### Write 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:

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

2-column product grid, 1 column on mobile:

```liquid
{{ 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:

```liquid
{{ 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`.

### Use `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:

```liquid
{{ 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.

### Use `<picture>` for art direction

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.

```liquid
<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](https://shopify.dev/docs/storefronts/themes/best-practices/performance/use-picture-for-art-directed-heroes).

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.

### Avoid 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:

```liquid
{% 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:

```liquid
<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:

```liquid
{% 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](https://shopify.dev/docs/api/ajax/section-rendering) to load it on demand with JavaScript instead of including it in the initial HTML.

### Minimize 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:

```liquid
{%- 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](https://shopify.dev/docs/storefronts/themes/best-practices/performance/reduce-mega-menu-dom-overhead).

***

## Examples

### Product card grid with correct `sizes` and conditional loading

```liquid
{% 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](https://shopify.dev/docs/storefronts/themes/best-practices/performance/use-section-index).

### Hero with art direction and position-aware loading

```liquid
{%- 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>
```

***

## Testing

* **Responsive Image Linter** ([browser extension](https://chromewebstore.google.com/detail/responsive-image-linter/mnddginionlghpbkfadlgdoaoemnahga)): 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.

***

## References

* [`image_tag`](https://shopify.dev/docs/api/liquid/filters/image_tag) filter
* [`image_url`](https://shopify.dev/docs/api/liquid/filters/image_url) filter
* [`section.index`](https://shopify.dev/docs/api/liquid/objects/section#section-index)
* [Use responsive images](https://shopify.dev/docs/storefronts/themes/best-practices/performance/use-responsive-images)
* [Use `section.index` for position-aware optimizations](https://shopify.dev/docs/storefronts/themes/best-practices/performance/use-section-index)
* [Reduce mega-menu DOM overhead](https://shopify.dev/docs/storefronts/themes/best-practices/performance/reduce-mega-menu-dom-overhead)
* [Never lazy-load the LCP image](https://shopify.dev/docs/storefronts/themes/best-practices/performance/never-lazy-load-lcp-image)
* [Prevent image layout shift](https://shopify.dev/docs/storefronts/themes/best-practices/performance/prevent-image-layout-shift)
* [Use the `<picture>` element when you have separate mobile and desktop images](https://shopify.dev/docs/storefronts/themes/best-practices/performance/use-picture-for-art-directed-heroes)
* [Section Rendering API](https://shopify.dev/docs/api/ajax/section-rendering)

***
