Shadows images should by default have a film strip on the bottom, yet the thumbnails are off to the left. When you have ten images it looks horrible. Shadows images handling also don't let you elegantly or easily put the filmstrip thumbnails under the image, but we can do it with SWIPER.JS quite easily.
1. Load the cdn. CSS at the top, JS at the bottom. https://swiperjs.com/get-started
2. CSS:
<mvt:comment>
#
# swiper.js - modern/better bx slider/carousel - also used for filmstrip because miva image js/shadows doesn't play well with horzontal layout
#
</mvt:comment>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/11.0.5/swiper-bundle.min.css" integrity="sha512-rd0qOHVMOcez6pLWPVFIv7EfSdGKLt+eafXh4RO/12Fgr41hDQxfGvoi1Vy55QIVcQEujUE1LQrATCLl2Fs+ag==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<style>
<mvt:comment>
#
# images thumbnail filmstrip
#
</mvt:comment>
.x-filmstrip-swiper .swiper-slide {
width: var(--prod-thumb-x, 64px);
}
.x-filmstrip-swiper {
overflow: visible;
height: var(--prod-thumb-y);
}
.x-filmstrip-swiper .swiper-wrapper {
overflow: visible;
}
.x-filmstrip-swiper .swiper-button-prev,
.x-filmstrip-swiper .swiper-button-next {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.6);
color: #fff;
top: 58% !important;
display: flex;
align-items: center;
justify-content: center;
}
.x-filmstrip-swiper .swiper-button-prev::after,
.x-filmstrip-swiper .swiper-button-next::after {
display: none;
}
.x-filmstrip-swiper .swiper-button-prev [class*="u-icon"],
.x-filmstrip-swiper .swiper-button-next [class*="u-icon"] {
font-size: 1rem;
line-height: 1;
}
@media (max-width: 767px) {
.x-filmstrip-swiper .swiper-button-prev,
.x-filmstrip-swiper .swiper-button-next {
display: none;
}
}
<mvt:comment>
#
# related products swiper
#
</mvt:comment>
.swiper-button-next-unique, .swiper-button-prev-unique {
color: #5e7a6e; !important;
margin: auto;
}
.swiper-button-next-unique::after, .swiper-button-prev-unique::after {
content: "" !important;;
}
</style>
3: Images MVT Code in the Product Display Layout template
<section class="o-layout o-layout--wide x-product-layout">
<div class="o-layout__item u-width-12 u-width-5--m">
<mvt:comment>
#
# swiper start
#
</mvt:comment>
<div class="x-product-layout-images" data-PhotoGallery role="group" style="--prod-img-x: &mvte:product_display_imagemachine:image_width;; --prod-img-y: &mvte:product_display_imagemachine:image_height;; --prod-thumb-x: &mvte:product_display_imagemachine:thumb_width;px; --prod-thumb-y: &mvte:product_display_imagemachine:thumb_height;px;">
<figure class="x-product-layout-images__figure">
<a data-photograph href="#" aria-label="Open larger &mvte:product:name; images">
<picture class="x-product-layout-images__picture">
<img id="main_image" class="x-product-layout-images__image" src="&mvt:imagedata[1]:image:image;" alt="&mvte:product:name;" loading="lazy" width="&mvte:product_display_imagemachine:image_width;" height="&mvte:product_display_imagemachine:image_height;" style="max-width: 350px; margin: auto;">
</picture>
</a>
</figure>
<section class="x-filmstrip-wrapper">
<div class="swiper x-filmstrip-swiper">
<h3 id="filmstrip-heading" class="x-filmstrip__heading u-hide-visually">Thumbnail Filmstrip of &mvt:product:name; Images</h3>
<div class="swiper-wrapper" id="thumbnails" aria-label="Thumbnail Filmstrip" role="region"></div>
<div class="swiper-button-prev"><span class="u-icon-chevron-left u-color-white" aria-hidden="true"></span></div>
<div class="swiper-button-next"><span class="u-icon-chevron-right u-color-white" aria-hidden="true"></span></div>
</div>
</section>
</div>
<mvt:comment>
#
# swiper end
GET RID OF THIS AND PUT IT IN THE FOOT:
<mvt:item name="product_display_imagemachine" param="body_deferred:product:id"/>
#
</mvt:comment>
<!-- end .x-product-layout-images -->
</div>
4. Related Products Swiper MVT
<section class="o-layout o-layout--justify-center">
<div class="o-layout__item u-width-1 u-text-center" style="padding: 0; display: flex; align-items: center;">
<div class="swiper-button-prev-unique color-skyblue"><span class="u-icon-triangle-left" style="font-size: 2em;"></span></div>
</div>
<div class="o-layout__item u-width-8 u-width-10--s u-text-center" style="padding: 0;">
<div class="swiper mySwiper">
<div class="swiper-wrapper" style="display: flex;">
<mvt:foreach iterator="product" array="related_products:products">
<div class="swiper-slide" >
<a class="u-block x-product-list__link" style="text-decoration: none;" href="https://&mvt:global:domain:name;/animal/&mvt:product:code;.html" title="&mvte:product:name;">
<div style="min-height: 255px;">
<img class="x-product-list__image" src="&mvte:product:imagetypes:MainPrimary;" alt="&mvte:product:name;">
</div>
<strong class="x-product-list__name">&mvte:product:name;</strong>
<mvt:if expr="l.settings:product:base_price GT l.settings:product:price">
<span class="u-text-center">
<span class="x-product-list__price text-price-green">
&mvt:product:formatted_price;
</span>
<span class="x-product-list__price text-price-gray"><s>&mvt:product:formatted_base_price;</s></span>
</span>
<mvt:elseif expr="l.settings:product_msrp GT l.settings:product:price">
<span class="u-text-center">
<span class="x-product-list__price text-price-green">
&mvt:product:formatted_price;
</span>
<span class="x-product-list__price text-price-gray"><s>$&mvt:product_msrp;</s></span>
</span>
<mvt:else>
<span class="x-product-list__price text-price-green">
&mvt:product:formatted_price;
</span>
</mvt:if>
</a>
</div>
</mvt:foreach>
</div>
</div>
</div>
<div class="o-layout__item u-width-1 u-text-center" style="padding: 0; display: flex; align-items: center;">
<div class="swiper-button-next-unique color-skyblue" ><span class="u-icon-triangle-right" style="font-size: 2em;"></span></div>
</div>
</section>
5. Product Display Layout Image Machine JS Section
const debounce = (callback, wait) => {
let timeoutId = null;
return (...args) => {
window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
callback.apply(null, args);
}, wait);
};
};
const productName = '&mvtj:product:name;';
let generate_thumbnail_event = new CustomEvent('ImageMachine_Generate_Thumbnail');
let images = [];
let thumbnailIndex = 0;
let zoomImageLink = document.querySelector('[data-photograph]');
let thumbSwiper = null;
ImageMachine.prototype.oninitialize = function (data) {
images = [];
thumbnailIndex = 0;
zoomImageLink.href = (data.length > 0) ? data[0].image_data[this.closeup_index] : 'graphics/en-US/admin/blank.gif';
this.Initialize(data);
initThumbSwiper();
};
ImageMachine.prototype.ImageMachine_Generate_Thumbnail = function (thumbnail_image, main_image, closeup_image, type_code) {
let thumbnailItem = document.createElement('div');
thumbnailItem.classList.add('swiper-slide', 'x-filmstrip__list-item');
if (typeof(thumbnail_image) === 'string' && thumbnail_image.length > 0) {
let thumbnailLink = document.createElement('a');
thumbnailLink.href = closeup_image;
thumbnailLink.classList.add('x-filmstrip__link');
thumbnailLink.setAttribute('aria-label', ` Product Image ${Number(thumbnailIndex + 1)} of ${Number(this.data.length)}`);
thumbnailLink.setAttribute('data-hook', 'a11yThumbnailLink');
thumbnailLink.setAttribute('data-title', productName);
thumbnailLink.setAttribute('role', 'button');
thumbnailLink.setAttribute('target', '_blank');
let thumbnailPicture = document.createElement('picture');
thumbnailPicture.classList.add('x-filmstrip__picture');
let thumbnailImg = document.createElement('img');
thumbnailImg.classList.add('x-filmstrip__image');
thumbnailImg.setAttribute('alt', productName);
thumbnailImg.setAttribute('data-zoom', closeup_image);
thumbnailImg.setAttribute('decoding', 'async');
thumbnailImg.setAttribute('loading', 'lazy');
thumbnailImg.setAttribute('width', this.thumb_width);
thumbnailImg.setAttribute('height', this.thumb_height);
thumbnailImg.src = thumbnail_image;
thumbnailPicture.appendChild(thumbnailImg);
thumbnailLink.appendChild(thumbnailPicture);
thumbnailItem.appendChild(thumbnailLink);
images.push({
imageIndex: thumbnailIndex,
imageSrc: closeup_image,
imageTitle: productName
});
thumbnailIndex++;
}
else {
images.push({
imageIndex: thumbnailIndex,
imageSrc: closeup_image,
imageTitle: productName
});
}
document.dispatchEvent(generate_thumbnail_event);
return thumbnailItem;
};
ImageMachine.prototype.onthumbnailimageclick = function (data) {
event.preventDefault();
this.Thumbnail_Click(data);
if (event.target.hasAttribute('data-zoom')) {
zoomImageLink.href = event.target.getAttribute('data-zoom');
}
else if (event.target.parentElement.hasAttribute('href')) {
zoomImageLink.href = event.target.parentElement.href;
}
else {
zoomImageLink.href = event.target.href;
}
};
function initThumbSwiper() {
if (thumbSwiper !== null) {
thumbSwiper.destroy(true, true);
thumbSwiper = null;
}
let swiperEl = document.querySelector('.x-filmstrip-swiper');
if (!swiperEl) return;
thumbSwiper = new Swiper('.x-filmstrip-swiper', {
slidesPerView: 'auto',
slidesPerGroup: 6,
spaceBetween: 24,
navigation: {
nextEl: '.swiper-button-next',
prevEl: '.swiper-button-prev',
},
a11y: {
prevSlideMessage: 'Previous thumbnail',
nextSlideMessage: 'Next thumbnail',
},
});
}
/**
* Picture Book
* Version 1.0
*
* Pure JavaScript photo gallery with accessibility baked in.
*
* Inspired by the PhotoViewerJS code by Curtis Campbell:
* https://github.com/curtisc123/PhotoViewerJS
*/
(document => {
/**
* Public Properties
* @type {{init}}
*/
let PictureBook = {};
let defaults = {
AnimationTime: 150
};
/**
* Private Members
* @type {string}
*/
const PHOTO_VIEWER_ACTIVE = 'has-photo-viewer';
const PHOTO_VIEWER_VISIBLE = 'x-photo-viewer__visible';
const PHOTO_VIEWER_LOADED_CLASS = 'is-loaded';
const PhotoGallery = document.querySelector('[data-PhotoGallery]');
let currentLoadedImage;
let Photographs;
let PhotographSources;
let PhotoViewer;
let PhotoViewerTitle;
let PhotoViewerClose;
let PhotoViewerCurrentImageContainer;
let PhotoViewerCurrentImage;
let PhotoViewerControls;
let PhotoViewerPreviousImage;
let PhotoViewerNextImage;
let PhotoViewerCount;
let openTrigger;
/**
* Public Methods
*/
PictureBook.init = () => {
BuildPhotoViewer();
Setup();
SetImageLinkListeners();
PhotoViewerClose.addEventListener('click', ClosePhotoViewer);
PhotoViewerNextImage.addEventListener('click', LoadNextImage);
PhotoViewerPreviousImage.addEventListener('click', LoadPreviousImage);
window.addEventListener('keydown', event => {
let escKey = (event.key === 'Escape' || event.keyCode === 27);
if (event.defaultPrevented) {
return; // Do nothing if the event was already processed
}
if (!escKey) {
return;
}
if (escKey) {
if (PhotoViewer.classList.contains('x-photo-viewer__visible')) {
ClosePhotoViewer(event);
}
}
}, true);
swipe.init(PhotoViewerCurrentImageContainer);
};
/**
* Private Methods
* @constructor
*/
let Setup = () => {
Photographs = document.querySelectorAll('[data-photograph]');
PhotographSources = document.querySelectorAll('[data-zoom]');
PhotoViewer = document.querySelector('[data-PhotoViewer]');
PhotoViewerTitle = document.querySelector('[data-PhotoViewerTitle]');
PhotoViewerClose = document.querySelector('[data-PhotoViewerClose]');
PhotoViewerCurrentImageContainer = document.querySelector('[data-PhotoViewerCurrentImageContainer]');
PhotoViewerCurrentImage = document.querySelector('[data-PhotoViewerCurrentImage]');
PhotoViewerControls = document.querySelector('[data-PhotoViewerControls]');
PhotoViewerPreviousImage = document.querySelector('[data-PhotoViewerPreviousImage]');
PhotoViewerNextImage = document.querySelector('[data-PhotoViewerNextImage]');
PhotoViewerCount = document.querySelector('[data-PhotoViewerCount]');
};
let BuildPhotoViewer = () => {
let PhotoViewerElement = document.createElement('div');
PhotoViewerElement.classList.add('x-photo-viewer');
PhotoViewerElement.setAttribute('data-PhotoViewer', '');
PhotoViewerElement.setAttribute('aria-hidden', 'true');
PhotoViewerElement.setAttribute('aria-label', `Gallery of ${productName} Images`);
PhotoViewerElement.setAttribute('role', 'dialog');
PhotoViewerElement.innerHTML = [
'<header class="x-photo-viewer__header">',
'<p class="x-photo-viewer__title" data-PhotoViewerTitle aria-live="polite" aria-atomic="true"></p>',
'<div class="x-photo-viewer__close" data-PhotoViewerClose><button class="c-button c-button-dark" disabled>X<span class="u-hide-visually">Close dialog</span></button></div>',
'</header>',
'<div class="x-photo-viewer__container">',
'<picture class="x-photo-viewer__current-image" data-PhotoViewerCurrentImageContainer>',
'<img data-PhotoViewerCurrentImage src="" alt="" loading="lazy">',
'</picture>',
'</div>',
'<div class="x-photo-viewer__controls" data-PhotoViewerControls>',
'<div class="x-photo-viewer__previous-image" data-PhotoViewerPreviousImage><button class="c-button c-button-dark c-button--small" aria-label="Previous" disabled>« Previous</button></div>',
'<div class="x-photo-viewer__count" data-PhotoViewerCount aria-live="polite" aria-atomic="true"></div>',
'<div class="x-photo-viewer__next-image" data-PhotoViewerNextImage><button class="c-button c-button-dark c-button--small" aria-label="Next" disabled>Next »</button></div>',
'</div>'
].join('');
document.body.append(PhotoViewerElement);
};
let SetImageLinkListeners = () => {
for (let i = 0; i < Photographs.length; i++) {
Photographs[i].addEventListener('click', ImageOpen);
}
};
let ImageOpen = function (e) {
e.preventDefault();
InitializePhotoViewer(this.href);
};
let InitializePhotoViewer = clickedImage => {
if (images.length === 1) {
PhotoViewerControls.classList.add('u-invisible');
}
for (let i = 0; i < images.length; i++) {
if (images[i].hasOwnProperty('imageSrc')) {
const clickedImageURL = new URL(clickedImage);
const imageURL = new URL(images[i].imageSrc, document.baseURI);
if (clickedImageURL.pathname === imageURL.pathname) {
OpenPhotoViewer(images[i]);
}
}
}
};
let SetPhotoViewerPhoto = ({imageTitle, imageSrc, imageIndex}) => {
PhotoViewerCurrentImage.alt = imageTitle;
PhotoViewerCurrentImage.src = imageSrc;
PhotoViewerTitle.innerHTML = imageTitle;
PhotoViewerCount.innerHTML = `Image ${imageIndex + 1} of ${images.length}`;
currentLoadedImage = imageIndex;
setTimeout(() => {
PhotoViewerCurrentImageContainer.classList.add(PHOTO_VIEWER_LOADED_CLASS);
}, defaults.AnimationTime);
};
let OpenPhotoViewer = clickedImage => {
document.documentElement.classList.add(PHOTO_VIEWER_ACTIVE);
PhotoViewer.classList.add(PHOTO_VIEWER_VISIBLE);
PhotoViewer.setAttribute('aria-hidden', 'false');
Array.from(PhotoViewer.querySelectorAll('button')).forEach(button => {
button.removeAttribute('disabled');
});
SetPhotoViewerPhoto(clickedImage);
a11yHelper();
};
let ClosePhotoViewer = e => {
e.preventDefault();
PhotoViewer.setAttribute('aria-hidden', 'true');
Array.from(PhotoViewer.querySelectorAll('button')).forEach(button => {
button.setAttribute('disabled', '');
});
PhotoViewer.classList.remove(PHOTO_VIEWER_VISIBLE);
document.documentElement.classList.remove(PHOTO_VIEWER_ACTIVE);
a11yHelper();
PhotoViewerControls.classList.remove('u-invisible');
};
let LoadNextImage = e => {
e.preventDefault();
if (currentLoadedImage >= images.length - 1) {
return;
}
PhotoViewerCurrentImageContainer.classList.remove(PHOTO_VIEWER_LOADED_CLASS);
SetPhotoViewerPhoto(images[currentLoadedImage + 1]);
};
let LoadPreviousImage = e => {
e.preventDefault();
if (currentLoadedImage <= 0) {
return;
}
PhotoViewerCurrentImageContainer.classList.remove(PHOTO_VIEWER_LOADED_CLASS);
SetPhotoViewerPhoto(images[currentLoadedImage - 1]);
};
let swipe = {
touchStartX: 0,
touchEndX: 0,
minSwipePixels: 100,
detectionZone: undefined,
init(detectionZone) {
detectionZone.addEventListener('touchstart', ({changedTouches}) => {
swipe.touchStartX = changedTouches[0].screenX;
}, false);
detectionZone.addEventListener('touchend', event => {
swipe.touchEndX = event.changedTouches[0].screenX;
swipe.handleSwipeGesture(event);
}, false);
},
handleSwipeGesture(event) {
let direction;
let moved;
if (swipe.touchEndX <= swipe.touchStartX) {
moved = swipe.touchStartX - swipe.touchEndX;
direction = 'left'
}
if (swipe.touchEndX >= swipe.touchStartX) {
moved = swipe.touchEndX - swipe.touchStartX;
direction = 'right'
}
if (moved > swipe.minSwipePixels && direction !== 'undefined') {
swipe.scroll(direction, event)
}
},
scroll(direction, event) {
if (direction === 'left') {
LoadNextImage(event);
}
if (direction === 'right') {
LoadPreviousImage(event);
}
}
};
let a11yHelper = () => {
let focusableElements = PhotoViewer.querySelectorAll('a[href], button:not([disabled]):not([aria-hidden])');
let firstFocus = focusableElements[0];
let lastFocus = focusableElements[focusableElements.length - 1];
function handleKeyboard(keyEvent) {
let tabKey = (keyEvent.key === 'Tab' || keyEvent.keyCode === 9);
function handleBackwardTab() {
if (document.activeElement === firstFocus) {
keyEvent.preventDefault();
lastFocus.focus();
}
}
function handleForwardTab() {
if (document.activeElement === lastFocus) {
keyEvent.preventDefault();
firstFocus.focus();
}
}
if (!tabKey) {
return;
}
if (keyEvent.shiftKey) {
handleBackwardTab();
}
else {
handleForwardTab();
}
}
/**
* Toggles an 'inert' attribute on all direct children of the <body> that are not the element you passed in. The
* element you pass in needs to be a direct child of the <body>.
*
* Most useful when displaying a dialog/modal/overlay and you need to prevent screen-reader users from escaping the
* modal to content that is hidden behind the modal.
*
* This is a basic version of the `inert` concept from WICG. It is based on an alternate idea which is presented here:
* https://github.com/WICG/inert/blob/master/explainer.md#wouldnt-this-be-better-as
* Also see https://github.com/WICG/inert for more information about the inert attribute.
*/
let setInert = () => {
Array.from(document.body.children).forEach(child => {
if (child !== PhotoViewer && child.tagName !== 'LINK' && child.tagName !== 'SCRIPT') {
child.classList.add('is-inert');
child.setAttribute('inert', '');
child.setAttribute('aria-hidden', 'true');
}
});
};
let removeInert = () => {
Array.from(document.body.children).forEach(child => {
if (child !== PhotoViewer && child.tagName !== 'LINK' && child.tagName !== 'SCRIPT') {
child.classList.remove('is-inert');
child.removeAttribute('inert');
child.removeAttribute('aria-hidden');
}
});
};
if (PhotoViewer.classList.contains('x-photo-viewer__visible')) {
openTrigger = document.activeElement;
setInert();
firstFocus.focus();
PhotoViewer.addEventListener('keydown', keyEvent => {
handleKeyboard(keyEvent);
});
}
else {
removeInert();
openTrigger.focus();
PhotoViewer.removeEventListener('keydown', handleKeyboard);
}
};
return PictureBook.init();
})(document);
6. PROD JS - includes the miva image machine call which should be removed from the product display layout image section
<mvt:comment>
#
# using swiper for related products AND the filmstrip since miva image js doesn't handle horizontal filmstrip/control buttons well
#
</mvt:comment>
<script src="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js"></script>
<script>
var swiper = new Swiper(".mySwiper", {
navigation: {
nextEl: ".swiper-button-next-unique",
prevEl: ".swiper-button-prev-unique",
},
loop: true,
slidesPerView: 1,
spaceBetween: 30,
slidesPerGroup: 1,
slidesPerGroupSkip: 1,
breakpoints: {
600: {
slidesPerGroup: 1,
slidesPerView: 1,
spaceBetween: 10,
slidesPerGroup: 1,
},
768: {
slidesPerGroup: 3,
slidesPerView: 3,
spaceBetween: 20,
slidesPerGroup: 1,
},
1024: {
slidesPerGroup: 3,
slidesPerView: 4,
spaceBetween: 20,
slidesPerGroup: 1,
}
}
});
</script>
<mvt:item name="product_display_imagemachine" param="body_deferred:product:id"/>
https://www.scotsscripts.com/mvblog/swiperjs-horizontal-film-strip-in-shadows.html