Revitalize Your Shopify Product Pages: Fix Broken Media Galleries & Variant Images
Hey fellow store owners!
I've been spending some time in the Shopify Community forums lately, and a recent discussion really caught my eye. It was from Tilda_Falth, who was having a seriously frustrating time with her product page media gallery and images on her Horizon theme. We've all been there, right? You've got amazing products, but if your customers can't easily browse your images, it's a huge barrier.
Tilda's main headaches included:
- Her product image thumbnails and navigation arrows weren't clickable. You could swipe on mobile, and click the main image to zoom, but that was about it.
- She wanted her product thumbnails to display on the left for desktop, but automatically shift to the bottom on mobile – a common and smart responsive design choice!
- She also mentioned some issues with her 'Recommended Products' section, where images weren't showing correctly.
It's a classic scenario: a little custom code, maybe from an app or a developer, can sometimes introduce unexpected quirks. In Tilda's case, it turned out to be a mix of JavaScript and CSS tweaks that needed some expert attention. Thankfully, a super helpful community member, tim_1, stepped in to guide her through the fixes.
Bringing Your Product Slideshow Back to Life: The JavaScript Fix
The first big issue was that Tilda's main product image slideshow wasn't fully functional. Thumbnails weren't clickable, and neither were the navigation arrows. Sounds like a deal-breaker, right? tim_1 quickly diagnosed the problem as a JavaScript error within her assets/slideshow.js file.
It seems a private method, #updateContainerHeight(), had been added to the Slideshow class but was declared at the very end of the class definition. In JavaScript, private class fields and methods need to be declared before they're referenced, and this misplacement was causing the entire slideshow functionality to break.
Here's how tim_1 helped Tilda fix it, and how you can apply a similar fix if you encounter this:
Step-by-Step: Correcting Your slideshow.js File
- Backup Your Theme: Seriously, this is crucial. Before touching any code, go to your Shopify Admin -> Online Store -> Themes. Find your current theme, click 'Actions', and select 'Duplicate'. This creates a safe copy you can revert to if anything goes wrong.
- Navigate to
assets/slideshow.js: In your duplicated theme, click 'Actions' again and select 'Edit code'. Open theassetsfolder and find theslideshow.jsfile. - Locate the Misplaced Code: Search for the method declaration
#updateContainerHeight(). You'll likely find it declared lower down in the file, perhaps after many other methods. - Move the Code: You need to cut the entire
#updateContainerHeight()method, including its comments and curly braces, and paste it higher up in the file. A good spot, as suggested by tim_1, is right below the linerequiredRefs = ['scroller'];(which might be around line 77 or so in your file, depending on your theme version).
To make things super easy, tim_1 provided Tilda with the complete, corrected slideshow.js file. This comprehensive update not only fixed the slideshow's core functionality but also helped with connecting product variants to their respective images – a common request! When a customer clicks on a variant, the main image now switches to show that specific variant, which is a fantastic user experience improvement.
Here's the full, corrected JavaScript code for your assets/slideshow.js file, as shared by tim_1. Remember to back up first!
import { Component } from '@theme/component';
import {
center,
closest,
clamp,
getVisibleElements,
mediaQueryLarge,
prefersReducedMotion,
preventDefault,
viewTransition,
scheduler,
} from '@theme/utilities';
import { Scroller, scrollIntoView } from '@theme/scrolling';
import { SlideshowSelectEvent } from '@theme/events';
// The threshold for determining visibility of slides.
const SLIDE_VISIBLITY_THRESHOLD = 0.7;
/**
* Slideshow custom element that allows sliding between content.
*
* @typedef {Object} Refs
* @property {HTMLElement} scroller
* @property {HTMLElement} slideshowContainer
* @property {HTMLElement[]} [slides]
* @property {HTMLElement} [current]
* @property {HTMLElement[]} [thumbnails]
* @property {HTMLElement[]} [dots]
* @property {HTMLButtonElement} [previous]
* @property {HTMLButtonElement} [next]
*
* @extends {Component}
*/
export class Slideshow extends Component {
static get observedAttributes() {
return ['initial-slide'];
}
/**
* @param {string} name
* @param {string} oldValue
* @param {string} newValue
*/
attributeChangedCallback(name, oldValue, newValue) {
// Collection page filtering will Morph slideshow galleries in place, updating
// the slideshow[initial-slide] and slideshow-slide[hidden] attributes.
// We need to re-select() the slide after the morph is complete, but not before
// slideshow-slide elements have their [hidden] attribute updated.
if (name === 'initial-slide' && oldValue !== newValue) {
queueMicrotask(() => {
// Only select if the component is connected and initialized
if (!this.isConnected || !this.#scroll || !this.refs.slides) return;
const index = parseInt(newValue, 10) || 0;
const slide_id = this.refs.slides[index]?.getAttribute('slide-id');
if (slide_id) {
this.select({ id: slide_id }, undefined, { animate: false });
}
});
}
}
requiredRefs = ['scroller'];
/**
* Updates the container height based on the current slide's aspect ratio
*/
#updateContainerHeight() {
const { slides, scroller } = this;
if (!slides || !scroller) return;
const currentSlide = slides[this.current];
if (!currentSlide) return;
const img = currentSlide.querySelector('img, video, model-viewer');
if (!img) return;
if (img.tagName === 'IMG' && !img.complete) {
img.addEventListener('load', () => this.#updateContainerHeight(), { once: true });
return;
}
if (img.tagName === 'IMG') {
const naturalWidth = img.naturalWidth;
const naturalHeight = img.naturalHeight;
if (naturalWidth && naturalHeight) {
const c
const aspectRatio = naturalHeight / naturalWidth;
const calculatedHeight = containerWidth * aspectRatio;
scroller.style.height = `${calculatedHeight}px`;
scroller.style.transition = 'height 0.4s ease';
}
}
}
async connectedCallback() {
super.connectedCallback();
// Wait for any in-progress view transitions to finish
if (viewTransition.current) {
await viewTransition.current;
// It's possible that the slideshow was disconnected before the view transition finished
if (!this.isConnected) return;
}
const slideCount = this.slides?.length || 0;
slideCount <= 1 ? this.#setupSlideshowWithoutControls() : this.#setupSlideshow();
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.#scroll) {
const { scroller } = this.refs;
scroller.removeEventListener('mousedown', this.#handleMouseDown);
this.#scroll.destroy();
}
const slideCount = this.slides?.length || 0;
if (slideCount > 1) {
this.removeEventListener('mouseenter', this.suspend);
this.removeEventListener('mouseleave', this.resume);
this.removeEventListener('pointerenter', this.#handlePointerEnter);
document.removeEventListener('visibilitychange', this.#handleVisibilityChange);
}
if (this.#resizeObserver) {
this.#resizeObserver.disconnect();
}
}
/** Indicates whether the slideshow is nested inside another slideshow. */
get isNested() {
return this.parentElement?.closest('slideshow-component') !== null;
}
get initialSlide() {
return this.refs.slides?.[this.initialSlideIndex];
}
/**
* Selects a slide based on the input index.
* @param {number|string|{id: string}} input - The index or id of the slide to select.
* @param {Event} [event] - The event that triggered the selection.
* @param {Object} [options] - The options for the selection.
* @param {boolean} [options.animate=true] - Whether to animate the selection.
*/
async select(input, event, opti {
if (this.#disabled || !this.refs.slides?.length) return;
if (!this.#scroll) return;
// Store the actual current slide before any mutations
const currentSlide = this.slides?.[this.current];
for (const slide of this.refs.slides) {
if (slide.hasAttribute('reveal')) {
slide.removeAttribute('reveal');
slide.setAttribute('aria-hidden', 'true');
}
}
// Figure out the raw desired index (could be -1 if user is on first slide and clicks prev)
let requestedIndex = (() => {
if (typeof input === 'number') return input;
if (typeof input === 'string') return parseInt(input, 10);
if ('id' in input) {
const requestedSlide = this.refs.slides.find((slide) => slide.getAttribute('slide-id') == input.id);
if (!requestedSlide || !this.slides) return;
// Force the slide to be revealed if it is hidden
if (requestedSlide.hasAttribute('hidden')) {
requestedSlide.setAttribute('reveal', '');
requestedSlide.setAttribute('aria-hidden', 'false');
}
return this.slides.indexOf(requestedSlide);
}
})();
const { current } = this;
const { slides } = this;
// Guard checks: no slides, invalid index, or selecting the same slide
if (!slides?.length || requestedIndex === undefined || isNaN(requestedIndex)) return;
const requestedSlideElement = slides?.[requestedIndex];
if (currentSlide === requestedSlideElement) return;
if (!this.infinite) requestedIndex = clamp(requestedIndex, 0, slides.length - 1);
event?.preventDefault();
const { animate = true } = options;
const lastIndex = slides.length - 1;
// Decide the actual target index (clamp for infinite loop)
let index = requestedIndex;
if (requestedIndex < 0) index = lastIndex;
else if (requestedIndex > lastIndex) index = 0;
const isAdjacentSlide = Math.abs(index - current) <= 1 && requestedIndex >= 0 && requestedIndex <= lastIndex;
const { visibleSlides } = this;
const instant = prefersReducedMotion() || !animate;
// If jump is more than 1 or we looped, do the placeholder + reorder trick
if (!instant && !isAdjacentSlide && visibleSlides.length === 1) {
this.#disabled = true;
await this.#scroll.finished; // ensure we're not mid-scroll
const targetSlide = slides[index];
if (!targetSlide || !currentSlide) return;
// Create a placeholder in the original DOM position of targetSlide
const placeholder = document.createElement('slideshow-slide');
targetSlide.before(placeholder);
// Decide whether targetSlide goes before or after currentSlide
// so that we scroll a short distance in the correct direction
if (requestedIndex < current) {
currentSlide.before(targetSlide);
} else {
currentSlide.after(targetSlide);
}
if (current === 0) this.#scroll.to(currentSlide, { instant: true });
// Once that scroll finishes, restore the DOM
queueMicrotask(async () => {
await this.#scroll.finished;
this.#disabled = false;
// Restore the slide back to its original position. This triggers a scroll event.
placeholder.replaceWith(targetSlide);
// Instantly scroll to the target slide as its position will have changed
this.#scroll.to(targetSlide, { instant: true });
});
}
const slide = slides[index];
if (!slide) return;
const previousIndex = this.current;
slide.setAttribute('aria-hidden', 'false');
if (this.#scroll) {
this.#scroll.to(slide, { instant });
}
this.current = this.slides?.indexOf(slide) || 0;
this.#centerSelectedThumbnail(index, instant ? 'instant' : 'smooth');
this.dispatchEvent(
new SlideshowSelectEvent({
index,
previousIndex,
userInitiated: event != null,
trigger: 'select',
slide,
id: slide.getAttribute('slide-id'),
})
);
}
/**
* Advances to the next slide.
* @param {Event} [event] - The event that triggered the next slide.
* @param {Object} [options] - The options for the next slide.
* @param {boolean} [options.animate=true] - Whether to animate the next slide.
*/
next(event, options) {
event?.preventDefault();
this.select(this.nextIndex, event, options);
}
/**
* Goes back to the previous slide.
* @param {Event} [event] - The event that triggered the previous slide.
* @param {Object} [options] - The options for the previous slide.
* @param {boolean} [options.animate=true] - Whether to animate the previous slide.
*/
previous(event, options) {
event?.preventDefault();
this.select(this.previousIndex, event, options);
}
/**
* Starts automatic slide playback.
* @param {number} [interval] - The time interval in seconds between slides.
*/
play(interval = this.autoplayInterval) {
if (this.#interval) return;
this.paused = false;
this.#interval = setInterval(() => {
if (this.matches(':hover') || document.hidden) return;
this.next();
}, interval);
}
/**
* Pauses automatic slide playback.
*/
pause() {
this.paused = true;
this.suspend();
}
get paused() {
return this.hasAttribute('paused');
}
set paused(value) {
if (value) {
this.setAttribute('paused', '');
} else {
this.removeAttribute('paused');
}
}
/**
* Suspends automatic slide playback.
*/
suspend() {
clearInterval(this.#interval);
this.#interval = undefined;
}
/**
* Resumes automatic slide playback if autoplay is enabled.
*/
resume() {
if (!this.autoplay || this.paused) return;
this.pause();
this.play();
}
get autoplay() {
return Boolean(this.autoplayInterval);
}
get autoplayInterval() {
const interval = this.getAttribute('autoplay');
const value = parseInt(`${interval}`, 10);
if (Number.isNaN(value)) return undefined;
return value * 1000;
}
/**
* The current slide index.
* @type {number}
*/
#current = 0;
get current() {
return this.#current;
}
/**
* Sets the current slide index and update the DOM
* @type {number}
*/
set current(value) {
const { current, thumbnails, dots, slides, previous, next } = this.refs;
this.#current = value;
if (current) current.textC + 1}`;
for (const controls of [thumbnails, dots]) {
controls?.forEach((el, i) => el.setAttribute('aria-selected', `${i === value}`));
}
if (previous) previous.disabled = Boolean(!this.infinite && value === 0);
if (next) next.disabled = Boolean(!this.infinite && slides && this.nextIndex >= slides.length);
}
get infinite() {
return this.getAttribute('infinite') != null;
}
get visibleSlides() {
return getVisibleElements(this.refs.scroller, this.slides, SLIDE_VISIBLITY_THRESHOLD, 'x');
}
get previousIndex() {
const { current, visibleSlides } = this;
const modifier = visibleSlides.length > 1 ? visibleSlides.length : 1;
return current - modifier;
}
get nextIndex() {
const { current, visibleSlides } = this;
const modifier = visibleSlides.length > 1 ? visibleSlides.length : 1;
return current + modifier;
}
get atStart() {
const { current, slides } = this;
return slides?.length ? current === 0 : false;
}
get atEnd() {
const { current, slides } = this;
return slides?.length ? current === slides.length - 1 : false;
}
/**
* Sets the disabled attribute.
* @param {boolean} value - The value to set the disabled attribute to.
*/
set disabled(value) {
this.setAttribute('disabled', String(value));
}
/**
* Whether the slideshow is disabled.
* @type {boolean}
*/
get disabled() {
return (
this.getAttribute('disabled') === 'true' || (this.hasAttribute('mobile-disabled') && !mediaQueryLarge.matches)
);
}
/**
* Indicates whether the slideshow is temporarily disabled (e.g., during infinite loop transition).
* @type {boolean}
*/
#disabled = false;
/**
* The interval ID for automatic playback.
* @type {number|undefined}
*/
#interval = undefined;
/**
* The Scroller instance that manages scrolling.
* @type {Scroller}
*/
#scroll;
/**
* The ResizeObserver instance for monitoring scroller size changes
* @type {ResizeObserver}
*/
#resizeObserver;
/**
* Setup the slideshow without controls for zero or one slides
*/
#setupSlideshowWithoutControls() {
this.current = 0;
if (this.hasAttribute('auto-hide-controls')) {
const { slideshowControls } = this.refs;
if (slideshowControls instanceof HTMLElement) {
slideshowControls.hidden = true;
}
}
if (this.refs.slides?.[0]) {
this.refs.slides[0].setAttribute('aria-hidden', 'false');
}
}
/**
* Setup the slideshow with controls for when there are multiple slides
*/
#setupSlideshow() {
// Setup the scroll instance
const { scroller } = this.refs;
this.#scroll = new Scroller(scroller, {
onScroll: this.#handleScroll,
onScrollStart: this.#onTransitionInit,
onScrollEnd: this.#onTransitionEnd,
});
scroller.addEventListener('mousedown', this.#handleMouseDown);
this.addEventListener('mouseenter', this.suspend);
this.addEventListener('mouseleave', this.resume);
this.addEventListener('pointerenter', this.#handlePointerEnter);
document.addEventListener('visibilitychange', this.#handleVisibilityChange);
this.#updateControlsVisibility();
this.disabled = this.isNested || this.disabled;
this.resume();
this.current = this.initialSlideIndex;
// Batch reads and writes to the DOM
scheduler.schedule(() => {
let visibleSlidesAmount = 0;
const initialSlideId = this.initialSlide?.getAttribute('slide-id');
// Wait for next frame to ensure layout is fully calculated before setting initial scroll position
// This prevents race conditions on Safari mobile when section_width is 'full-width'
requestAnimationFrame(() => {
if (this.initialSlideIndex !== 0 && initialSlideId) {
this.select({ id: initialSlideId }, undefined, { animate: false });
visibleSlidesAmount = 1;
} else {
visibleSlidesAmount = this.#updateVisibleSlides();
if (visibleSlidesAmount === 0) {
this.select(0, undefined, { animate: false });
visibleSlidesAmount = 1;
}
}
// Add at the end, right before the closing });
this.#updateContainerHeight();
});
this.#resizeObserver = new ResizeObserver(async () => {
if (viewTransition.current) await viewTransition.current;
if (visibleSlidesAmount > 1) {
this.#updateVisibleSlides();
}
if (this.hasAttribute('auto-hide-controls')) {
this.#updateControlsVisibility();
}
// Add at the end of the ResizeObserver callback
this.#updateContainerHeight();
});
this.#resizeObserver.observe(this.refs.slideshowContainer);
});
}
/**
* Callback invoked on user initiated scroll to sync the current slide index
* and emit a slide change event if the index has changed.
*/
#handleScroll = () => {
const previousIndex = this.#current;
const index = this.#sync();
if (index === previousIndex) return;
const slide = this.slides?.[index];
if (!slide) return;
// Add this line right after: if (!slide) return;
this.#updateContainerHeight();
this.dispatchEvent(
new SlideshowSelectEvent({
index,
previousIndex,
userInitiated: true,
trigger: 'scroll',
slide,
id: slide.getAttribute('slide-id'),
})
);
};
# => {
this.setAttribute('transitioning', '');
};
# => {
this.#updateVisibleSlides();
this.removeAttribute('transitioning');
};
/**
* Synchronizes the scroll position and updates the current slide index.
* @returns {number} The index of the current slide.
*/
#sync = () => {
const { slides } = this;
if (!slides) return (this.current = 0);
if (!this.#scroll) return (this.current = 0);
const visibleSlides = this.visibleSlides;
if (!visibleSlides.length) return this.current;
const { axis } = this.#scroll;
const { scroller } = this.refs;
const centers = visibleSlides.map((slide) => center(slide, axis));
const referencePoint = visibleSlides.length > 1 ? scroller.getBoundingClientRect()[axis] : center(scroller, axis);
const closestCenter = closest(centers, referencePoint);
const closestVisibleSlide = visibleSlides[centers.indexOf(closestCenter)];
if (!closestVisibleSlide) return (this.current = 0);
const index = slides.indexOf(closestVisibleSlide);
return (this.current = index);
};
#dragging = false;
/**
* Handles the 'mousedown' event to start dragging slides.
* @param {MouseEvent} event - The mousedown event.
*/
#handleMouseDown = (event) => {
const { slides } = this;
if (!slides || slides.length <= 1) return;
if (!(event.target instanceof Element)) return;
if (this.disabled || this.#dragging) return;
// Check if the event target is within a 3D model interactive element
// This prevents the slideshow from capturing drag events when interacting with 3D models
if (event.target.closest('model-viewer')) {
return;
n}
event.preventDefault();
// Store initial position but don't start handling yet
const { axis } = this.#scroll;
const startPosition = event[axis];
const c AbortController();
const { signal } = controller;
const startTime = performance.now();
let previous = startPosition;
let velocity = 0;
let moved = false;
let distanceTravelled = 0;
this.#dragging = true;
/**
* Handles the 'pointermove' event to update the scroll position.
* @param {PointerEvent} event - The pointermove event.
*/
const => {
const current = event[axis];
const initialDelta = startPosition - current;
if (!initialDelta) return;
if (!moved) {
moved = true;
this.setPointerCapture(event.pointerId);
// Prevent clicks once the user starts dragging
document.addEventListener('click', preventDefault, { once: true, signal });
const movingRight = initialDelta < 0;
const movingLeft = initialDelta > 0;
// Check if the current slideshow should handle this drag
const closestSlideshow = this.parentElement?.closest('slideshow-component');
const isNested = closestSlideshow instanceof Slideshow && closestSlideshow !== this;
const cannotMoveInDirection = (movingRight && this.atStart) || (movingLeft && this.atEnd);
// Abort and let the parent slideshow handle the drag if we're moving in a direction where nested slideshow can't move
if (isNested && cannotMoveInDirection) {
controller.abort();
return;
}
this.pause();
this.setAttribute('dragging', '');
}
// Stop the event from bubbling up to parent slideshow components
event.stopImmediatePropagation();
const delta = previous - current;
const timeDelta = performance.now() - startTime;
velocity = Math.round((delta / timeDelta) * 1000);
previous = current;
distanceTravelled += Math.abs(delta);
this.#scroll.by(delta, { instant: true });
};
/**
* Handles the 'pointerup' event to stop dragging slides.
* @param {PointerEvent} event - The pointerup event.
*/
const (event) => {
controller.abort();
const { current, slides } = this;
const { scroller } = this.refs;
this.#dragging = false;
if (!slides?.length || !scroller) return;
const direction = Math.sign(velocity);
const next = this.#sync();
const modifier = current !== next || Math.abs(velocity) < 10 || distanceTravelled < 10 ? 0 : direction;
const newIndex = clamp(next + modifier, 0, slides.length - 1);
const newSlide = slides[newIndex];
const currentIndex = this.current;
if (!newSlide) throw new Error(`Slide not found at index ${newIndex}`);
this.#scroll.to(newSlide);
this.removeAttribute('dragging');
this.releasePointerCapture(event.pointerId);
this.#centerSelectedThumbnail(newIndex);
this.dispatchEvent(
new SlideshowSelectEvent({
index: newIndex,
previousIndex: currentIndex,
userInitiated: true,
trigger: 'drag',
slide: newSlide,
id: newSlide.getAttribute('slide-id'),
})
);
this.current = newIndex;
await this.#scroll.finished;
// It's possible that the user started dragging again before the scroll finished
if (this.#dragging) return;
this.#scroll.snap = true;
this.resume();
};
this.#scroll.snap = false;
document.addEventListener('pointermove', onPointerMove, { signal });
document.addEventListener('pointerup', onPointerUp, { signal });
/**
* pointerDown calls onPointerUp to fix an issue where the first tap-and-drag
* on the zoom dialog is captured by the pointerMove/pointerUp listeners,
* sometimes causing the slideshow to change slides unexpectedly
*/
document.addEventListener('pointerdown', onPointerUp, { signal });
document.addEventListener('pointercancel', onPointerUp, { signal });
document.addEventListener('pointercapturelost', onPointerUp, { signal });
};
#handlePointerEnter = () => {
this.setAttribute('actioned', '');
};
get slides() {
return this.refs.slides?.filter((slide) => !slide.hasAttribute('hidden') || slide.hasAttribute('reveal'));
}
/**
* The initial slide index.
* @type {number}
*/
get initialSlideIndex() {
const initialSlide = this.getAttribute('initial-slide');
if (initialSlide == null) return 0;
return parseInt(initialSlide, 10);
}
/**
* Pause the slideshow when the page is hidden.
*/
#handleVisibilityChange = () => (document.hidden ? this.pause() : this.resume());
#updateControlsVisibility() {
if (!this.hasAttribute('auto-hide-controls')) return;
const { scroller, slideshowControls } = this.refs;
if (!(slideshowControls instanceof HTMLElement)) return;
slideshowControls.hidden = scroller.scrollWidth <= scroller.offsetWidth;
}
/**
* Centers the selected thumbnail in the thumbnails container
* @param {number} index - The index of the selected thumbnail
* @param {ScrollBehavior} [behavior] - The scroll behavior.
*/
#centerSelectedThumbnail(index, behavior = 'smooth') {
const selectedThumbnail = this.refs.thumbnails?.[index];
if (!selectedThumbnail) return;
const { thumbnailsContainer } = this.refs;
if (!thumbnailsContainer || !(thumbnailsContainer instanceof HTMLElement)) return;
const { slideshowControls } = this.refs;
if (!slideshowControls || !(slideshowControls instanceof HTMLElement)) return;
scrollIntoView(selectedThumbnail, {
ancestor: thumbnailsContainer,
behavior,
block: 'center',
inline: 'center',
});
}
#updateVisibleSlides() {
const { slides } = this;
if (!slides || !slides.length) return 0;
const visibleSlides = this.visibleSlides;
// Batch writes to the DOM
scheduler.schedule(() => {
// Update aria-hidden based on visibility
slides.forEach((slide) => {
const isVisible = visibleSlides.includes(slide);
slide.setAttribute('aria-hidden', `${!isVisible}`);
});
});
return visibleSlides.length;
}
}
if (!customElements.get('slideshow-component')) {
customElements.define('slideshow-component', Slideshow);
}
Responsive Thumbnails: Desktop Left, Mobile Bottom
Once the core slideshow was working, Tilda still had a common responsive design challenge: she wanted her product image thumbnails to appear on the left side of the main image on desktop, but move to the bottom on mobile. This is a great way to optimize space on smaller screens!
tim_1 pointed out that by default, Shopify themes (like Horizon) often place thumbnails under the main image on mobile. This implies that some custom CSS was overriding this default. The solution was to wrap the desktop-specific styling for the thumbnails within a media query. This tells the browser: "Only apply these styles when the screen is wide enough for a desktop."
Step-by-Step: Adjusting Thumbnail Position with CSS
- Backup Your Theme (again!): Always make a backup before making CSS changes.
- Navigate to
assets/base.css: In your duplicated theme's code editor, open theassetsfolder and find thebase.cssfile. - Add the Media Query Opening: Search for the CSS selector
slideshow-component:has(slideshow-controls[thumbnails]). This selector targets your slideshow component when it has thumbnail controls. Right above this line, add the following media query opening:@media (min-width:750px){This tells the browser to apply the following styles only when the screen width is 750 pixels or wider (typically desktop size).

- Add the Media Query Closing: Now, you need to close that media query. Search for
.slideshow-controls__arrows. Right above this line, add a closing curly brace:}
This simple CSS adjustment ensures that your desktop-specific thumbnail layout (like "thumbnails to the left") is only active on wider screens, allowing your mobile layout to revert to the theme's default (thumbnails at the bottom) or whatever other mobile-specific styles you might have.
What About the 'Recommended Products' Block?
Tilda also mentioned some problems with her 'Recommended Products' block, like images not showing when the carousel was enabled, unclickable chevrons, and images only appearing on hover. While the solutions provided by tim_1 successfully addressed the main product page media gallery and variant image switching, these specific issues with the 'Recommended Products' section weren't explicitly covered in the thread's resolution.
It's possible that fixing the core slideshow.js file might have indirectly helped with some of these, as carousels often share similar underlying JavaScript components. However, if you're experiencing similar problems with your 'Recommended Products' section, it might require a separate investigation into the specific Liquid sections or JavaScript files responsible for that particular block in your theme. It's a good reminder that different parts of your theme, even if they look similar, can be powered by distinct code.
Wrapping Things Up
It's really common for store owners to run into these kinds of issues, especially when customizing themes or integrating third-party apps. The key takeaways from Tilda's experience are clear:
- JavaScript matters: Small errors in placement or syntax can have a big impact on functionality.
- Responsive design is key: Use media queries to control how elements behave on different screen sizes.
- The community is your friend: Don't be afraid to ask for help! There are always experts willing to lend a hand.
- Always, always, ALWAYS back up your theme: This can't be stressed enough. It saves so much headache.
By following these steps, you can tackle some of the most common product media display issues and ensure your customers have a smooth, visually appealing shopping experience. Happy selling!