Media Gallery and Slider
Build a product media gallery with thumbnails, image zoom, video support, and responsive slider functionality.
A well-designed media gallery lets customers examine products in detail. It’s often the deciding factor between browsing and buying. Let’s build a flexible gallery that handles images, videos, and responsive behavior.
Product Media Types
Shopify supports multiple media types:
{%- for media in product.media -%} {{ media.media_type }} {# image, video, external_video, model #} {{ media.id }} {{ media.alt }} {{ media.position }}{%- endfor -%}Basic Gallery Structure
{# snippets/product-gallery.liquid #}
<div class="product-gallery" data-product-gallery> {# Main media display #} <div class="product-gallery__main"> {%- for media in product.media -%} <div class="product-gallery__slide {% if forloop.first %}is-active{% endif %}" data-media-id="{{ media.id }}" data-index="{{ forloop.index0 }}" > {%- render 'product-media', media: media -%} </div> {%- endfor -%} </div>
{# Thumbnail navigation #} {%- if product.media.size > 1 -%} <div class="product-gallery__thumbs"> {%- for media in product.media -%} <button class="product-gallery__thumb {% if forloop.first %}is-active{% endif %}" data-thumb-index="{{ forloop.index0 }}" aria-label="View {{ media.alt | default: 'product image' }}" > {%- if media.media_type == 'image' -%} <img src="{{ media | image_url: width: 100 }}" alt="" loading="lazy" width="100" height="100" > {%- elsif media.media_type == 'video' or media.media_type == 'external_video' -%} <div class="product-gallery__thumb-video"> <img src="{{ media.preview_image | image_url: width: 100 }}" alt="" loading="lazy" > <span class="product-gallery__thumb-play">▶</span> </div> {%- endif -%} </button> {%- endfor -%} </div> {%- endif -%}</div>Media Rendering Snippet
{# snippets/product-media.liquid #}
{%- case media.media_type -%} {%- when 'image' -%} <div class="product-media product-media--image"> <img src="{{ media | image_url: width: 1200 }}" srcset=" {{ media | image_url: width: 600 }} 600w, {{ media | image_url: width: 900 }} 900w, {{ media | image_url: width: 1200 }} 1200w, {{ media | image_url: width: 1800 }} 1800w " sizes="(min-width: 1024px) 50vw, 100vw" alt="{{ media.alt | default: product.title }}" loading="{{ loading | default: 'lazy' }}" width="{{ media.width }}" height="{{ media.height }}" class="product-media__image" {% if enable_zoom %}data-zoom-image="{{ media | image_url: width: 2400 }}"{% endif %} > </div>
{%- when 'video' -%} <div class="product-media product-media--video"> {{ media | video_tag: controls: true, loop: false, muted: false, preload: 'metadata', class: 'product-media__video' }} </div>
{%- when 'external_video' -%} <div class="product-media product-media--external-video"> {{ media | external_video_tag: class: 'product-media__video' }} </div>
{%- when 'model' -%} <div class="product-media product-media--model"> {{ media | model_viewer_tag: reveal: 'interaction', toggleable: true, class: 'product-media__model' }} </div>
{%- endcase -%}Gallery CSS
.product-gallery { display: flex; flex-direction: column; gap: var(--spacing-md);}
/* Main media area */.product-gallery__main { position: relative; aspect-ratio: 3/4; overflow: hidden; background: var(--color-background-secondary);}
.product-gallery__slide { position: absolute; inset: 0; opacity: 0; transition: opacity 0.3s ease;}
.product-gallery__slide.is-active { opacity: 1; z-index: 1;}
.product-media { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;}
.product-media__image { max-width: 100%; max-height: 100%; object-fit: contain;}
.product-media__video,.product-media__model { width: 100%; height: 100%;}
/* Thumbnails */.product-gallery__thumbs { display: flex; gap: var(--spacing-sm); overflow-x: auto; padding-bottom: var(--spacing-xs); scrollbar-width: thin;}
.product-gallery__thumb { flex-shrink: 0; width: 80px; height: 80px; padding: 0; border: 2px solid transparent; background: var(--color-background-secondary); cursor: pointer; transition: border-color 0.2s;}
.product-gallery__thumb:hover,.product-gallery__thumb.is-active { border-color: var(--color-text);}
.product-gallery__thumb img { width: 100%; height: 100%; object-fit: cover;}
.product-gallery__thumb-video { position: relative;}
.product-gallery__thumb-play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.3); color: white; font-size: 1.25rem;}Gallery JavaScript
class ProductGallery extends HTMLElement { constructor() { super(); this.slides = this.querySelectorAll('.product-gallery__slide'); this.thumbs = this.querySelectorAll('.product-gallery__thumb'); this.currentIndex = 0; }
connectedCallback() { // Thumbnail clicks this.thumbs.forEach((thumb, index) => { thumb.addEventListener('click', () => this.goTo(index)); });
// Keyboard navigation this.addEventListener('keydown', this.onKeydown.bind(this));
// Swipe support this.setupSwipe(); }
goTo(index) { // Update slides this.slides[this.currentIndex].classList.remove('is-active'); this.slides[index].classList.add('is-active');
// Update thumbs this.thumbs[this.currentIndex]?.classList.remove('is-active'); this.thumbs[index]?.classList.add('is-active');
// Pause any playing videos this.pauseAllMedia();
this.currentIndex = index;
// Scroll thumb into view this.thumbs[index]?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest', }); }
next() { const nextIndex = (this.currentIndex + 1) % this.slides.length; this.goTo(nextIndex); }
prev() { const prevIndex = (this.currentIndex - 1 + this.slides.length) % this.slides.length; this.goTo(prevIndex); }
onKeydown(event) { if (event.key === 'ArrowRight') { this.next(); } else if (event.key === 'ArrowLeft') { this.prev(); } }
setupSwipe() { let startX = 0; let startY = 0;
this.addEventListener( 'touchstart', (e) => { startX = e.touches[0].clientX; startY = e.touches[0].clientY; }, { passive: true } );
this.addEventListener( 'touchend', (e) => { const endX = e.changedTouches[0].clientX; const endY = e.changedTouches[0].clientY; const diffX = startX - endX; const diffY = startY - endY;
// Only swipe if horizontal movement is greater than vertical if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) { if (diffX > 0) { this.next(); } else { this.prev(); } } }, { passive: true } ); }
pauseAllMedia() { this.querySelectorAll('video').forEach((video) => video.pause()); }
// Called from variant selection to show variant image setActiveMedia(mediaId) { const index = Array.from(this.slides).findIndex((slide) => slide.dataset.mediaId == mediaId); if (index !== -1) { this.goTo(index); } }}
customElements.define('product-gallery', ProductGallery);Image Zoom
Add zoom on hover for detailed inspection:
{%- if section.settings.enable_zoom -%} <div class="product-media product-media--zoomable" data-zoom > <img src="{{ media | image_url: width: 1200 }}" data-zoom-image="{{ media | image_url: width: 2400 }}" alt="{{ media.alt }}" > </div>{%- endif -%}class ImageZoom extends HTMLElement { connectedCallback() { this.image = this.querySelector('img'); this.zoomImage = this.image.dataset.zoomImage;
this.addEventListener('mouseenter', this.onMouseEnter.bind(this)); this.addEventListener('mousemove', this.onMouseMove.bind(this)); this.addEventListener('mouseleave', this.onMouseLeave.bind(this)); }
onMouseEnter() { // Create zoom container this.zoomContainer = document.createElement('div'); this.zoomContainer.className = 'product-zoom'; this.zoomContainer.innerHTML = `<img src="${this.zoomImage}" alt="">`; this.appendChild(this.zoomContainer);
// Wait for image to load this.zoomContainer.querySelector('img').onload = () => { this.zoomContainer.classList.add('is-loaded'); }; }
onMouseMove(event) { if (!this.zoomContainer) return;
const rect = this.getBoundingClientRect(); const x = ((event.clientX - rect.left) / rect.width) * 100; const y = ((event.clientY - rect.top) / rect.height) * 100;
const zoomImg = this.zoomContainer.querySelector('img'); zoomImg.style.transformOrigin = `${x}% ${y}%`; }
onMouseLeave() { this.zoomContainer?.remove(); this.zoomContainer = null; }}
customElements.define('image-zoom', ImageZoom);.product-media--zoomable { cursor: zoom-in;}
.product-zoom { position: absolute; inset: 0; overflow: hidden; opacity: 0; transition: opacity 0.2s;}
.product-zoom.is-loaded { opacity: 1;}
.product-zoom img { width: 200%; height: 200%; max-width: none; transform-origin: center center;}Lightbox/Fullscreen
Add fullscreen viewing:
<button class="product-gallery__fullscreen" data-open-lightbox aria-label="View fullscreen"> {% render 'icon-expand' %}</button>
{# Lightbox modal #}<div class="product-lightbox" id="product-lightbox" hidden> <div class="product-lightbox__content"> <button class="product-lightbox__close" data-close-lightbox aria-label="Close"> {% render 'icon-close' %} </button>
<button class="product-lightbox__prev" data-lightbox-prev aria-label="Previous"> {% render 'icon-chevron-left' %} </button>
<div class="product-lightbox__media"> {%- for media in product.media -%} {%- if media.media_type == 'image' -%} <img src="{{ media | image_url: width: 2000 }}" alt="{{ media.alt }}" class="product-lightbox__image {% if forloop.first %}is-active{% endif %}" data-index="{{ forloop.index0 }}" > {%- endif -%} {%- endfor -%} </div>
<button class="product-lightbox__next" data-lightbox-next aria-label="Next"> {% render 'icon-chevron-right' %} </button> </div></div>Variant Image Switching
Update gallery when variant changes:
document.addEventListener('variant:changed', (event) => { const variant = event.detail.variant;
if (variant.featured_media) { const gallery = document.querySelector('product-gallery'); gallery.setActiveMedia(variant.featured_media.id); }});Mobile Slider
On mobile, use a simpler swipe-based slider:
@media (max-width: 767px) { .product-gallery__main { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; }
.product-gallery__slide { position: relative; flex: 0 0 100%; scroll-snap-align: start; opacity: 1; }
.product-gallery__thumbs { display: none; }
/* Dots indicator */ .product-gallery__dots { display: flex; justify-content: center; gap: var(--spacing-xs); padding: var(--spacing-sm) 0; }
.product-gallery__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--color-border); }
.product-gallery__dot.is-active { background: var(--color-text); }}
@media (min-width: 768px) { .product-gallery__dots { display: none; }}Complete Gallery Component
{# snippets/product-gallery.liquid #}
<product-gallery class="product-gallery" data-product-gallery> {# Main media viewer #} <div class="product-gallery__viewer"> <div class="product-gallery__main"> {%- for media in product.media -%} <div class="product-gallery__slide {% if forloop.first %}is-active{% endif %}" data-media-id="{{ media.id }}" data-index="{{ forloop.index0 }}" > {%- render 'product-media', media: media, enable_zoom: section.settings.enable_zoom, loading: forloop.first | ternary: 'eager', 'lazy' -%} </div> {%- endfor -%} </div>
{# Navigation arrows #} {%- if product.media.size > 1 -%} <button class="product-gallery__arrow product-gallery__arrow--prev" data-gallery-prev aria-label="Previous"> {% render 'icon-chevron-left' %} </button> <button class="product-gallery__arrow product-gallery__arrow--next" data-gallery-next aria-label="Next"> {% render 'icon-chevron-right' %} </button> {%- endif -%}
{# Fullscreen button #} {%- if section.settings.enable_lightbox -%} <button class="product-gallery__fullscreen" data-open-lightbox aria-label="View fullscreen"> {% render 'icon-expand' %} </button> {%- endif -%} </div>
{# Thumbnails #} {%- if product.media.size > 1 -%} <div class="product-gallery__thumbs-wrapper"> <div class="product-gallery__thumbs"> {%- for media in product.media -%} <button class="product-gallery__thumb {% if forloop.first %}is-active{% endif %}" data-thumb-index="{{ forloop.index0 }}" aria-label="View {{ media.alt | default: 'image' }} {{ forloop.index }} of {{ product.media.size }}" > <img src="{{ media.preview_image | image_url: width: 100, height: 100, crop: 'center' }}" alt="" loading="lazy" width="100" height="100" > {%- if media.media_type == 'video' or media.media_type == 'external_video' -%} <span class="product-gallery__thumb-badge">{% render 'icon-play' %}</span> {%- elsif media.media_type == 'model' -%} <span class="product-gallery__thumb-badge">3D</span> {%- endif -%} </button> {%- endfor -%} </div> </div>
{# Mobile dots #} <div class="product-gallery__dots"> {%- for media in product.media -%} <span class="product-gallery__dot {% if forloop.first %}is-active{% endif %}"></span> {%- endfor -%} </div> {%- endif -%}</product-gallery>Practice Exercise
Build a product gallery that:
- Shows thumbnails below the main image
- Supports keyboard navigation
- Handles swipe on mobile
- Shows variant-specific images when variant changes
- Includes a fullscreen/lightbox option
Test with products that have:
- Multiple images
- Videos
- Only one image
Key Takeaways
- Use
product.mediafor all media types - Support images, videos, 3D models with proper tags
- Thumbnails aid navigation for multiple media
- Add swipe support for mobile users
- Keyboard navigation improves accessibility
- Image zoom lets customers inspect details
- Variant switching should update gallery
- Responsive design: thumbnails on desktop, dots on mobile
What’s Next?
With the gallery complete, the next lesson covers Variant Selection: Options and Availability for building variant pickers.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...