Product Page Implementation Intermediate 15 min read

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 -%}
{# 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 -%}
.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;
}
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;
}
}
{# 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:

  1. Shows thumbnails below the main image
  2. Supports keyboard navigation
  3. Handles swipe on mobile
  4. Shows variant-specific images when variant changes
  5. Includes a fullscreen/lightbox option

Test with products that have:

  • Multiple images
  • Videos
  • Only one image

Key Takeaways

  1. Use product.media for all media types
  2. Support images, videos, 3D models with proper tags
  3. Thumbnails aid navigation for multiple media
  4. Add swipe support for mobile users
  5. Keyboard navigation improves accessibility
  6. Image zoom lets customers inspect details
  7. Variant switching should update gallery
  8. 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...