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

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
The zoom dialog is now also filtered by variant: both thumbnails and large images.
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_galleryType: 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.jpgIn 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
_product-media-gallery.liquidBelow 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>
<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.
3.3. vg_list for the first render
vg_list for the first renderRight 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>)
</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
imageslides 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-id— optional, can be removed (won’t break the script).MutationObserveris currently attached todocument.bodyfor 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
Last updated
Was this helpful?