Product gallery issue

Topic summary

A user running Dawn 15 theme is experiencing issues with the Magic Zoom Plus app for their product gallery. The app doesn’t work well with their theme, causing problems like slow dot indicators on mobile, long initial load times, and lack of video support during swiping.

Custom Solution Attempt:

  • The user (not a developer) tried building a custom zoom solution with ChatGPT assistance
  • Main issue: Desktop zoom panning is slow and doesn’t allow viewing the entire zoomed image by moving the cursor, unlike the Magic Zoom app
  • ChatGPT kept providing faulty, looping solutions

Proposed Solutions:

  • PieLab suggested replacing the entire script with standard CSS/JS implementation, providing detailed code for base.css, a new custom-zoom.js file, and theme.liquid modifications
  • namphan noted the Magic Zoom Plus rendering issues and recommended either contacting the app developer or using simpler JavaScript alternatives like W3Schools’ image magnifier

Current Status:

  • User attempted PieLab’s solution via ChatGPT but unsuccessful
  • Magic Zoom support has been contacted multiple times but cannot reproduce or fix the issues
  • Discussion remains open with suggestions to either implement the library directly or use simpler alternatives
Summarized with AI on October 25. AI used: claude-sonnet-4-5-20250929.

I am running Dawn 15 with Magic Zoom Plus as product page gallery app.

However, the Magic Zoom Plus app does not work that well with my Dawn theme so I decided to see if I could make my own logic with ChatGPT.

I am NOT a developer at all - just for reference.

Today (live with Magic Zoom) it looks like this:

And here is my custom solution (preview):

I have one issue:

I would like the desktop active image zoom (not modal) on the PLP to have the same zoom function as my Magic Zoom app.

As you can see - right now the zoom pan seems slow - and I cannot move the cursor around and see the entire, zoomed image - only parts of it.

ChatGPT seems stuck and looping over the same faulty solutions, so I hope someone can help me adjust it, so the user can zoom and see the entire image in the ‘inner’ zoom by moving the cursor (like my live theme gallery).

Below is my javascript handling this.

class CustomProductGallery {
  constructor(element) {
    this.gallery = element;
    if (!this.gallery) return;

    // Main
    this.gallerySlider = this.gallery.querySelector('.gallery-slider');
    this.slides = this.gallery.querySelectorAll('.gallery-slide');
    this.dots = this.gallery.querySelectorAll('.gallery-dot');
    this.thumbnails = this.gallery.querySelectorAll('.gallery-thumbnail');

    // Modal
    this.modal = document.querySelector('[data-modal]');
    if (!this.modal) return;
    this.modalSliderViewport = this.modal.querySelector('.modal-slider-viewport');
    this.modalSliderTrack = this.modal.querySelector('.modal-slider-track');
    this.modalSlides = this.modal.querySelectorAll('.modal-slide');

    // Modal thumbs
    this.modalThumbsWrapper = this.modal.querySelector('.modal-thumbnails-wrapper');
    this.modalThumbsViewport = this.modal.querySelector('.modal-thumbnails-container');
    this.modalThumbsTrack = this.modal.querySelector('.modal-thumbnails');
    this.modalThumbnails = this.modal.querySelectorAll('.modal-thumbnail');

    this.currentIndex = 0;
    this.totalSlides = this.slides.length;
    this.triggerElement = null;

    // Zoom caps
    this.maxPlpZoom = 1.2;   // desktop PLP hover zoom cap
    this.maxModalZoom = 1.8; // modal click zoom cap

    this.init();
  }

  init() {
    this.relocateModalArrows();
    this.bindAll();
    this.setupSwipeGestures();
    this.updateThumbLayout();
    window.addEventListener('resize', () => this.updateThumbLayout(), { passive: true });
    this.setupPlpHoverZoom(); // desktop PLP hover zoom
  }

  relocateModalArrows() {
    if (!this.modalSliderViewport) return;
    const prev = this.modal.querySelector('[data-modal-prev]');
    const next = this.modal.querySelector('[data-modal-next]');
    if (prev && prev.parentElement !== this.modalSliderViewport) this.modalSliderViewport.appendChild(prev);
    if (next && next.parentElement !== this.modalSliderViewport) this.modalSliderViewport.appendChild(next);
  }

  bindAll() {
    this.gallery.querySelector('[data-prev]')?.addEventListener('click', () => this.prevSlide());
    this.gallery.querySelector('[data-next]')?.addEventListener('click', () => this.nextSlide());

    this.dots.forEach((dot, i) => dot.addEventListener('click', () => this.goToSlide(i)));
    this.thumbnails.forEach((t, i) => t.addEventListener('click', () => this.goToSlide(i)));

    this.slides.forEach((slide, index) => {
      slide.addEventListener('click', (e) => {
        if (e.target && e.target.tagName === 'IMG') this.openModal(index);
      });
      const vid = slide.querySelector('video');
      if (vid) this._bindVideo(vid);
    });

    this.modal.querySelector('[data-modal-close]')?.addEventListener('click', () => this.closeModal());
    this.modal.addEventListener('click', (e) => {
      if (e.target.classList.contains('gallery-modal')) this.closeModal();
    });

    this.modal.querySelector('[data-modal-prev]')?.addEventListener('click', () => this.modalPrev());
    this.modal.querySelector('[data-modal-next]')?.addEventListener('click', () => this.modalNext());

    this.modal.querySelector('[data-modal-thumb-prev]')?.addEventListener('click', () => this.modalThumbPrev());
    this.modal.querySelector('[data-modal-thumb-next]')?.addEventListener('click', () => this.modalThumbNext());
    this.modalThumbnails.forEach((t, i) => t.addEventListener('click', () => this.goToModalSlide(i)));

    this.modalSlides.forEach((slide) => {
      const img = slide.querySelector('img');
      if (img) {
        slide.addEventListener('click', (e) => {
          if (window.matchMedia('(max-width: 768px)').matches) return; // no click zoom on mobile
          if (e.target === img || e.target === slide) this.toggleZoom(slide);
        });
        slide.addEventListener('mousemove', (e) => this.handleZoomMove(e));
        slide.addEventListener('mouseleave', () => this.deactivateZoom(slide));
      }
      const vid = slide.querySelector('video');
      if (vid) this._bindVideo(vid);
    });

    document.addEventListener('keydown', (e) => {
      if (!this.modal.classList.contains('active')) return;
      if (e.key === 'Escape') this.closeModal();
      if (e.key === 'ArrowLeft') this.modalPrev();
      if (e.key === 'ArrowRight') this.modalNext();
    });
  }

  _bindVideo(vid) {
    vid.controls = true;
    vid.style.pointerEvents = 'auto';
    vid.addEventListener('pointerdown', (e) => e.stopPropagation());
    vid.addEventListener('click', (e) => e.stopPropagation());
  }

  setupSwipeGestures() {
    const addSwipe = (el, prev, next) => {
      if (!el) return;
      let sx = 0, sy = 0, dx = 0, dy = 0;
      el.addEventListener('touchstart', (e) => {
        if (e.touches.length > 1) return;
        sx = e.touches[0].clientX; sy = e.touches[0].clientY;
      }, { passive: true });
      el.addEventListener('touchmove', (e) => {
        if (e.touches.length > 1 || !sx) return;
        dx = e.touches[0].clientX - sx; dy = e.touches[0].clientY - sy;
      }, { passive: true });
      el.addEventListener('touchend', () => {
        if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 50) { dx > 0 ? prev() : next(); }
        sx = sy = dx = dy = 0;
      }, { passive: true });
    };
    addSwipe(this.gallerySlider, () => this.prevSlide(), () => this.nextSlide());
    addSwipe(this.modalSliderViewport, () => this.modalPrev(), () => this.modalNext());
  }

  // Desktop zoom in modal
  toggleZoom(slide) {
    slide.classList.toggle('zoomed');
    const img = slide.querySelector('img');
    if (!img) return;

    if (!slide.classList.contains('zoomed')) {
      img.style.transform = '';
      img.style.removeProperty('--zoom-scale');
      return;
    }

    const zoomSrc = img.dataset.zoom || img.src;
    const hi = new Image();
    hi.onload = () => {
      img.src = hi.src;
      const base = Math.max(1, hi.naturalWidth / img.clientWidth);
      const scale = Math.min(this.maxModalZoom, base);
      img.style.setProperty('--zoom-scale', scale);
      // modal uses vertical pan only by design
      img.style.transform = `scale(${scale})`;
    };
    hi.src = zoomSrc;
  }

  handleZoomMove(e) {
    const slide = e.currentTarget;
    if (!slide.classList.contains('zoomed')) return;
    const img = slide.querySelector('img');
    const r = slide.getBoundingClientRect();
    const scale = parseFloat(img.style.getPropertyValue('--zoom-scale')) || 1;
    if (scale <= 1) return;
    const my = e.clientY - r.top;
    const pannableY = img.clientHeight * scale - r.height;
    if (pannableY <= 0) return;
    const ratioY = Math.min(Math.max(my / r.height, 0), 1);
    const ty = -pannableY * ratioY;
    img.style.transform = `scale(${scale}) translateY(${ty}px)`;
  }

  deactivateZoom(slide) {
    if (!slide) return;
    slide.classList.remove('zoomed');
    const img = slide.querySelector('img');
    if (img) { img.style.transform = ''; img.style.removeProperty('--zoom-scale'); }
  }

  /* ===== Main gallery ===== */
  goToSlide(i) {
    if (i < 0 || i >= this.totalSlides) return;
    this.slides.forEach(s => s.classList.remove('active'));
    this.dots.forEach(d => d.classList.remove('active'));
    this.thumbnails.forEach(t => t.classList.remove('active'));
    this.currentIndex = i;
    this.slides[i]?.classList.add('active');
    this.dots[i]?.classList.add('active');
    this.thumbnails[i]?.classList.add('active');
  }
  nextSlide() { this.goToSlide((this.currentIndex + 1) % this.totalSlides); }
  prevSlide() { this.goToSlide((this.currentIndex - 1 + this.totalSlides) % this.totalSlides); }

  /* ===== Modal ===== */
  openModal(i) {
    this.triggerElement = document.activeElement;
    this.modal.classList.add('active');
    document.body.style.overflow = 'hidden';
    this.goToModalSlide(i, true);
    if (this.modalThumbsTrack) this.modalThumbsTrack.scrollLeft = 0;
    this.updateThumbLayout();
  }
  closeModal() {
    this.modal.classList.remove('active');
    document.body.style.overflow = '';
    this.deactivateZoom(this.modalSlides[this.currentIndex] || null);
    this.modalSlides.forEach(s => {
      const v = s.querySelector('video'); const f = s.querySelector('iframe');
      if (v) v.pause();
      if (f) { const src = f.src; f.src = ''; f.src = src; }
    });
    if (this.triggerElement) this.triggerElement.focus();
  }

  goToModalSlide(i, initial = false) {
    if (i < 0 || i >= this.totalSlides) return;
    this.deactivateZoom(this.modalSlides[this.currentIndex] || null);
    this.currentIndex = i;
    this.modalSliderTrack.style.transition = initial ? 'none' : 'transform .3s ease';
    this.modalSliderTrack.style.transform = `translateX(-${i * 100}%)`;
    this.modalThumbnails.forEach(t => t.classList.remove('active'));
    this.modalThumbnails[i]?.classList.add('active');

    const active = this.modalThumbnails[i];
    if (active && this.modalThumbsTrack && this.modalThumbsViewport) {
      const trackRect = this.modalThumbsTrack.getBoundingClientRect();
      const activeRect = active.getBoundingClientRect();
      const viewportRect = this.modalThumbsViewport.getBoundingClientRect();

      if (activeRect.left < viewportRect.left) {
        this.modalThumbsTrack.scrollBy({ left: activeRect.left - viewportRect.left - 10, behavior: 'smooth' });
      } else if (activeRect.right > viewportRect.right) {
        this.modalThumbsTrack.scrollBy({ left: activeRect.right - viewportRect.right + 10, behavior: 'smooth' });
      }
    }

    if (!initial) setTimeout(() => { this.modalSliderTrack.style.transition = ''; }, 300);
  }
  modalNext() { this.goToModalSlide((this.currentIndex + 1) % this.totalSlides); }
  modalPrev() { this.goToModalSlide((this.currentIndex - 1 + this.totalSlides) % this.totalSlides); }
  modalThumbNext() { this.modalThumbsTrack?.scrollBy({ left: 120, behavior: 'smooth' }); }
  modalThumbPrev() { this.modalThumbsTrack?.scrollBy({ left: -120, behavior: 'smooth' }); }

  updateThumbLayout() {
    if (!this.modalThumbsViewport || !this.modalThumbsTrack) return;
    const canCenter = this.modalThumbsTrack.scrollWidth <= this.modalThumbsViewport.clientWidth + 1;
    this.modalThumbsViewport.classList.toggle('is-centered', canCenter);
  }

  /* ===== PLP hover zoom, scale then translate with proper offsets ===== */
  setupPlpHoverZoom() {
    const isDesktop = () => window.matchMedia('(min-width: 768px)').matches;

    const bindSlide = (slide) => {
      const img = slide.querySelector('img');
      if (!img) return;

      let hi = null;
      let scale = 1;
      let baseW = 0, baseH = 0;

      const enter = () => {
        if (!isDesktop()) return;
        const zoomSrc = img.dataset.zoom || img.src;

        // measure displayed image size before zoom
        const rect = img.getBoundingClientRect();
        baseW = rect.width;
        baseH = rect.height;

        if (!hi) {
          hi = new Image();
          hi.onload = () => {
            img.src = hi.src;
            const computed = Math.max(1, hi.naturalWidth / baseW);
            scale = Math.min(this.maxPlpZoom, computed);
            slide.classList.add('plp-zoomed');
          };
          hi.src = zoomSrc;
        } else {
          const computed = Math.max(1, hi.naturalWidth / baseW);
          scale = Math.min(this.maxPlpZoom, computed);
          slide.classList.add('plp-zoomed');
        }
      };

      const move = (e) => {
        if (!isDesktop()) return;
        if (!slide.classList.contains('plp-zoomed') || scale <= 1) return;

        const sr = slide.getBoundingClientRect();

        // image is centered in slide (object-fit: contain)
        const offsetX = Math.max(0, (sr.width  - baseW) / 2);
        const offsetY = Math.max(0, (sr.height - baseH) / 2);

        // pointer relative to the displayed image box
        const clamp = (v, a, b) => Math.min(Math.max(v, a), b);
        const x = clamp((e.clientX - (sr.left + offsetX)) / baseW, 0, 1);
        const y = clamp((e.clientY - (sr.top  + offsetY)) / baseH, 0, 1);

        // total extra content introduced by scaling
        const pannableX = baseW * scale - baseW;
        const pannableY = baseH * scale - baseH;

        // translate in image coordinates so scaling does not multiply translation
        const tx = -(pannableX * x) / scale;
        const ty = -(pannableY * y) / scale;

        img.style.transform = `scale(${scale}) translate(${tx}px, ${ty}px)`;
      };

      const leaveAll = () => {
        slide.classList.remove('plp-zoomed');
        img.style.transform = '';
      };

      slide.addEventListener('mouseenter', enter);
      slide.addEventListener('mousemove', move);
      // cancel only when leaving the whole gallery area
      this.gallerySlider.addEventListener('mouseleave', leaveAll);
    };

    this.slides.forEach(bindSlide);

    window.addEventListener('resize', () => {
      if (!isDesktop()) {
        this.slides.forEach(s => {
          s.classList.remove('plp-zoomed');
          const img = s.querySelector('img');
          if (img) img.style.transform = '';
        });
      }
    }, { passive: true });
  }
}

function initializeGalleries() {
  document.querySelectorAll('[data-gallery]').forEach(g => new CustomProductGallery(g));
}
document.addEventListener('DOMContentLoaded', initializeGalleries);
document.addEventListener('shopify:section:load', initializeGalleries);

Hello @PureTime

It’s common for generated scripts to have logic issues like the slow panning you’re seeing. A more reliable fix is to replace that code entirely with a standard implementation. You’ll need to first remove your old zoom script to prevent conflicts, then add the new CSS and JavaScript provided below.

First, add the following CSS to the bottom of your assets/base.css file. This will set up the necessary styles for the zoom container and the zoomed image.

/* --- Custom Product Image Zoom --- */
.custom-zoom-container {
  position: relative;
  overflow: hidden;
  cursor: zoom-in;
}
.custom-zoom-container .zoomed-image {
  position: absolute;
  top: 0;
  left: 0;
  width: 200%; /* Controls zoom level */
  height: 200%; /* Controls zoom level */
  background-repeat: no-repeat;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s ease;
}
.custom-zoom-container:hover .zoomed-image {
  opacity: 1;
}

Next, create a new file in your Assets folder named custom-zoom.js and paste this script into it. This code finds your product images, creates the zoom elements, and handles the logic for tracking the mouse to pan the zoomed image.

document.addEventListener('DOMContentLoaded', () => {
  document.querySelectorAll('.card__inner .media').forEach(container => {
    const zoomContainer = document.createElement('div');
    zoomContainer.className = 'custom-zoom-container';
    const originalImage = container.querySelector('img');
    if (!originalImage) return;

    container.appendChild(zoomContainer);
    zoomContainer.appendChild(originalImage);

    const zoomedImageDiv = document.createElement('div');
    zoomedImageDiv.className = 'zoomed-image';
    zoomedImageDiv.style.backgroundImage = `url('${originalImage.src.replace(/_(\d+)x(\d*q=\d+)@2x/, '')}')`;
    zoomContainer.appendChild(zoomedImageDiv);

    zoomContainer.addEventListener('mousemove', (e) => {
      const rect = zoomContainer.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;
      zoomedImageDiv.style.backgroundPosition = `${(x / rect.width) * 100}% ${(y / rect.height) * 100}%`;
    });
  });
});

Finally, to activate the script, open your layout/theme.liquid file and add <script src="{{ 'custom-zoom.js' | asset_url }}" defer="defer"></script> right before the closing </body> tag. Once you save all the files, this should provide the smooth and reliable zoom effect you were looking for.

Let me know how it goes!

I tried through ChatGPT to implement your code and remove the old one, but I’m afraid it did not turn out that well.

I will have to dig more into it or hope Magic Zoom will have the issues solved as it is after all a paid-for app.

Hi @PureTime,

I checked and it seems the Magic Zoom Plus you imported is not the same as they support. The current mz-zoom-window div is not rendered in the correct position. Can you send me the link of the document you made to install it? I will try to check this.

Alternatively, you can also follow the instructions below to add simple Javascript code to it. Refer link

If I helped you, then a Like would be truly appreciated.

I did not do anything else than installing their app and enable it through an app embed. That’s all.

If there are any changes that would have been Magic Zoom doing these to try and sort out some minor issues (which are still there: Dots marking not moving when swiping on phone, very long inital load time before images are ready to swipe, swiping not supporting videos)

Hi @PureTime,

If it is app settings I recommend you contact them directly they will help you check everything quickly.

I did that already and they’ve looked several times. They can’t recreate the issue themselves and so far they have not been able to fix it.

That is why I have to look at a custom solution.

Hi @PureTime,

If so, you can try by adding the library directly instead of installing the app, you can refer to their instructions at link

Or you can also refer to my previous tutorial, although it is not as good as magiczoom, but it is simpler and lighter