Table of Contents
- Introduction
- What We'll Build
- What is a Theme App Extension?
- Creating the Extension
- Building the Slideshow Markup
- Styling the Slideshow
- Adding Slide Animation with JavaScript
- Supporting Separate Mobile and Desktop Images
- Making It Configurable from the Theme Editor
- Complete File
- Things to Watch Out For
- Conclusion
- UR: Smart Image Slideshow
- References
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:
Merchants configure it entirely from 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:
- A Shopify Partner account
- A development store
- Shopify CLI installed
- An existing Shopify app (or create one with
shopify app init)
Generate the Extension
Inside your app directory, run:
shopify app generate extension --template theme_app_extension --name image-slideshow
This creates the following structure:
extensions/
image-slideshow/
blocks/
assets/
locales/
snippets/
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">
❮
</button>
<button class="app-slideshow__arrow app-slideshow__arrow--next" aria-label="Next slide">
❯
</button>
</div>
{{ 'slideshow.js' | asset_url | script_tag }}
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 uselazy - 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;
}
This gives us two animation styles:
-
Slide (default) — slides move horizontally using
transform: translateX() -
Fade — slides crossfade using
opacitytransitions
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);
})();
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>
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 %}
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">
❮
</button>
<button class="app-slideshow__arrow app-slideshow__arrow--next" aria-label="Next slide">
❯
</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 %}
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_urlfilter with awidthparameter — 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-labelattributes on navigation buttons (we did this) - Provide meaningful
alttext 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-motionmedia query:
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
// Disable autoplay and use instant transitions
}
Conclusion
In this tutorial, we built a fully customizable image slideshow for Shopify stores using Theme App Extensions. Here's what we covered:
- Created a Theme App Extension with Shopify CLI
- Built a responsive slideshow with Liquid, CSS, and JavaScript
- Added slide and fade animations with touch/swipe support
-
Supported separate PC and mobile images using the
<picture>element - 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:
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.


Top comments (0)