Unlocking the Shopify Spotlight: Fix for Unclickable Product Cards in Horizon & Atelier Themes
Hey there, fellow store owners!
Ever run into one of those head-scratching moments where a key part of your store just… doesn’t quite work as expected? That’s exactly what happened recently in our community, and it sparked a really insightful discussion I wanted to share. Our friend siva_fds kicked things off, asking for help with their Shopify Horizon theme. Specifically, they were seeing an issue in the "Spotlight" section on their homepage: when hovering over a product card, the link to the product page became unclickable. Talk about a conversion killer, right?
What made this particularly interesting was a little twist early on. As ajaycodewiz quickly pointed out, siva_fds's store was actually running the Atelier 3.4.0 theme, which, while built on Horizon, introduced a layer of complexity. Turns out, this unclickable hotspot issue is a known bug that affects both the core Horizon theme and themes derived from it like Atelier. Good to know it wasn't just a one-off! The community really rallied to diagnose and offer solutions, so let's dive into what we learned.
Initial Checks: Where to Start When Things Go Wrong
Before jumping straight into code, the community offered some excellent first troubleshooting steps – the kind of common-sense checks we all sometimes forget in the heat of the moment. If you ever face a similar issue with an unclickable element, start here:
- Third-Party Apps & Custom Code: As Custom-Cursor and rshrivastava63 suggested, often these issues are caused by recently installed apps (especially quick view, animation, or popup apps) or custom code changes. Temporarily disabling them can help isolate the problem.
- Overlapping Elements: rshrivastava63 also highlighted inspecting your browser's Developer Tools. Look for another element with a higher
z-indexthat might be sitting on top of your product link, intercepting clicks. siva_fds mentioned not knowing how to do this, which is totally understandable if you're not a developer, but it's a powerful diagnostic tool! - Test on a Clean Theme: If possible, duplicate your live theme and remove all customizations or try the Spotlight section on a fresh, unmodified copy of your theme. If it works there, you know the issue lies within your customizations.
These steps are crucial for narrowing down the cause, but in siva_fds's case, it turned out to be a more fundamental theme bug.
The Core Problem: A Bug in Horizon's Hotspot Dialog
The main culprit here is how the hotspot-dialog component in the Horizon theme (and its derivatives) handles pointer events. Essentially, when you hover over the hotspot dot, the product card appears. But if your mouse moves slightly, especially onto the product card itself, the dialog often vanishes or becomes unclickable because the theme's JavaScript interprets this as "leaving the hotspot area" and closes the dialog prematurely. Not ideal for trying to click a product link!
Fortunately, the community came up with a couple of solid solutions, ranging from a quick fix to a more comprehensive theme file update.
Solution 1: The Quick JavaScript Snippet
Ajaycodewiz provided a neat, targeted JavaScript fix that you can inject directly into your theme without needing to edit core theme files. This approach is great if you're a bit hesitant about deeper code modifications.
Here’s how to implement it:
- In your Shopify admin, go to Online Store > Themes.
- Find your current theme and click Customize.
- In the theme editor, navigate to the page where your Spotlight section is.
- On the left sidebar, scroll down to Template and click Add section.
- Search for "Custom Liquid" and choose it.
- Paste the following JavaScript snippet into the "Custom Liquid" code area:
- Click Save.
This snippet essentially tells the browser: "If the pointer leaves the dialog but immediately enters one of its children (like the product card itself), don't close the dialog!" It's a clever way to override the default behavior that was causing the problem.
And with the fix:
Solution 2: Updating Theme Files for a Robust Fix
While the Custom Liquid snippet is effective, the most robust solution, as highlighted by tim_tairli and user3489, is to update the relevant theme files with the code from a newer version of the Horizon theme (specifically, version 4.1.3 and above, which includes an official fix for this). This involves directly replacing the contents of a couple of JavaScript and Liquid files.
Updating assets/product-hotspot.js
This file controls the behavior of the product hotspot component. You'll need to replace its contents with the updated code. Tim_tairli even linked to the Shopify Horizon GitHub repo for the latest version, which is super helpful!
- In your Shopify admin, go to Online Store > Themes.
- Next to your current theme, click Actions > Edit code.
- In the file explorer, navigate to
assets/product-hotspot.js. - Replace the entire content of this file with the code below:
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);
- Click Save.
Updating sections/_hotspot-product.liquid
This Liquid snippet likely renders the actual HTML structure for the hotspot product.
- In the file explorer, navigate to
sections/_hotspot-product.liquid(or similar path if it's a snippet, e.g.,snippets/_hotspot-product.liquid). - Replace the entire content of this file with the code below:
{% 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
%}
{% 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 %}
- Click Save.
Updating assets/quick-add.js (Optional, but Recommended)
User3489 also provided an update for quick-add.js. While the main issue is with the hotspot dialog, this file handles the quick-add functionality often associated with these product cards, so updating it ensures full compatibility and any other related bug fixes.
- In the file explorer, navigate to
assets/quick-add.js. - Replace the entire content of this file with the code below:
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);
}
- Click Save.
Important Note: Before making any direct code edits, always, always, always duplicate your theme first! This way, you have a fallback if anything goes wrong. Modifying theme files directly can be tricky, so if you're not comfortable, consider hiring a Shopify expert.
What About a Full Theme Update?
Tim_tairli also mentioned that updating your theme to version 4.1.3 or newer would include this fix. While this sounds like the most straightforward solution, he wisely cautioned that newer theme versions can come with other significant changes, like transitions from color schemes to color palettes. These can complicate things and require reconfiguring your theme settings, so it's not always a quick and easy upgrade. If you're planning a full theme update, make sure to test it thoroughly in a duplicate theme first.
A CSS Workaround (Use with Caution)
Moeed offered a CSS-based workaround that attempts to adjust the positioning of the hotspot dialog. While it might provide a visual fix for some, tim_tairli rightly noted that a CSS fix alone isn't very stable for an issue rooted in JavaScript behavior. Devcoders also agreed that it's unlikely to be fixed with CSS alone. It's generally better to address the underlying JavaScript logic when dealing with interactive elements.
This CSS would be added to your theme.liquid file, just above the


