Multiple Variant Images

Different images for variants in the Horizon theme from Shopify (without apps and without Shopify Plus). A free solution from VecomLab.

Relevant for Horizon theme version 3.1.0


If you want to avoid coding, try the Shrine theme.

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.

Use Shrine − 15% OFF

Your discount code: VECOMLAB

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.

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-idoptional, for debugging/analytics; the script itself takes the variant id from the form.

If you want things maximally “clean”, you can remove data-initial-variant-id. Functionality won’t change.

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).

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.

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 touchthumbnails/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-idoptional, 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.

FAQ

What exactly do I need to configure in the admin?

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.

How many files should I attach to the variant metafield?

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.

How exactly should the file be named?

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.

What if the variant metafield is empty?

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.

What happens if none of the variants has the metafield?

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.

Will images “flicker” on load or when switching variants?

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.

In the zoom dialog all photos are shown. Is that intentional?

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.

In what cases might the script “not see” the variant id?

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.

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

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.

What about performance?
  • 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.

Will SEO/alt texts/structured data be affected?

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.

Do I need to change the media order in the product itself?

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.

What if different variants accidentally get the same stem?

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

Can I use spaces/Unicode in file names?

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.

What about videos and 3D models?

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.

What happens if I rename a file later in Files?

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.

How do I roll back?

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.

Does this work across all languages/locales?

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

Is it safe to update the theme?

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.

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

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

Can I remove the data-initial-variant-id attribute?

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

Why is there only one photo in the quick add modal?

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.

Last updated

Was this helpful?