DEV Community

Eiji Saito
Eiji Saito

Posted on

How to Build an Image Slideshow on Shopify Using Theme App Extensions — A Step-by-Step Guide

Table of Contents


Introduction

A well-designed image slideshow is one of the most effective ways to showcase products, promotions, or brand stories on a Shopify store. While Shopify themes often include a basic slideshow section, merchants frequently need more control — custom animations, separate mobile/desktop images, and fine-tuned speed settings.

In this tutorial, I'll show you how to build a fully customizable image slideshow as a Shopify app using Theme App Extensions. We'll write real Liquid, CSS, and JavaScript — no theme code editing required, no ScriptTags, and merchants can add the slideshow from the theme editor with zero coding.

By the end, you'll have a working app block that:

  • Displays a responsive image slideshow with smooth transitions
  • Supports separate images for PC and mobile
  • Lets merchants customize animation style, speed, and design from the theme editor
  • Works across all Online Store 2.0 themes

Let's dive in.


What We'll Build

Here's the final slideshow on a storefront:

the final slideshow

Merchants configure it entirely from the theme editor:

the theme editor


What is a Theme App Extension?

Theme App Extensions let Shopify app developers inject UI components — called app blocks — into a merchant's theme without touching the theme's source code.

Why this matters:

  • No theme code editing — Merchants add your block via the theme editor, just like any native section or block.
  • Theme-safe — Your code lives inside your app. Theme updates won't break anything.
  • Uninstall-clean — When a merchant uninstalls your app, all injected blocks are automatically removed. No leftover code.

App blocks are built with Liquid, CSS, and JavaScript — the same stack used in Shopify themes. If you've worked with themes before, you'll feel right at home.

For more details, see the official Shopify documentation on Theme App Extensions.


Creating the Extension

Prerequisites

Make sure you have:

Generate the Extension

Inside your app directory, run:

shopify app generate extension --template theme_app_extension --name image-slideshow
Enter fullscreen mode Exit fullscreen mode

This creates the following structure:

extensions/
  image-slideshow/
    blocks/
    assets/
    locales/
    snippets/
Enter fullscreen mode Exit fullscreen mode

Directory Overview

Directory Purpose
blocks/ Liquid files that become app blocks in the theme editor
assets/ CSS and JavaScript files
locales/ Translation files for multi-language support
snippets/ Reusable Liquid snippets

Building the Slideshow Markup

Create the app block at extensions/image-slideshow/blocks/slideshow.liquid:

{{ 'slideshow.css' | asset_url | stylesheet_tag }}

<div
  class="app-slideshow"
  data-speed="{{ block.settings.speed }}"
  data-animation="{{ block.settings.animation }}"
  data-autoplay="{{ block.settings.autoplay }}"
>
  <div class="app-slideshow__track">
    {% for i in (1..5) %}
      {% capture desktop_key %}slide_{{ i }}_desktop{% endcapture %}
      {% capture mobile_key %}slide_{{ i }}_mobile{% endcapture %}
      {% capture link_key %}slide_{{ i }}_link{% endcapture %}

      {% if block.settings[desktop_key] != blank %}
        <div class="app-slideshow__slide" data-index="{{ forloop.index0 }}">
          {% if block.settings[link_key] != blank %}
            <a href="{{ block.settings[link_key] }}" class="app-slideshow__link">
          {% endif %}

          {%- comment -%} Desktop image {%- endcomment -%}
          <picture>
            {% if block.settings[mobile_key] != blank %}
              <source
                media="(max-width: 749px)"
                srcset="{{ block.settings[mobile_key] | image_url: width: 750 }}"
              >
            {% endif %}
            <img
              class="app-slideshow__image"
              src="{{ block.settings[desktop_key] | image_url: width: 1500 }}"
              alt="{{ block.settings[desktop_key].alt | escape }}"
              loading="{% if forloop.first %}eager{% else %}lazy{% endif %}"
              width="1500"
              height="750"
            >
          </picture>

          {% if block.settings[link_key] != blank %}
            </a>
          {% endif %}
        </div>
      {% endif %}
    {% endfor %}
  </div>

  {%- comment -%} Navigation dots {%- endcomment -%}
  <div class="app-slideshow__dots"></div>

  {%- comment -%} Previous / Next arrows {%- endcomment -%}
  <button class="app-slideshow__arrow app-slideshow__arrow--prev" aria-label="Previous slide">
    &#10094;
  </button>
  <button class="app-slideshow__arrow app-slideshow__arrow--next" aria-label="Next slide">
    &#10095;
  </button>
</div>

{{ 'slideshow.js' | asset_url | script_tag }}
Enter fullscreen mode Exit fullscreen mode

A few things to note:

  • We use the <picture> element to serve different images for mobile and desktop
  • The first slide uses loading="eager" for performance; subsequent slides use lazy
  • Navigation dots are generated dynamically by JavaScript
  • Data attributes pass merchant settings to JavaScript

Styling the Slideshow

Create extensions/image-slideshow/assets/slideshow.css:

.app-slideshow {
  position: relative;
  overflow: hidden;
  width: 100%;
  max-width: 100%;
}

.app-slideshow__track {
  display: flex;
  transition: transform 0.5s ease-in-out;
  will-change: transform;
}

.app-slideshow__slide {
  min-width: 100%;
  flex-shrink: 0;
}

.app-slideshow__image {
  display: block;
  width: 100%;
  height: auto;
  object-fit: cover;
}

.app-slideshow__link {
  display: block;
}

/* Arrows */
.app-slideshow__arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  background: rgba(0, 0, 0, 0.4);
  color: #fff;
  border: none;
  font-size: 24px;
  padding: 12px 16px;
  cursor: pointer;
  z-index: 2;
  border-radius: 4px;
  transition: background 0.2s;
}

.app-slideshow__arrow:hover {
  background: rgba(0, 0, 0, 0.7);
}

.app-slideshow__arrow--prev {
  left: 12px;
}

.app-slideshow__arrow--next {
  right: 12px;
}

/* Dots */
.app-slideshow__dots {
  display: flex;
  justify-content: center;
  gap: 8px;
  padding: 12px 0;
}

.app-slideshow__dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  border: none;
  background: rgba(0, 0, 0, 0.3);
  cursor: pointer;
  padding: 0;
  transition: background 0.2s;
}

.app-slideshow__dot.is-active {
  background: rgba(0, 0, 0, 0.8);
}

/* Fade animation variant */
.app-slideshow[data-animation="fade"] .app-slideshow__track {
  display: block;
  position: relative;
}

.app-slideshow[data-animation="fade"] .app-slideshow__slide {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  opacity: 0;
  transition: opacity 0.6s ease-in-out;
}

.app-slideshow[data-animation="fade"] .app-slideshow__slide:first-child {
  position: relative;
}

.app-slideshow[data-animation="fade"] .app-slideshow__slide.is-active {
  opacity: 1;
  z-index: 1;
}
Enter fullscreen mode Exit fullscreen mode

This gives us two animation styles:

  • Slide (default) — slides move horizontally using transform: translateX()
  • Fade — slides crossfade using opacity transitions

Adding Slide Animation with JavaScript

Create extensions/image-slideshow/assets/slideshow.js:

(function () {
  function initSlideshow(container) {
    const track = container.querySelector('.app-slideshow__track');
    const slides = container.querySelectorAll('.app-slideshow__slide');
    const dotsContainer = container.querySelector('.app-slideshow__dots');
    const prevBtn = container.querySelector('.app-slideshow__arrow--prev');
    const nextBtn = container.querySelector('.app-slideshow__arrow--next');

    if (!track || slides.length === 0) return;

    const speed = parseInt(container.dataset.speed, 10) || 5000;
    const animation = container.dataset.animation || 'slide';
    const autoplay = container.dataset.autoplay === 'true';
    const totalSlides = slides.length;
    let currentIndex = 0;
    let autoplayTimer = null;

    // Generate dots
    dotsContainer.innerHTML = '';
    slides.forEach(function (_, index) {
      const dot = document.createElement('button');
      dot.classList.add('app-slideshow__dot');
      dot.setAttribute('aria-label', 'Go to slide ' + (index + 1));
      if (index === 0) dot.classList.add('is-active');
      dot.addEventListener('click', function () {
        goToSlide(index);
      });
      dotsContainer.appendChild(dot);
    });

    const dots = dotsContainer.querySelectorAll('.app-slideshow__dot');

    function goToSlide(index) {
      currentIndex = index;

      if (animation === 'fade') {
        slides.forEach(function (slide, i) {
          slide.classList.toggle('is-active', i === currentIndex);
        });
      } else {
        track.style.transform = 'translateX(-' + (currentIndex * 100) + '%)';
      }

      dots.forEach(function (dot, i) {
        dot.classList.toggle('is-active', i === currentIndex);
      });

      resetAutoplay();
    }

    function nextSlide() {
      goToSlide((currentIndex + 1) % totalSlides);
    }

    function prevSlide() {
      goToSlide((currentIndex - 1 + totalSlides) % totalSlides);
    }

    function resetAutoplay() {
      if (!autoplay) return;
      clearInterval(autoplayTimer);
      autoplayTimer = setInterval(nextSlide, speed);
    }

    // Arrow buttons
    prevBtn.addEventListener('click', prevSlide);
    nextBtn.addEventListener('click', nextSlide);

    // Touch / swipe support
    let touchStartX = 0;
    let touchEndX = 0;

    container.addEventListener('touchstart', function (e) {
      touchStartX = e.changedTouches[0].screenX;
    }, { passive: true });

    container.addEventListener('touchend', function (e) {
      touchEndX = e.changedTouches[0].screenX;
      var diff = touchStartX - touchEndX;
      if (Math.abs(diff) > 50) {
        if (diff > 0) {
          nextSlide();
        } else {
          prevSlide();
        }
      }
    }, { passive: true });

    // Initialize first slide for fade animation
    if (animation === 'fade') {
      slides[0].classList.add('is-active');
    }

    // Start autoplay
    if (autoplay) {
      autoplayTimer = setInterval(nextSlide, speed);
    }

    // Hide arrows if single slide
    if (totalSlides <= 1) {
      prevBtn.style.display = 'none';
      nextBtn.style.display = 'none';
      dotsContainer.style.display = 'none';
    }
  }

  function initAll() {
    document.querySelectorAll('.app-slideshow').forEach(initSlideshow);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initAll);
  } else {
    initAll();
  }

  // Re-initialize on theme editor section reload
  document.addEventListener('shopify:section:load', initAll);
})();
Enter fullscreen mode Exit fullscreen mode

Key features:

  • Two animation modes — slide (translateX) and fade (opacity)
  • Autoplay with configurable speed
  • Touch/swipe support for mobile devices
  • Navigation dots generated dynamically from the slide count
  • Theme editor compatible — re-initializes on shopify:section:load

Supporting Separate Mobile and Desktop Images

One of the most requested features for store slideshows is the ability to set different images for PC and mobile. Banner images designed for wide desktop screens often look terrible on narrow mobile viewports — text gets too small, the composition breaks, and the impact is lost.

We handle this with the <picture> element:

<picture>
  {% if block.settings[mobile_key] != blank %}
    <source
      media="(max-width: 749px)"
      srcset="{{ block.settings[mobile_key] | image_url: width: 750 }}"
    >
  {% endif %}
  <img
    src="{{ block.settings[desktop_key] | image_url: width: 1500 }}"
    alt="{{ block.settings[desktop_key].alt | escape }}"
  >
</picture>
Enter fullscreen mode Exit fullscreen mode

How it works:

  • On screens 749px and below (mobile), the browser loads the mobile image
  • On screens 750px and above (desktop/tablet), the desktop image is used
  • If no mobile image is set, the desktop image is used as a fallback

This approach is better than using CSS display: none because the browser only downloads the image it actually needs — saving bandwidth on mobile.


Making It Configurable from the Theme Editor

The schema at the bottom of the Liquid file defines what merchants see in the theme editor. Add the following at the very end of slideshow.liquid, after the {{ 'slideshow.js' | asset_url | script_tag }} line:

{% schema %}
{
  "name": "Image Slideshow",
  "target": "section",
  "settings": [
    {
      "type": "select",
      "id": "animation",
      "label": "Animation style",
      "options": [
        { "value": "slide", "label": "Slide" },
        { "value": "fade", "label": "Fade" }
      ],
      "default": "slide"
    },
    {
      "type": "range",
      "id": "speed",
      "label": "Autoplay speed (ms)",
      "min": 2000,
      "max": 10000,
      "step": 500,
      "default": 5000,
      "unit": "ms"
    },
    {
      "type": "checkbox",
      "id": "autoplay",
      "label": "Enable autoplay",
      "default": true
    },
    {
      "type": "header",
      "content": "Slide 1"
    },
    {
      "type": "image_picker",
      "id": "slide_1_desktop",
      "label": "Desktop image"
    },
    {
      "type": "image_picker",
      "id": "slide_1_mobile",
      "label": "Mobile image (optional)"
    },
    {
      "type": "url",
      "id": "slide_1_link",
      "label": "Link URL (optional)"
    },
    {
      "type": "header",
      "content": "Slide 2"
    },
    {
      "type": "image_picker",
      "id": "slide_2_desktop",
      "label": "Desktop image"
    },
    {
      "type": "image_picker",
      "id": "slide_2_mobile",
      "label": "Mobile image (optional)"
    },
    {
      "type": "url",
      "id": "slide_2_link",
      "label": "Link URL (optional)"
    },
    {
      "type": "header",
      "content": "Slide 3"
    },
    {
      "type": "image_picker",
      "id": "slide_3_desktop",
      "label": "Desktop image"
    },
    {
      "type": "image_picker",
      "id": "slide_3_mobile",
      "label": "Mobile image (optional)"
    },
    {
      "type": "url",
      "id": "slide_3_link",
      "label": "Link URL (optional)"
    },
    {
      "type": "header",
      "content": "Slide 4"
    },
    {
      "type": "image_picker",
      "id": "slide_4_desktop",
      "label": "Desktop image"
    },
    {
      "type": "image_picker",
      "id": "slide_4_mobile",
      "label": "Mobile image (optional)"
    },
    {
      "type": "url",
      "id": "slide_4_link",
      "label": "Link URL (optional)"
    },
    {
      "type": "header",
      "content": "Slide 5"
    },
    {
      "type": "image_picker",
      "id": "slide_5_desktop",
      "label": "Desktop image"
    },
    {
      "type": "image_picker",
      "id": "slide_5_mobile",
      "label": "Mobile image (optional)"
    },
    {
      "type": "url",
      "id": "slide_5_link",
      "label": "Link URL (optional)"
    }
  ],
  "presets": [
    {
      "name": "Image Slideshow"
    }
  ]
}
{% endschema %}
Enter fullscreen mode Exit fullscreen mode

This gives merchants full control over:

  • Animation style — slide or fade
  • Autoplay speed — 2 to 10 seconds with a range slider
  • Autoplay toggle — on/off
  • Up to 5 slides, each with desktop image, mobile image, and optional link URL

Complete File

Here is the complete slideshow.liquid file with markup and schema combined. You can copy this directly into extensions/image-slideshow/blocks/slideshow.liquid:

{{ 'slideshow.css' | asset_url | stylesheet_tag }}

<div
  class="app-slideshow"
  data-speed="{{ block.settings.speed }}"
  data-animation="{{ block.settings.animation }}"
  data-autoplay="{{ block.settings.autoplay }}"
>
  <div class="app-slideshow__track">
    {% for i in (1..5) %}
      {% capture desktop_key %}slide_{{ i }}_desktop{% endcapture %}
      {% capture mobile_key %}slide_{{ i }}_mobile{% endcapture %}
      {% capture link_key %}slide_{{ i }}_link{% endcapture %}

      {% if block.settings[desktop_key] != blank %}
        <div class="app-slideshow__slide" data-index="{{ forloop.index0 }}">
          {% if block.settings[link_key] != blank %}
            <a href="{{ block.settings[link_key] }}" class="app-slideshow__link">
          {% endif %}

          <picture>
            {% if block.settings[mobile_key] != blank %}
              <source
                media="(max-width: 749px)"
                srcset="{{ block.settings[mobile_key] | image_url: width: 750 }}"
              >
            {% endif %}
            <img
              class="app-slideshow__image"
              src="{{ block.settings[desktop_key] | image_url: width: 1500 }}"
              alt="{{ block.settings[desktop_key].alt | escape }}"
              loading="{% if forloop.first %}eager{% else %}lazy{% endif %}"
              width="1500"
              height="750"
            >
          </picture>

          {% if block.settings[link_key] != blank %}
            </a>
          {% endif %}
        </div>
      {% endif %}
    {% endfor %}
  </div>

  <div class="app-slideshow__dots"></div>

  <button class="app-slideshow__arrow app-slideshow__arrow--prev" aria-label="Previous slide">
    &#10094;
  </button>
  <button class="app-slideshow__arrow app-slideshow__arrow--next" aria-label="Next slide">
    &#10095;
  </button>
</div>

{{ 'slideshow.js' | asset_url | script_tag }}

{% schema %}
{
  "name": "Image Slideshow",
  "target": "section",
  "settings": [
    {
      "type": "select",
      "id": "animation",
      "label": "Animation style",
      "options": [
        { "value": "slide", "label": "Slide" },
        { "value": "fade", "label": "Fade" }
      ],
      "default": "slide"
    },
    {
      "type": "range",
      "id": "speed",
      "label": "Autoplay speed (ms)",
      "min": 2000,
      "max": 10000,
      "step": 500,
      "default": 5000,
      "unit": "ms"
    },
    {
      "type": "checkbox",
      "id": "autoplay",
      "label": "Enable autoplay",
      "default": true
    },
    {
      "type": "header",
      "content": "Slide 1"
    },
    {
      "type": "image_picker",
      "id": "slide_1_desktop",
      "label": "Desktop image"
    },
    {
      "type": "image_picker",
      "id": "slide_1_mobile",
      "label": "Mobile image (optional)"
    },
    {
      "type": "url",
      "id": "slide_1_link",
      "label": "Link URL (optional)"
    },
    {
      "type": "header",
      "content": "Slide 2"
    },
    {
      "type": "image_picker",
      "id": "slide_2_desktop",
      "label": "Desktop image"
    },
    {
      "type": "image_picker",
      "id": "slide_2_mobile",
      "label": "Mobile image (optional)"
    },
    {
      "type": "url",
      "id": "slide_2_link",
      "label": "Link URL (optional)"
    },
    {
      "type": "header",
      "content": "Slide 3"
    },
    {
      "type": "image_picker",
      "id": "slide_3_desktop",
      "label": "Desktop image"
    },
    {
      "type": "image_picker",
      "id": "slide_3_mobile",
      "label": "Mobile image (optional)"
    },
    {
      "type": "url",
      "id": "slide_3_link",
      "label": "Link URL (optional)"
    },
    {
      "type": "header",
      "content": "Slide 4"
    },
    {
      "type": "image_picker",
      "id": "slide_4_desktop",
      "label": "Desktop image"
    },
    {
      "type": "image_picker",
      "id": "slide_4_mobile",
      "label": "Mobile image (optional)"
    },
    {
      "type": "url",
      "id": "slide_4_link",
      "label": "Link URL (optional)"
    },
    {
      "type": "header",
      "content": "Slide 5"
    },
    {
      "type": "image_picker",
      "id": "slide_5_desktop",
      "label": "Desktop image"
    },
    {
      "type": "image_picker",
      "id": "slide_5_mobile",
      "label": "Mobile image (optional)"
    },
    {
      "type": "url",
      "id": "slide_5_link",
      "label": "Link URL (optional)"
    }
  ],
  "presets": [
    {
      "name": "Image Slideshow"
    }
  ]
}
{% endschema %}
Enter fullscreen mode Exit fullscreen mode

Things to Watch Out For

1. Performance and Image Optimization

Large slideshow images can seriously hurt page load times. Follow these best practices:

  • Use Shopify's image_url filter with a width parameter — this serves optimally sized images through Shopify's CDN
  • Set loading="lazy" on all slides except the first one
  • Keep image file sizes reasonable (compress before uploading)

2. Theme Compatibility

App blocks work with Online Store 2.0 themes only. Older "vintage" themes do not support app blocks. Most themes in the Shopify Theme Store today are OS 2.0, but it's worth noting in your app's documentation.

Additionally, each theme has different section structures. Test your slideshow against:

  • Dawn (Shopify's default free theme)
  • At least 2-3 popular paid themes

3. Accessibility

Make sure your slideshow is accessible:

  • Include aria-label attributes on navigation buttons (we did this)
  • Provide meaningful alt text on images — this comes from the merchant's image upload
  • Consider adding aria-live="polite" to announce slide changes to screen readers
  • Ensure arrow buttons and dots are keyboard-navigable

4. Autoplay Considerations

While autoplay makes slideshows dynamic, some users find auto-advancing slides frustrating. Consider:

  • Making autoplay off by default and letting merchants opt in
  • Pausing autoplay when the user hovers over the slideshow
  • Respecting the prefers-reduced-motion media query:
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
  // Disable autoplay and use instant transitions
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this tutorial, we built a fully customizable image slideshow for Shopify stores using Theme App Extensions. Here's what we covered:

  1. Created a Theme App Extension with Shopify CLI
  2. Built a responsive slideshow with Liquid, CSS, and JavaScript
  3. Added slide and fade animations with touch/swipe support
  4. Supported separate PC and mobile images using the <picture> element
  5. Made everything configurable from the theme editor — no code required for merchants

Theme App Extensions are a powerful pattern for building storefront features. Once you understand the approach, you can apply it to all kinds of UI components — countdown timers, custom badges, product tabs, and more.

If you're looking for a production-ready slideshow without building from scratch, we develop Shopify apps at UnReact that use this exact same Theme App Extension architecture. Our UR: Smart Image Slideshow provides a no-code image slider with customizable animations, speed control, and separate mobile/desktop image support — all configurable from the theme editor.

Feel free to drop a comment below if you have questions. Happy building!


UR: Smart Image Slideshow

If you want a production-ready image slideshow without building one from scratch, check out our Shopify app:

👉 UR: Smart Image Slideshow

Built by UnReact, this app uses the exact same Theme App Extension architecture covered in this tutorial. It provides:

  • No-code setup — add a fully customizable slideshow from the theme editor
  • Slide & fade animations with adjustable speed
  • Separate PC and mobile images for optimized responsive design
  • Up to 10 slides with optional link URLs
  • Works with all Online Store 2.0 themes

Install it in one click and start showcasing your products, promotions, and brand stories with a beautiful image slider — no coding required.


References

Top comments (0)