Fixing the Shopify Horizon Theme Spotlight: Making Product Links Clickable Again

Hey everyone! As a Shopify expert who spends a lot of time diving into the community forums, I often come across really specific, yet super common, challenges that store owners face. Recently, a thread titled 'Troubleshooting Horizon theme spotlight section,' started by siva_fds, became a goldmine of insights for a rather annoying problem. siva_fds was hitting a snag with their free Shopify Horizon theme (though, as we found out, it was actually Atelier 3.4.0 built on Horizon – a key detail!). The issue? In the Spotlight section, hovering over a product card made the product link unclickable. Imagine the frustration – customers trying to click through, and nothing happens! They even shared a screen recording, which always helps. This kind of problem, where an element on your site doesn't behave as expected, can often feel like finding a needle in a haystack. Initial thoughts from community members like Custom-Cursor and rshrivastava63 pointed to the usual suspects: custom code changes, third-party apps, or even an overlapping element with a higher z-index blocking the click. These are always great starting points for troubleshooting any front-end issue, and something I always recommend checking first. Sometimes, a rogue app or a small CSS tweak can cause unexpected side effects. However, as devcoders quickly suspected, this wasn't just a simple CSS overlay. It looked like the JavaScript handling the hover/click behavior itself needed an update. And that's where the community really stepped up, offering some fantastic solutions.

The Quick JavaScript Fix for Hotspot Dialogs

One of the most actionable solutions came from ajaycodewiz, who identified this as a bug in both Horizon and themes built upon it, like Atelier. They even tested a fix on their own store and confirmed it worked. The core problem was how the product card's dialog element was handling pointerleave events, causing it to vanish or become unclickable when you moved your mouse from the trigger onto the card itself. ajaycodewiz provided a neat, targeted JavaScript snippet. This snippet essentially tells the browser: 'If the pointer is leaving the hotspot trigger but immediately entering a child element of the hotspot dialog, don't close the dialog!' This keeps the product card visible and, crucially, clickable. Here’s how you can implement this fix:
  1. In your Shopify admin, go to Online Store > Themes.
  2. Find your current theme, click Actions, then Edit code.
  3. Click Customize to open the theme editor.
  4. Under Template, click Add section.
  5. Under Template click Add section
  6. Search for “custom” and choose Custom Liquid.
  7. Search custom and pick Custom Liquid
  8. Paste the following snippet into the Liquid code area, then Save.
  9. Paste into Liquid code then Save
This small piece of code should make a big difference, ensuring your product cards stay open and clickable as intended. ajaycodewiz even shared images showing the 'before' (card vanishes) and 'after' (card stays open and clickable) – a true hero in the forums!

Move onto the card and it is gone:

after applying my Fix

The More Comprehensive Solution: Updating Your Theme

While the custom liquid snippet is a great specific fix, tim_tairli brought up an even more robust solution: updating your theme version. They noted that Shopify added a fix for this exact issue in Horizon theme version 4.1.3. Updating your theme is often the best long-term solution for known bugs, incorporating all the latest fixes and performance improvements directly from Shopify. However, tim_tairli also wisely warned that updating themes isn't always a walk in the park. Major theme updates, especially those with significant changes (like the transition from color schemes to color palettes with Horizon 4.1.3), can complicate things. If you've made extensive customizations, a direct update might overwrite your changes or introduce new conflicts. This is why it's absolutely crucial to always create a duplicate of your live theme and test any updates thoroughly in a staging environment before pushing them live. You don't want to break something else while fixing this! If a full theme update feels too daunting or risky, you can take a more surgical approach. user3489, following tim_tairli's suggestion, shared the updated code for key JavaScript files: assets/product-hotspot.js and assets/quick-add.js, and even the Liquid snippet sections/_hotspot-product.liquid. Replacing the content of these specific files in your theme with the newer versions can apply the fix without needing a full theme overhaul. This is a bit more advanced but a fantastic middle-ground solution. For those comfortable with editing theme code directly, here's what you'd typically do:
  1. Go to Online Store > Themes.
  2. Find your current theme, click Actions, then Edit code.
  3. Locate the file assets/product-hotspot.js and replace its entire content with the updated code from the thread (or the GitHub source).
  4. Locate the file assets/quick-add.js and replace its entire content with the updated code.
  5. (Optional, but recommended) Check sections/_hotspot-product.liquid as well, and update if necessary.
  6. Always back up your files before making changes!

Updated Code Snippets for Manual Replacement

Here's the updated product-hotspot.js content from the thread:
import { Component } from '@theme/component';
import { QuickAddComponent } from '@theme/quick-add';
import { isClickedOutside, isMobileBreakpoint, isTouchDevice, mediaQueryLarge } from '@theme/utilities';

/**
 * A custom element that manages a dialog.
 *
 * @typedef {object} Refs
 * @property {HTMLDialogElement} dialog - The dialog element.
 * @property {HTMLButtonElement} trigger - The button element.
 * @property {HTMLAnchorElement} productLink - The product link element.
 *
 * @extends Component
 */

export class ProductHotspotComponent extends Component {
  requiredRefs = ['trigger', 'dialog'];
  /** @type {(() => void) | null} */
  #pointerenterHandler = null;
  timer = /** @type {number | null} */ (null);

  connectedCallback() {
    super.connectedCallback();

    // Set up initial event listeners based on current breakpoint
    this.#handleBreakpointChange();

    // Listen for breakpoint changes
    mediaQueryLarge.addEventListener('change', this.#handleBreakpointChange);
  }

  disconnectedCallback() {
    super.disconnectedCallback();

    // Clean up listeners
    this.#removeDesktopListeners();
    mediaQueryLarge.removeEventListener('change', this.#handleBreakpointChange);
  }

  /**
   * Open the quick-add modal
   * @returns {void}
   */
  #openQuickAddModal() {
    const quickAddComp @type {QuickAddComponent | null} */ (this.querySelector('quick-add-component'));

    if (!quickAddComponent) return;
    quickAddComponent.handleClick(new MouseEvent('click', { bubbles: true, cancelable: true }));
  }

  /**
   * Set up desktop event listeners (hover)
   * @returns {void}
   */
  #setupDesktopListeners() {
    const { trigger, dialog } = this.refs;

    /** @type {() => void} */
    const pointerenterHandler = () => {
      if (dialog.open) return;

      this.timer = setTimeout(() => {
        this.showDialog();
      }, 120);
      // Add pointerleave listener when entering trigger
      trigger.addEventListener('pointerleave', this.#handlePointerLeave);
    };

    this.#pointerenterHandler = pointerenterHandler;
    trigger.addEventListener('pointerenter', pointerenterHandler);
  }

  /**
   * Remove desktop event listeners from trigger
   * @returns {void}
   */
  #removeDesktopListeners() {
    const { trigger } = this.refs;

    if (this.#pointerenterHandler) {
      trigger.removeEventListener('pointerenter', this.#pointerenterHandler);
      trigger.removeEventListener('pointerleave', this.#handlePointerLeave);
      this.#pointerenterHandler = null;
    }

    // Clear any pending timer
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    }
  }

  /**
   * Handle breakpoint changes
   * @returns {void}
   */
  #handleBreakpointChange = () => {
    // Remove existing listeners
    this.#removeDesktopListeners();

    // Set up desktop hover listeners only (mobile uses on:click in template)
    if (!isMobileBreakpoint()) {
      this.#setupDesktopListeners();
    }
  };

  /**
   * Calculate the placement of the dialog.
   * @returns {Promise | undefined}
   */
  #calculateDialogPlacement() {
    const { trigger, dialog } = this.refs;

    const hotspotsC

    if (!hotspotsContainer) {
      return;
    }

    // Spacing constants
    const BUTT // Gap between button and dialog
    const C // Gap from container edges
    const TOTAL_GAP = BUTTON_GAP + CONTAINER_GAP;

    // Get container bounds
    const c

    // Get button dimensions
    const triggerRect = trigger.getBoundingClientRect();

    // To get dialog dimensions, we need to temporarily show it invisibly
    // Show dialog invisibly to measure it
    dialog.style.visibility = 'hidden';
    dialog.style.display = 'block';
    dialog.style.transform = 'none';
    dialog.removeAttribute('data-placement');

    const { width: dialogWidth, height: dialogHeight } = dialog.getBoundingClientRect();

    // Reset dialog state
    dialog.style.removeProperty('display');
    dialog.style.removeProperty('visibility');
    dialog.style.removeProperty('transform');
    // Calculate button position relative to container
    const butt - containerRect.left;
    const butt - containerRect.left;
    const butt - containerRect.top;
    const butt - containerRect.top;

    // Calculate available space
    const spaceRight = containerRect.width - buttonRight - CONTAINER_GAP;
    const spaceLeft = buttonLeft - CONTAINER_GAP;

    // Determine horizontal placement
    let x = 'right';

    if (spaceRight >= dialogWidth + BUTTON_GAP) {
      x = 'right';
    } else if (spaceLeft >= dialogWidth + BUTTON_GAP) {
      x = 'left';
    } else {
      x = 'center';
    }

    // Determine vertical placement
    let y = 'bottom';
    let verticalOffset = 0;

    if (x !== 'center') {
      let dialogStartY = buttonTop; // Default to top-aligned
      let dialogEndY = buttonTop + dialogHeight;

      if (dialogEndY > containerRect.height - CONTAINER_GAP) {
        // If top-aligned overflows bottom
        dialogStartY = buttonBottom - dialogHeight;
        dialogEndY = buttonBottom;
        y = 'top';

        if (dialogStartY < CONTAINER_GAP) {
          // If bottom-aligned overflows top
          verticalOffset = CONTAINER_GAP - dialogStartY;
        } else if (dialogEndY > containerRect.height - CONTAINER_GAP) {
          // If bottom-aligned overflows bottom
          verticalOffset = -(dialogEndY - (containerRect.height - CONTAINER_GAP));
        }
      } else {
        if (dialogStartY < CONTAINER_GAP) {
          // If top-aligned overflows top
          if (dialogStartY < CONTAINER_GAP) {
            verticalOffset = CONTAINER_GAP - dialogStartY;
          }
          y = 'bottom';
        }
      }
    } else {
      // For center horizontal: position below or above button
      if (containerRect.height - buttonBottom >= dialogHeight + TOTAL_GAP) {
        y = 'bottom';
      } else if (buttonTop >= dialogHeight + TOTAL_GAP) {
        y = 'top';
      } else {
        // If neither fits well, choose based on button position
        y = buttonTop < containerRect.height / 2 ? 'bottom' : 'top';
      }
    }

    // Set placement data attribute
    dialog.dataset.placement = `${x},${y}`;

    // Apply vertical offset if needed to keep dialog in bounds
    if (verticalOffset !== 0) {
      dialog.style.setProperty('--dialog-vertical-offset', `${verticalOffset}px`);
    } else {
      dialog.style.removeProperty('--dialog-vertical-offset');
    }

    // Return a promise that resolves after a few ticks to ensure styles are applied
    return new Promise((resolve) => setTimeout(resolve, 100));
  }

  /**
   * Handle pointer leave.
   * @param {PointerEvent} e - The event.
   * @returns {void}
   */
  #handlePointerLeave = (e) => {
    const { dialog, trigger } = this.refs;

    // Clear open timer if leaving trigger before dialog opens
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    }

    if (!dialog.open) return;

    const isLeavingTrigger = e.target === trigger;
    const isLeavingDialog = e.target === dialog;
    const isGoingToDialog = e.relatedTarget instanceof Element && dialog.contains(e.relatedTarget);
    const isGoingToTrigger = e.relatedTarget === trigger;

    if (isGoingToDialog || (isLeavingDialog && isGoingToTrigger)) return;

    if (isLeavingTrigger || isLeavingDialog) this.closeDialog();
  };

  /**
   * Get the product link for the hotspot product.
   * @returns {HTMLAnchorElement | null} The product link or null.
   */
  getHotspotProductLink() {
    return this.refs.productLink || null;
  }

  /**
   * Handle hotspot click - on mobile/touch devices opens quick-add, on desktop opens dialog
   * @param {MouseEvent} e - The click event
   * @returns {void}
   */
  handleHotspotClick = (e) => {
    // Check if it's a touch device (tablets) or mobile breakpoint
    if (isMobileBreakpoint() || isTouchDevice()) {
      e.preventDefault();
      e.stopPropagation();
      this.#openQuickAddModal();
    } else {
      this.showDialog();
    }
  };

  showDialog = async () => {
    const { dialog } = this.refs;
    await this.#calculateDialogPlacement();
    dialog.dataset.showing = 'true';
    dialog.show();
    document.body.addEventListener('click', this.lightDismissMouse);
    document.body.addEventListener('keydown', this.lightDismissKeyboard);
    document.body.addEventListener('keyup', this.lightDismissKeyboard);
    // Add pointerleave listener to dialog when it opens
    dialog.addEventListener('pointerleave', this.#handlePointerLeave);
  };

  /**
   * Close the dialog.
   * @returns {Promise}
   */
  closeDialog = async () => {
    const { dialog, trigger } = this.refs;
    dialog.dataset.closing = 'true';
    dialog.close();
    document.body.removeEventListener('click', this.lightDismissMouse);
    document.body.removeEventListener('keydown', this.lightDismissKeyboard);
    document.body.removeEventListener('keyup', this.lightDismissKeyboard);
    // Remove pointerleave listeners when closing
    dialog.removeEventListener('pointerleave', this.#handlePointerLeave);
    trigger.removeEventListener('pointerleave', this.#handlePointerLeave);
    // we need to use a data-attribute to keep transition-behavior working only when open
    const animati subtree: true });
    await Promise.allSettled(animations.map((a) => a.finished));
    if (!dialog.open) {
      delete dialog.dataset.showing;
      delete dialog.dataset.closing;
      delete dialog.dataset.placement;
    }
  };

  /**
   * Light dismiss the dialog.
   * @param {MouseEvent} event - The event.
   * @returns {void}
   */
  lightDismissMouse = (event) => {
    const { dialog } = this.refs;
    if (isClickedOutside(event, dialog)) {
      this.closeDialog();
    }
  };

  /**
   * Light dismiss the dialog.
   * @param {KeyboardEvent} event - The event.
   * @returns {void}
   */
  lightDismissKeyboard = (event) => {
    const { dialog } = this.refs;
    if (
      (event.type === 'keydown' && event.key === 'Escape') ||
      (event.type === 'keyup' && !dialog.matches(':is(:focus, :focus-visible, :focus-within)'))
    ) {
      this.closeDialog();
    }
  };
}

// Register custom element
customElements.define('product-hotspot-component', ProductHotspotComponent);
And the updated quick-add.js:
import { Component } from '@theme/component';
import { morph } from '@theme/morph';
import { DialogComponent, DialogCloseEvent } from '@theme/dialog';
import { mediaQueryLarge, isMobileBreakpoint, getIOSVersion } from '@theme/utilities';
import VariantPicker from '@theme/variant-picker';
import { StandardEvents, ProductSelectEvent, CartLinesUpdateEvent } from '@shopify/events';

export class QuickAddComponent extends Component {
  /** @type {AbortController | null} */
  #abortC
  /** @type {Map} */
  #cachedC Map();
  /** @type {AbortController} */
  #cartUpdateAbortC AbortController();

  get productPageUrl() {
    const productCard = /** @type {import('./product-card').ProductCard | null} */ (this.closest('product-card'));
    if (productCard) return productCard.productPageUrl;

    const hotspotProduct = /** @type {import('./product-hotspot').ProductHotspotComponent | null} */ (
      this.closest('product-hotspot-component')
    );
    const productLink = hotspotProduct?.getHotspotProductLink();

    return productLink?.href || '';
  }

  /**
   * Gets the currently selected variant ID from the product card
   * @returns {string | null} The variant ID or null
   */
  #getSelectedVariantId() {
    const productCard = /** @type {import('./product-card').ProductCard | null} */ (this.closest('product-card'));
    return productCard?.getSelectedVariantId() ?? null;
  }

  connectedCallback() {
    super.connectedCallback();

    mediaQueryLarge.addEventListener('change', this.#closeQuickAddModal);
    document.addEventListener(StandardEvents.cartLinesUpdate, this.#handleCartUpdate, {
      signal: this.#cartUpdateAbortController.signal,
    });
    document.addEventListener(StandardEvents.productSelect, this.#handleProductSelectUpdate);
  }

  disconnectedCallback() {
    super.disconnectedCallback();

    mediaQueryLarge.removeEventListener('change', this.#closeQuickAddModal);
    this.#abortController?.abort();
    this.#cartUpdateAbortController.abort();
    document.removeEventListener(StandardEvents.productSelect, this.#handleProductSelectUpdate);
  }

  /**
   * Updates quick-add button state when product variant is selected
   * @param {ProductSelectEvent} event - The product select event
   */
  #handleProductSelectUpdate = (event) => {
    if (!(event.target instanceof HTMLElement)) return;
    if (event.target.closest('product-card') !== this.closest('product-card')) return;
    if (this.dataset.usesSellingPlans === 'true') return;

    // Only flip choose <-> add when both buttons were rendered.
    // Otherwise the flip would hide the sole rendered button and reveal nothing.
    if (this.dataset.rendersBothButtons !== 'true') return;

    const productOpti
    const quickAddButton = productOpti '1' ? 'add' : 'choose';
    this.setAttribute('data-quick-add-button', quickAddButton);
  };

  /**
   * Clears the cached content when cart is updated
   */
  #handleCartUpdate = () => {
    this.#cachedContent.clear();
  };

  /**
   * Re-renders the variant picker in the quick-add modal.
   * @param {Element} newHtml - The element to re-render.
   */
  #updateVariantPicker(newHtml) {
    const modalC
    if (!modalContent) return;
    const variantPicker = /** @type {VariantPicker | null} */ (modalContent.querySelector('variant-picker'));
    if (!variantPicker) return;
    variantPicker.updateVariantPicker(newHtml);
  }

  /**
   * Handles quick add button click
   * @param {Event} event - The click event
   */
  handleClick = async (event) => {
    event.preventDefault();

    const currentUrl = this.productPageUrl;

    if (this.dataset.usesSellingPlans === 'true') {
      if (currentUrl) window.location.href = currentUrl;
      return;
    }

    // Check if we have cached content for this URL
    let productGrid = this.#cachedContent.get(currentUrl);

    if (!productGrid) {
      // Fetch and cache the content
      const html = await this.fetchProductPage(currentUrl);
      if (html) {
        const gridElement = html.querySelector('[data-product-grid-content]');
        if (gridElement) {
          // Cache the cloned element to avoid modifying the original
          productGrid = /** @type {Element} */ (gridElement.cloneNode(true));
          this.#cachedContent.set(currentUrl, productGrid);
        }
      }
    }

    if (productGrid) {
      // Use a fresh clone from the cache
      const freshC @type {Element} */ (productGrid.cloneNode(true));
      await this.updateQuickAddModal(freshContent);
      this.#updateVariantPicker(productGrid);
    }

    this.#openQuickAddModal();
  };

  #resetScroll() {
    const dialogComp
    if (!(dialogComponent instanceof QuickAddDialog)) return;

    const productDetails = dialogComponent.querySelector('.product-details');
    const productMedia = dialogComponent.querySelector('.product-information__media');
    productDetails?.scrollTo({ top: 0, behavior: 'instant' });
    productMedia?.scrollTo({ top: 0, behavior: 'instant' });
  }

  /** @param {QuickAddDialog} dialogComponent */
  #stayVisibleUntilDialogCloses(dialogComponent) {
    this.toggleAttribute('stay-visible', true);

    dialogComponent.addEventListener(DialogCloseEvent.eventName, () => this.toggleAttribute('stay-visible', false), {
      once: true,
    });
  }

  #openQuickAddModal = () => {
    const dialogComp
    if (!(dialogComponent instanceof QuickAddDialog)) return;

    this.#stayVisibleUntilDialogCloses(dialogComponent);

    dialogComponent.showDialog();

    // is nondeterministic when the open attribute is set on the dialog element after .showDialog() is called.
    // Waiting until the open animation starts seemed to be the most reliable metric here.
    const dialog = dialogComponent.refs?.dialog;
    if (!dialog) return;
    dialog.addEventListener('animationstart', this.#resetScroll.bind(this), { once: true });
  };

  #closeQuickAddModal = () => {
    const dialogComp
    if (!(dialogComponent instanceof QuickAddDialog)) return;

    dialogComponent.closeDialog();
  };

  /**
   * Fetches the product page content
   * @param {string} productPageUrl - The URL of the product page to fetch
   * @returns {Promise}
   */
  async fetchProductPage(productPageUrl) {
    if (!productPageUrl) return null;

    // We use this to abort the previous fetch request if it's still pending.
    this.#abortController?.abort();
    this.#abortC AbortController();

    try {
      const resp fetch(productPageUrl, {
        signal: this.#abortController.signal,
      });

      if (!response.ok) {
        throw new Error(`Failed to fetch product page: HTTP error ${response.status}`);
      }

      const resp response.text();
      const html = new DOMParser().parseFromString(responseText, 'text/html');

      return html;
    } catch (error) {
      if (error.name === 'AbortError') {
        return null;
      } else {
        throw error;
      }
    } finally {
      this.#abortC
    }
  }

  /**
   * Re-renders the variant picker.
   * @param {Element} productGrid - The product grid element
   */
  async updateQuickAddModal(productGrid) {
    const modalC

    if (!productGrid || !modalContent) return;

    if (isMobileBreakpoint()) {
      const productDetails = productGrid.querySelector('.product-details');
      const productFormComp
      const variantPicker = productGrid.querySelector('variant-picker');
      const productPrice = productGrid.querySelector('product-price');
      const productTitle = document.createElement('a');
      productTitle.textC || '';

      // Make product title as a link to the product page
      productTitle.href = this.productPageUrl;

      const productHeader = document.createElement('div');
      productHeader.classList.add('product-header');

      productHeader.appendChild(productTitle);
      if (productPrice) {
        productHeader.appendChild(productPrice);
      }
      productGrid.appendChild(productHeader);

      if (variantPicker) {
        productGrid.appendChild(variantPicker);
      }
      if (productFormComponent) {
        productGrid.appendChild(productFormComponent);
      }

      productDetails?.remove();
    }

    // Sync the view-event-payload attribute and morph children into the modal's product-component
    const payload = productGrid.getAttribute('view-event-payload') || '';
    modalContent.setAttribute('view-event-payload', payload);

    morph(modalContent, productGrid);

    this.#syncVariantSelection(modalContent);
  }

  /**
   * Syncs the variant selection from the product card to the modal
   * @param {Element} modalContent - The modal content element
   */
  #syncVariantSelection(modalContent) {
    const selectedVariantId = this.#getSelectedVariantId();
    if (!selectedVariantId) return;

    // Find and check the corresponding input in the modal
    const modalInputs = modalContent.querySelectorAll('input[type="radio"][data-variant-id]');
    for (const input of modalInputs) {
      if (input instanceof HTMLInputElement && input.dataset.variantId === selectedVariantId && !input.checked) {
        input.checked = true;
        input.dispatchEvent(new Event('change', { bubbles: true }));
        break;
      }
    }
  }
}

if (!customElements.get('quick-add-component')) {
  customElements.define('quick-add-component', QuickAddComponent);
}

class QuickAddDialog extends DialogComponent {
  #abortC AbortController();

  connectedCallback() {
    super.connectedCallback();

    this.addEventListener(StandardEvents.cartLinesUpdate, this.handleCartUpdate, {
      signal: this.#abortController.signal,
    });
    this.addEventListener(StandardEvents.productSelect, this.#handleProductSelect);

    this.addEventListener(DialogCloseEvent.eventName, this.#handleDialogClose);
  }

  disconnectedCallback() {
    super.disconnectedCallback();

    this.#abortController.abort();
    this.removeEventListener(DialogCloseEvent.eventName, this.#handleDialogClose);
  }

  /**
   * Closes the dialog on successful cart update
   * @param {CartLinesUpdateEvent} event - The cart lines update event
   */
  handleCartUpdate = (event) => {
    event.promise
      ?.then(({ detail }) => {
        if (detail?.didError) return;
        this.closeDialog();
      })
      .catch((error) => {
        if (error?.name !== 'AbortError') console.warn('[quick-add] Event promise rejected:', error);
      });
  };

  /** @param {ProductSelectEvent} event - The product select event */
  #handleProductSelect = (event) => {
    // Wait for variant update data
    event.promise
      .then(({ detail }) => {
        if (!detail?.html) return;

        const { html } = detail;
        const anchorElement = /** @type {HTMLAnchorElement} */ (html.querySelector('.view-product-title a'));
        const viewMoreDetailsLink = /** @type {HTMLAnchorElement} */ (this.querySelector('.view-product-title a'));
        const mobileProductTitle = /** @type {HTMLAnchorElement} */ (this.querySelector('.product-header a'));

        if (!anchorElement) return;

        if (viewMoreDetailsLink) viewMoreDetailsLink.href = anchorElement.href;
        if (mobileProductTitle) mobileProductTitle.href = anchorElement.href;
      })
      .catch((error) => {
        if (error?.name !== 'AbortError') console.warn('[quick-add] Event promise rejected:', error);
      });
  };

  #handleDialogClose = () => {
    const iosVersion = getIOSVersion();
    /**
     * This is a patch to solve an issue with the UI freezing when the dialog is closed.
     * To reproduce it, use iOS 16.0.
     */
    if (!iosVersion || iosVersion.major >= 17 || (iosVersion.major === 16 && iosVersion.minor >= 4)) return;

    requestAnimationFrame(() => {
      /** @type {HTMLElement | null} */
      const grid = document.querySelector('#ResultsList [product-grid-view]');
      if (grid) {
        const currentWidth = grid.getBoundingClientRect().width;
        grid.style.width = `${currentWidth - 1}px`;
        requestAnimationFrame(() => {
          grid.style.width = '';
        });
      }
    });
  };
}

if (!customElements.get('quick-add-dialog')) {
  customElements.define('quick-add-dialog', QuickAddDialog);
}
And _hotspot-product.liquid:


{% liquid
  assign hotspot_product = closest.product

  assign placeholder_product_title = 'placeholders.product_title' | t
  assign hotspot_product_title = hotspot_product.title | default: placeholder_product_title
%}


  
  
    {% if hotspot_product != blank %}
      
      
    {% endif %}
    
{% if hotspot_product.featured_image != blank %} {% render 'image', image: hotspot_product.featured_image, class: 'hotspot-dialog__product-image' %} {% else %} {{ 'product-apparel-1' | placeholder_svg_tag: 'hotspot-dialog__placeholder-product-image' }} {% endif %}

{{ hotspot_product_title }}

{% render 'price', product_resource: hotspot_product %}
{% if hotspot_product != blank %} {% if hotspot_product.available %} {% render 'quick-add', product: hotspot_product, section_id: section.id, block: block %} {% else %}
{{ 'content.product_badge_sold_out' | t }}
{% endif %} {% endif %}
{% schema %} { "name": "t:names.hotspot_product", "tag": null, "settings": [ { "type": "product", "id": "product", "label": "t:settings.product" }, { "type": "range", "id": "x-position", "label": "t:settings.x_position", "min": 0, "max": 100, "step": 1, "default": 50 }, { "type": "range", "id": "y-position", "label": "t:settings.y_position", "min": 0, "max": 100, "step": 1, "default": 50 } ], "presets": [ { "name": "t:names.hotspot_product" } ] } {% endschema %}

A Note on CSS Workarounds

Moeed also offered a CSS workaround that adjusted the positioning of the hotspot dialog to prevent overlapping:

While this can sometimes be a quick visual fix, tim_tairli correctly pointed out that CSS-only solutions for JavaScript-driven behaviors aren't always stable or comprehensive. They often just mask the underlying problem rather than truly fixing the interaction logic. So, if you try the CSS and it doesn't fully resolve the clickability, you'll definitely want to move to one of the JavaScript-based solutions. It’s a fantastic example of how the Shopify community comes together to solve real-world problems. Whether you're dealing with a theme bug, a tricky app conflict, or just trying to tweak your store's appearance, remember that there's usually someone out there who's faced a similar challenge. Don't be afraid to ask for help, and always consider backing up your theme before making any code changes. If you're just starting your Shopify journey or thinking about migrating your store, remember that a solid theme and careful customization are key, and resources like the community forums are invaluable. If you haven't started your store yet, it's a great time to sign up for Shopify and build your dream business with a platform that has such a supportive ecosystem. Hopefully, these insights will help any of you running into similar issues with your Horizon or Atelier theme's Spotlight section. Happy selling!
Share:

Start with the tools

Explore migration tools

See options, compare methods, and pick the path that fits your store.

Explore migration tools