# Multiple Variant Images

*Relevant for Horizon theme version 3.1.0*

***

{% hint style="info" %}
If you want to avoid coding, try the [Shrine](https://vecomlab.com/shrine) theme.

[Shrine](https://vecomlab.com/shrine) offers over 120 built-in features that help improve conversion rates and increase the average order value, without the need to configure additional apps or pay for subscriptions. Everything is customizable without code, saving you time and resources.

<a href="https://vecomlab.com/shrine" class="button primary" data-icon="torii-gate">Use Shrine − 15% OFF</a>

Your discount code: `VECOMLAB`
{% endhint %}

<figure><img src="https://755362860-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Fxa9ueiMPkj0LsrVay0aB%2Fuploads%2FquStxldSAt1lB5IgMap3%2Fvecomlab-multiple-variant-images-1.png?alt=media&#x26;token=f951839b-8c88-407f-ab2b-0709975c6e0c" alt=""><figcaption></figcaption></figure>

### Problem description

By default, in the Shopify admin and in the Horizon theme settings, each variant can only be assigned one image — not several.

The platform partially solves this for stores on the Plus plan. You can also use third-party Shopify apps, but this is often unprofitable and inefficient: errors appear regularly and sometimes take months to fix via support.

You can solve this task using **Metafields and Metaobjects** for **Variants** and the theme code editor.

### Idea

* Each variant has a metafield **`custom.variant_gallery`** (list of files).
* We treat as “owned” all images whose file name **matches the stem** from the metafield or **starts with the stem + `_` or `-`**.
* On the server (Liquid) we pre-hide extra images for the **first frame**.
* On the client (JS) we read the “variant → files” map and **filter when the variant changes**.
* If none of the variants has the metafield — **nothing changes**, everything works as in the original theme.

### What’s new: zoom and quick add

1. The **zoom dialog** is now also filtered by variant: both thumbnails and large images.
2. **Mobile quick add (collection page modal)**: on mobile, the modal shows **only one** image — the featured image of the current variant (as in the standard Horizon), even if the variant has a whole series.

### Step 1. Variant metafield (once per store)

Settings → Metafields and metaobjects → Variants → Add definition

* **Name:** Variant Gallery
* **Namespace and key:** `custom.variant_gallery`
* **Type:** File → **List of files**
* **Validations:** Images only
* **Options:** enable **Storefront API access**
* **Save**

**Why this way:** this is the native way to link a variant with files in Shopify Files. The key matches what the code uses.

### Step 2. File name convention

In Files, give each series of photos for a variant a common **stem**:

```
ring-gold.jpg
ring-gold_2.jpg
ring-gold-3.jpg
```

In the variant metafield attach **one “anchor” file** from the series (for example, `ring-gold.jpg`). The rest will be picked up automatically by the stem.

### Step 3. Inserts in `_product-media-gallery.liquid`

Below is **exactly** what we added/changed, and why.

#### 3.1. JSON map variant → files (server → front)

**Insert before** `{%- if has_image_drop -%}`:

```
{%- comment -%} Map variant.id -> image basenames from metafield custom.variant_gallery {%- endcomment -%}
{%- liquid
  assign prod = product | default: selected_product
-%}
<script id="variant-galleries-json" type="application/json">
{
{%- for v in prod.variants -%}
  "{{ v.id }}": [
    {%- liquid
      assign mf_raw = v.metafields['custom']['variant_gallery']
      assign files = mf_raw.value | default: mf_raw | default: v.metafields.custom.variant_gallery
    -%}
    {%- if files != blank -%}
      {%- for f in files -%}
        {%- assign base = f | file_url | split:'?' | first | split:'/' | last | downcase -%}
        "{{ base }}"{% unless forloop.last %},{% endunless %}
      {%- endfor -%}
    {%- endif -%}
  ]{% unless forloop.last %},{% endunless %}
{%- endfor -%}
}
</script>
```

**Why:** the front receives ready JSON without parsing the DOM. We immediately normalize URLs to the **base file name**.

#### 3.2. Flags on `<media-gallery>`

In the tag attributes, **next to** `data-presentation` and `style`, add:

```
data-initial-variant-id="{{ selected_product.selected_or_first_available_variant.id }}"
data-vg-ready="0"
```

* `data-vg-ready="0"` — anti-flicker: while the script is running, images are hidden (CSS below).
* `data-initial-variant-id` — **optional**, for debugging/analytics; the script itself takes the variant id from the form.

{% hint style="info" %}
If you want things maximally “clean”, you can **remove** `data-initial-variant-id`. Functionality won’t change.
{% endhint %}

#### 3.3. `vg_list` for the first render

**Right after** `{{ block.shopify_attributes }}`:

```
{%- liquid
  assign v = selected_product.selected_or_first_available_variant
  assign mf_raw = v.metafields['custom']['variant_gallery']
  assign vg_list = mf_raw.value | default: mf_raw | default: v.metafields.custom.variant_gallery
-%}
```

**Why:** we need to know the current variant’s file list on the server in order to **pre-hide** extra images even before JS runs (correct first frame).

#### 3.4. Server-side pre-hide in slides (Carousel)

**Before you compute `class/style/attributes`** for each media:

```
{% assign prehide = false %}
{% if vg_list != blank and media.media_type == 'image' %}
  {% assign media_base = media.preview_image.src | split:'?' | first | split:'/' | last | downcase %}
  {% assign media_stem = media_base | split:'.' | first %}

  {% assign allowed = false %}
  {% for f in vg_list %}
    {% assign f_base = f | file_url | split:'?' | first | split:'/' | last | downcase %}
    {% assign f_stem = f_base | split:'.' | first %}
    {% assign prefix = media_stem | slice: 0, f_stem.size %}
    {% assign next   = media_stem | slice: f_stem.size, 1 %}
    {% if prefix == f_stem %}
      {% if next == '' or next == '_' or next == '-' %}
        {% assign allowed = true %}
        {% break %}
      {% endif %}
    {% endif %}
  {% endfor %}

  {% unless allowed %}
    {% assign prehide = true %}
  {% endunless %}
{% endif %}
```

Then **add** `vg-prehide` and `display:none`:

```
{% capture class %}
  {{ product_media_container_class }} product-media-container--{{ media.media_type }}{% if block.settings.zoom %} product-media-container--zoomable{% endif %}{% if forloop.index0 == lowest_aspect_ratio_index and block.settings.aspect_ratio == 'adapt' %} product-media-container--tallest{% endif %}{% if prehide %} vg-prehide{% endif %}
{% endcapture %}
{% if block_settings.aspect_ratio == 'adapt' %}
  {% capture style %}
    --media-preview-ratio: {{ media.preview_image.aspect_ratio | default: 1.0 }};{% if prehide %} display:none;{% endif %}
  {% endcapture %}
{% else %}
  {% capture style %}{% if prehide %}display:none;{% endif %}{% endcapture %}
{% endif %}
```

#### 3.5. Unified set of data attributes on each slide

**Before the block with `attributes`**:

```
{%- capture common_data_attrs -%}
  data-media-type="{{ media.media_type }}"
  {%- if media.media_type == 'image' -%}
    data-media-url="{{ media.preview_image.src }}"
    {%- if vg_list != blank -%}
      {%- if prehide -%}
        data-vg-prehide="1"
      {%- else -%}
        data-vg-allow="1"
      {%- endif -%}
    {%- else -%}
      data-vg-allow="1"
    {%- endif -%}
  {%- endif -%}
{%- endcapture -%}
```

And **replace** the `attributes` block with options that inject `{{- common_data_attrs -}}` (for `model` / `image` / “no zoom”).

**Before:**

```
{% if block_settings.zoom and media.media_type == 'model' %}
  {%- capture attributes -%}"{% if settings.transition_to_main_product and forloop.first %} data-view-transition-type="product-image-transition"{% endif %}{% endcapture -%}
    {% elsif block_settings.zoom %}
  {%- capture attributes -%}on:click="#zoom-dialog-{{ block.id }}/open/{{ forloop.index0 }}"{% if settings.transition_to_main_product and forloop.first %} data-view-transition-type="product-image-transition"{% endif %}{% endcapture -%}
{% endif %}
```

**After:**

```
{%- if block_settings.zoom and media.media_type == 'model' -%}
  {%- capture attributes -%}
    {{- common_data_attrs -}}
    {% if settings.transition_to_main_product and forloop.first %} data-view-transition-type="product-image-transition"{% endif %}
  {%- endcapture -%}
{%- elsif block_settings.zoom -%}
  {%- capture attributes -%}
    {{- common_data_attrs -}}
    on:click="#zoom-dialog-{{ block.id }}/open/{{ forloop.index0 }}"
    {% if settings.transition_to_main_product and forloop.first %} data-view-transition-type="product-image-transition"{% endif %}
  {%- endcapture -%}
{%- else -%}
  {%- capture attributes -%}
    {{- common_data_attrs -}}
    {% if settings.transition_to_main_product and forloop.first %} data-view-transition-type="product-image-transition"{% endif %}
  {%- endcapture -%}
{%- endif -%}
```

**Why:** the front needs the image **type** and **URL**. `data-vg-allow`/`data-vg-prehide` help CSS before JS initializes.

#### 3.6. Same logic — for the Grid

Inside `<ul class="media-gallery__grid">` before each `<li>` repeat the `prehide` logic:

```
{% assign prehide = false %}
{% if vg_list != blank and media.media_type == 'image' %}
  {% assign media_base = media.preview_image.src | split:'?' | first | split:'/' | last | downcase %}
  {% assign media_stem = media_base | split:'.' | first %}

  {% assign allowed = false %}
  {% for f in vg_list %}
    {% assign f_base = f | file_url | split:'?' | first | split:'/' | last | downcase %}
    {% assign f_stem = f_base | split:'.' | first %}
    {% assign prefix = media_stem | slice: 0, f_stem.size %}
    {% assign next   = media_stem | slice: f_stem.size, 1 %}
    {% if prefix == f_stem %}
      {% if next == '' or next == '_' or next == '-' %}
        {% assign allowed = true %}
        {% break %}
      {% endif %}
    {% endif %}
  {% endfor %}

  {% unless allowed %}
    {% assign prehide = true %}
  {% endunless %}
{% endif %}
```

And in the Grid `<li>` replace `class=""` and `style=""` with:

```
class="{{ product_media_container_class }} product-media-container--{{ media.media_type }}{% if prehide %} vg-prehide{% endif %}"
style="{% if block_settings.aspect_ratio == 'adapt' %} --media-preview-ratio: {{ media.preview_image.aspect_ratio | default: 1.0 }};{% endif %}{% if prehide %} display:none;{% endif %}"
```

Add data attributes:

```
{% if settings.transition_to_main_product and forloop.first %}
  data-view-transition-type="product-image-transition"
{% endif %}
  data-media-type="{{ media.media_type }}"
  {% if media.media_type == 'image' %}
    data-media-url="{{ media.preview_image.src }}"
    {% if vg_list != blank %}
      {% if prehide %}
        data-vg-prehide="1"
      {% else %}
        data-vg-allow="1"
      {% endif %}
    {% else %}
      data-vg-allow="1"
    {% endif %}
  {% endif %}
```

#### 3.7. Filtering **inside the zoom dialog** (Liquid)

**Zoom thumbnails (1)**

Inside the thumbnails block (buttons `.dialog-thumbnails-list__thumbnail`) add pre-hide logic, like we did for slides/grid:

```
{% assign prehide = false %}
{% if vg_list != blank and media.media_type == 'image' %}
  {% assign media_base = media.preview_image.src | split:'?' | first | split:'/' | last | downcase %}
  {% assign media_stem = media_base | split:'.' | first %}

  {% assign allowed = false %}
  {% for f in vg_list %}
    {% assign f_base = f | file_url | split:'?' | first | split:'/' | last | downcase %}
    {% assign f_stem = f_base | split:'.' | first %}
    {% assign prefix = media_stem | slice: 0, f_stem.size %}
    {% assign next   = media_stem | slice: f_stem.size, 1 %}
    {% if prefix == f_stem %}
      {% if next == '' or next == '_' or next == '-' %}
        {% assign allowed = true %}
        {% break %}
      {% endif %}
    {% endif %}
  {% endfor %}

  {% unless allowed %}
    {% assign prehide = true %}
  {% endunless %}
{% endif %}
```

In the `<button>` replace `class=""` and `style=""`:

```
class="button button-unstyled dialog-thumbnails-list__thumbnail{% if prehide %} vg-prehide{% endif %}"
style="--aspect-ratio: {{ aspect_ratio }}; --gallery-aspect-ratio: {{ aspect_ratio }};{% if prehide %} display:none;{% endif %}"
```

And add attributes:

```
data-media-type="{{ media.media_type }}"
{% if media.media_type == 'image' %}
  data-media-url="{{ media.preview_image.src }}"
  {% if vg_list != blank %}
    {% if prehide %}
      data-vg-prehide="1"
    {% else %}
      data-vg-allow="1"
    {% endif %}
  {% else %}
    data-vg-allow="1"
  {% endif %}
{% endif %}
```

**Large zoomed frames (2)**

For `<li>` inside `.dialog-zoomed-gallery` apply the same `prehide` check and the same data attributes.

Insert the check before `<li>`:

```
{% assign prehide = false %}
{% if vg_list != blank and media.media_type == 'image' %}
  {% assign media_base = media.preview_image.src | split:'?' | first | split:'/' | last | downcase %}
  {% assign media_stem = media_base | split:'.' | first %}

  {% assign allowed = false %}
  {% for f in vg_list %}
    {% assign f_base = f | file_url | split:'?' | first | split:'/' | last | downcase %}
    {% assign f_stem = f_base | split:'.' | first %}
    {% assign prefix = media_stem | slice: 0, f_stem.size %}
    {% assign next   = media_stem | slice: f_stem.size, 1 %}
    {% if prefix == f_stem %}
      {% if next == '' or next == '_' or next == '-' %}
        {% assign allowed = true %}
        {% break %}
      {% endif %}
    {% endif %}
  {% endfor %}

  {% unless allowed %}
    {% assign prehide = true %}
  {% endunless %}
{% endif %}
```

Replace `class=""` and `style=""` in `<li>`:

```
class="{{ product_media_container_class }} product-media-container--{{ media.media_type }}{% if media.media_type == 'image' %} product-media-container--zoomable{% endif %}{% if prehide %} vg-prehide{% endif %}"
style="{% if block_settings.aspect_ratio == 'adapt' %} --media-preview-ratio: {{ media.preview_image.aspect_ratio | default: 1.0 }};{% endif %}{% if prehide %} display:none;{% endif %}"
```

And add attributes:

```
data-media-type="{{ media.media_type }}"
{% if media.media_type == 'image' %}
  data-media-url="{{ media.preview_image.src }}"
  {% if vg_list != blank %}
    {% if prehide %}
      data-vg-prehide="1"
    {% else %}
      data-vg-allow="1"
    {% endif %}
  {% else %}
    data-vg-allow="1"
  {% endif %}
{% endif %}
```

Same idea: we ensure a “clean” first frame in zoom even before JS runs.

#### 3.8. Client script and mini CSS (after `</media-gallery>`)

**Script:**

```
<script>
(() => {
  // ---------- helpers ----------
  const stripSize = s =>
    (s || '').replace(
      /_(?:\d+x\d+|\d+x|x\d+|pico|icon|thumb|small|compact|medium|large|grande|master)(?=\.)/gi,
      ''
    );
  const canon = (u) => {
    try { const x = new URL(u, location.href); return stripSize((x.pathname.split('/').pop() || '')).toLowerCase(); }
    catch { return stripSize(String(u).split('?')[0].split('/').pop() || '').toLowerCase(); }
  };
  const stem = (s) => canon(s).replace(/\.[a-z0-9]+$/i, '');
  const pickUrl = (el) =>
    el.getAttribute('data-media-url') ||
    el.querySelector('img')?.currentSrc ||
    el.querySelector('img')?.getAttribute('src') || '';

  // ---------- scope ----------
  const THIS = document.currentScript;
  const galleryEl = () =>
    (THIS?.previousElementSibling?.tagName === 'MEDIA-GALLERY'
      ? THIS.previousElementSibling
      : document.querySelector('media-gallery'));

  // NEW: detect we are inside the quick add modal and on a mobile viewport
  const inQuickAdd = () => !!galleryEl()?.closest('#quick-add-modal-content, .quick-add-modal__content');
  const isMobile = () => window.matchMedia('(max-width: 749px)').matches;

  const getVariantId = () =>
    document.querySelector('form[action*="/cart/add"] [name="id"]')?.value ||
    document.querySelector('input[name="id"]')?.value ||
    document.querySelector('select[name="id"]')?.value || null;

  // ---------- read metafield map ----------
  let MAP = {};
  try { const el = document.getElementById('variant-galleries-json'); if (el) MAP = JSON.parse(el.textContent || '{}'); } catch {}
  const HAS_VG = Object.values(MAP).some((a) => Array.isArray(a) && a.length > 0);
  if (!HAS_VG) { galleryEl()?.setAttribute('data-vg-ready', '1'); return; }

  // ---------- accessors ----------
  const zoomRoot   = () => galleryEl()?.parentElement?.querySelector('zoom-dialog') || null;
  const slidesMain = () => Array.from(galleryEl()?.querySelectorAll(':scope > *:not(zoom-dialog) [data-media-type="image"]') || []);
  const slidesAll  = () => Array.from(galleryEl()?.querySelectorAll(':scope > *:not(zoom-dialog) [data-media-type]') || []);
  const dotsAll    = () => Array.from(galleryEl()?.querySelectorAll('slideshow-controls [ref="dots[]"]') || []);
  const slidesZoom = () => Array.from(zoomRoot()?.querySelectorAll('.dialog-zoomed-gallery [data-media-type="image"]') || []);
  const thumbsZoom = () => Array.from(zoomRoot()?.querySelectorAll('.dialog-thumbnails-list__thumbnail[data-media-type="image"]') || []);
  const allowStemsFor = (vid) => (MAP?.[String(vid)] || []).map(stem);

  // ---------- filtering ----------
  const applyList = (els, allow) => {
    const useFilter = allow.length > 0;
    let shown = 0;
    const limitToOne = inQuickAdd() && isMobile(); // In quick add (mobile), keep only the first image

    els.forEach((el) => {
      const base = stem(pickUrl(el));
      let show = !useFilter || (base && allow.some(a => base === a || base.startsWith(a + '_') || base.startsWith(a + '-')));
      if (show && limitToOne) { shown += 1; if (shown > 1) show = false; }
      el.toggleAttribute('data-vg-hidden', !show);
      el.style.display = show ? '' : 'none';
    });
  };

  const applyDots = (allow) => {
    // In quick add on mobile, hide dots entirely
    if (inQuickAdd() && isMobile()) {
      galleryEl()?.querySelectorAll('slideshow-controls').forEach(sc => sc.style.display = 'none');
      return;
    }
    const useFilter = allow.length > 0;
    const slides = slidesAll();
    const dots = dotsAll();
    if (!slides.length || !dots.length) return;

    dots.forEach((dotBtn, i) => {
      const slide = slides[i];
      if (!slide) return;
      const type = slide.getAttribute('data-media-type');
      let show = true;
      if (type === 'image') {
        const base = stem(pickUrl(slide));
        show = !useFilter || (base && allow.some(a => base === a || base.startsWith(a + '_') || base.startsWith(a + '-')));
      }
      dotBtn.toggleAttribute('data-vg-hidden', !show);
      (dotBtn.closest('li') || dotBtn).style.display = show ? '' : 'none';
    });

    const dotsNow = dotsAll();
    const current = dotsNow.find(d => d.getAttribute('aria-selected') === 'true');
    if (current && current.hasAttribute('data-vg-hidden')) {
      const firstVisible = dotsNow.find(d => !d.hasAttribute('data-vg-hidden'));
      if (firstVisible) firstVisible.click();
    }
  };

  const apply = (variantId) => {
    const allow = allowStemsFor(variantId);
    applyList(slidesMain(), allow);
    applyList(slidesZoom(), allow);
    applyList(thumbsZoom(), allow);
    applyDots(allow);
    galleryEl()?.setAttribute('data-vg-ready', '1');
    galleryEl()?.dispatchEvent(new CustomEvent('variantMediaFiltered', { bubbles: true, detail: { variantId } }));
  };

  // ---------- bindings ----------
  const onVariantChange = () => apply(String(getVariantId() || ''));
  ['change','input'].forEach(t => document.addEventListener(t, (e) => { if (e.target?.name === 'id') onVariantChange(); }, { capture:true, passive:true }));
  ['selectVariant','variant:change','variant:changed'].forEach(evt => document.addEventListener(evt, (ev) => { if (ev?.detail?.variant?.id) onVariantChange(); }, { passive:true }));
  new MutationObserver(() => onVariantChange()).observe(document.body, { childList:true, subtree:true });

  // ---------- init ----------
  const init = () => { galleryEl()?.setAttribute('data-vg-ready', '0'); onVariantChange(); };
  if (document.readyState !== 'loading') init(); else document.addEventListener('DOMContentLoaded', init, { once:true });
})();
</script>
```

* reads `#variant-galleries-json`;
* if the product has **no** variants with the metafield → sets `data-vg-ready="1"` and **exits** (changes nothing);
* otherwise filters `image` slides by the current variant; on variant change — repeats;
* at the end sets `data-vg-ready="1"` (anti-flicker off).

**CSS:**

```
<style>
  media-gallery [data-media-type="image"] * { transition:none!important; animation:none!important; }
  media-gallery:not([data-vg-ready="1"]) [data-media-type="image"] { display:none!important; }
  media-gallery:not([data-vg-ready="1"]) [data-media-type="image"][data-vg-allow="1"] { display:flex!important; }
  [data-vg-hidden], [data-vg-prehide] { display:none!important; }
  media-gallery:not([data-vg-ready="1"]) slideshow-controls .slideshow-controls__dots { display: none !important; }
</style>

<style>
  /* QUICK ADD (mobile): keep only the first allowed image before JS initialization */
  @media (max-width: 749px) {
    #quick-add-modal-content media-gallery:not([data-vg-ready="1"]) [data-media-type="image"][data-vg-allow="1"] ~ [data-media-type="image"][data-vg-allow="1"],
    .quick-add-modal__content  media-gallery:not([data-vg-ready="1"]) [data-media-type="image"][data-vg-allow="1"] ~ [data-media-type="image"][data-vg-allow="1"] {
      display: none !important;
    }
    /* also hide navigation controls in the modal */
    #quick-add-modal-content slideshow-controls,
    .quick-add-modal__content  slideshow-controls {
      display: none !important;
    }
  }
</style>
```

**Why:** completely removes “jitter/flicker” and intermediate states before everything is ready. We **don’t touch**thumbnails/arrows — in the “clean” version of this script we don’t even hide them.

**Why this is needed:**

* while `data-vg-ready="0"` is set, the modal doesn’t “flash” with a set of images;
* JS additionally guarantees that even after switching variants in the modal there will be **exactly one** image.

### What we deliberately **don’t touch** (remains “as in the theme”)

We’ll do this in future script versions:

* **Counter / arrows / thumbnails** of the carousel — we don’t configure or hide them with separate rules.

### Small audit of “extra stuff”

* `data-initial-variant-id` — **optional**, can be removed (won’t break the script).
* `MutationObserver` is currently attached to `document.body` for robustness with dynamic re-renders. You can narrow it to only watch `<media-gallery>`, but the current option is more reliable across themes/scripts.
* The duplicated logic “server-side pre-hide” + “client-side filter” is **intentional**: it gives an **ideal first frame** (no junk) and correct behavior on further switches. This is not redundant; it’s an anti-FOUC layer.

### Result

If a variant has the `Variant Gallery` metafield filled in, only its images are shown on the page (by file name). If there is no metafield — everything works as before. The first frame is clean and without flickering (server pre-hide), and when switching variants the gallery is updated by the script. No apps, conflicts, or slowdowns.

### Theme settings

The **Product media (Grid + Hint)** and **Grid + Dots** modes are supported. The “single image in Mobile quick add” behavior is enabled **only** inside the modal on screens ≤ 749 px; on the product page and on desktop you still have the full variant-based gallery.

<figure><img src="https://755362860-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Fxa9ueiMPkj0LsrVay0aB%2Fuploads%2F9E9WUonEX4olXmbd4GxT%2Fvecomlab-multiple-variant-images-2.png?alt=media&#x26;token=9a82d54b-320c-4a3a-b096-5b3c545ee856" alt="" width="342"><figcaption></figcaption></figure>

### FAQ

<details>

<summary>What exactly do I need to configure in the admin?</summary>

Only one thing: create a variant metafield `custom.variant_gallery` of type **File → List of files** (Images only) and enable Storefront API access. Everything else is inserts into a single template, `_product-media-gallery.liquid`.

</details>

<details>

<summary>How many files should I attach to the variant metafield?</summary>

**One anchor** file from the series is enough. The rest will be picked up automatically by the name (by the “stem”) if they are called `stem.jpg`, `stem_2.jpg`, `stem-3.jpg`, etc.

</details>

<details>

<summary>How exactly should the file be named?</summary>

All photos of one modification should share a common **stem** (base of the file name):\
`ring-gold.jpg`, `ring-gold_2.jpg`, `ring-gold-3.jpg`.\
Matching goes by exact stem or stem with `_` / `-` suffix. Case and Shopify sizes (`_800x`) don’t matter — the script strips them.

</details>

<details>

<summary>What if the variant metafield is empty?</summary>

Then **all** product images are shown for that variant (as in the original theme). The logic only kicks in if at least one attached image is found for the variant.

</details>

<details>

<summary>What happens if none of the variants has the metafield?</summary>

The script detects this, **does nothing**, and immediately sets `data-vg-ready="1"`. The gallery works exactly as in the theme, with no extra work or performance impact.

</details>

<details>

<summary>Will images “flicker” on load or when switching variants?</summary>

No. We used a double protection:

* on the Liquid side — pre-hiding extra images for the first frame;
* on the JS side — filtering on variant switches + the `data-vg-ready` flag with mini CSS to avoid intermediate states on screen.

</details>

<details>

<summary>In the zoom dialog all photos are shown. Is that intentional?</summary>

**No, now zoom is also filtered.** Both thumbnails and large images in the dialog show only the current variant’s images (by the same stem rule). The first frame is clean thanks to Liquid pre-hide; then JS takes over.

</details>

<details>

<summary>Why didn’t you touch the carousel counter, arrows, thumbnails?</summary>

To keep the solution as compatible and minimally “invasive” as possible. We didn’t interfere with navigation elements, except for Dots — those can be customized later when there’s a specific need.

</details>

<details>

<summary>Does this work in both Grid and Carousel modes?</summary>

Yes. The pre-hide logic and data attributes are applied both to carousel slides and to grid `<li>` items.

</details>

<details>

<summary>In what cases might the script “not see” the variant id?</summary>

If the theme/app renders the variant form in a non-standard way. The script looks for `name="id"` in the add-to-cart form. If that field is renamed or missing, update the selector in `getVariantId()` or dispatch a custom `selectVariant`/`variant:change` event with `detail.variant.id`.

</details>

<details>

<summary>I have a “quick view” / product modal — will this work there?</summary>

Usually yes, because the script uses a `MutationObserver` on `document.body` and reapplies the filter. If the modal loads content dynamically, make sure the `#variant-galleries-json` block ends up inside its DOM.

</details>

<details>

<summary>What about performance?</summary>

* The JSON block is built once on the server;
* JS only walks visible gallery elements and uses simple string checks;
* If there are no metafields — the script effectively “turns off”.\
  There’s no overhead on pages without metafields.

</details>

<details>

<summary>Will SEO/alt texts/structured data be affected?</summary>

We **don’t remove** elements, we only add `display:none` to the initial state and toggle visibility. Alt texts and schema.org generated by the theme stay intact. The set of images in the DOM remains the same; some are just hidden when the variant is different.

</details>

<details>

<summary>Do I need to change the media order in the product itself?</summary>

Not necessarily. But for better UX, the “anchor” photo of each variant (that stem) should ideally sit near its series, so pre-hide and the first frame look visually logical.

</details>

<details>

<summary>What if different variants accidentally get the same stem?</summary>

Then both series will be considered “suitable” for both variants. Keep the naming strict (different stems for different variants) to avoid overlap.

</details>

<details>

<summary>Can I use spaces/Unicode in file names?</summary>

You can, but it’s better to avoid it. Shopify gives you a URL, and the script uses the base name from the URL. Recommendation: Latin letters, hyphens, and underscores.

</details>

<details>

<summary>What about videos and 3D models?</summary>

Filtering is applied only to `media_type="image"`. Videos/models stay as in the theme. If you want them to be variant-specific too, you can extend the matching logic using `data-media-type` and base names of posters/URLs.

</details>

<details>

<summary>What happens if I rename a file later in Files?</summary>

Matching is based on the **file name**. If you rename the “anchor” or the series, either update the “anchor” name in the metafield or revert the previous name — otherwise the series may be “detached” from the variant.

</details>

<details>

<summary>How do I roll back?</summary>

Remove the JSON block, the server inserts `vg_list`/`prehide`, the unified `common_data_attrs` block, the script, and the mini CSS. After that the theme returns to its original behavior.

</details>

<details>

<summary>Does this work across all languages/locales?</summary>

Yes. The logic does not depend on texts; only on attributes and file names.

</details>

<details>

<summary>Can this conflict with gallery apps?</summary>

If an app completely replaces the gallery markup, it probably ignores our data attributes. Then either disable the app on pages using this logic, or move the filtering into the app’s templates.

</details>

<details>

<summary>Is it safe to update the theme?</summary>

Major theme updates can overwrite `_product-media-gallery.liquid`. Keep git/Theme Download and run a **diff**: if Shopify changes the structure of this template, re-apply our inserts to the new version.

</details>

<details>

<summary>Can I attach all files in the series to the metafield instead of just the “anchor”?</summary>

You can, but there’s no need. The logic is built around **stems** so you don’t have to “poke” every file manually.

</details>

<details>

<summary>Can I remove the <code>data-initial-variant-id</code> attribute?</summary>

Yes. It’s optional and does not affect the filter.

</details>

<details>

<summary>What if there are multiple products on the page (recommended panel, custom sections, etc.)?</summary>

In its current form the script looks for the closest `<media-gallery>` next to itself. Place it **inside** the relevant product block. If you have several galleries on one page, add unique containers/initializations so that each script instance sits right after its own `<media-gallery>`.

</details>

<details>

<summary>Why is there only one photo in the quick add modal?</summary>

That’s by design: on mobile quick add you don’t want to overload the screen — we show the current variant’s featured image. On the PDP the full series remains.

</details>

<details>

<summary>What if a variant has no <code>Variant Gallery</code>?</summary>

Then it works like in the theme: all product images are shown; in the mobile quick add modal only the first (featured) one is shown, as in Horizon by default.

</details>
