Horizon Theme: How to Show Multiple Images per Variant (Free Guide)

Below is a free solution that allows you to set up a separate mini-gallery for every variant using metafields and a small code update in the theme. By default, the Horizon theme in Shopify lets you assign only one image to each product variant.

This setup is especially useful for fashion brands that sell the same product in multiple colors.

If you’d like the full explanation, check the VecomLab documentation page.

The latest test was done on theme version 3.1.0. The solution also works correctly on localized storefronts. Share your updates in the comments to help other members of the community.

Step 1. Create the metafield

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

Step 2. Upload the images

Content → Files

Step 3. Configure _product-media-gallery.liquid

Online Store → Themes → Edit code

3.1. Insert this code 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>

3.2. Insert this before {{ block.shopify_attributes }}

data-vg-ready="0"

3.3. Insert this after <media-gallery> and before {% capture slides %}

{%- 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
-%}

3.4. Insert this before {% capture class %}

{% 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 %}

3.5. Update the {% capture class %} block to

{% 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 %}

3.6. Update the {% if block_settings.aspect_ratio == 'adapt' %} block to

{% if block_settings.aspect_ratio == 'adapt' %}
  {% capture style %}
    --media-preview-ratio: {{ media.preview_image.aspect_ratio | default: 1.0 }};
  {% endcapture %}
{% else %}
  {% capture style %}{% if prehide %}display:none;{% endif %}{% endcapture %}
{% endif %}

3.7. Update the {% if block_settings.zoom and media.media_type == 'model' %} block to

{%- 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 -%}

{%- 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 -%}

3.8. Configure {% if block_settings.media_presentation == 'grid' %}

Insert this code right after {% for media in sorted_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 %}

In the <li> for Grid, update class="" to

class="{{ product_media_container_class }} product-media-container--{{ media.media_type }}{% if prehide %} vg-prehide{% endif %}"

In the same block, replace {% if settings.transition_to_main_product and forloop.first %} with

{% 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.9. Configure <div class="dialog-thumbnails-list-container">

Insert this code before <button

{% 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 <button> , update class="" to

class="button button-unstyled dialog-thumbnails-list__thumbnail{% if prehide %} vg-prehide{% endif %}"

In the same <button> , add these attributes before >

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.10. Configure class="dialog-zoomed-gallery list-unstyled"

Insert this code after {% for media in sorted_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 %}

In the <li> , update class="" to

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 %}"

In the same <li> , add these attributes before >

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 %}

Step 4. Add script and styles after /media-gallery>

<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, '');

  // stable URL source (NOT currentSrc)
  const pickUrl = (el) =>
    el?.getAttribute?.('data-media-url') ||
    el?.querySelector?.('img')?.getAttribute('src') ||
    '';

  // ---------- variant → files 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);
  const allowStemsFor = (vid) => (MAP?.[String(vid)] || []).map(stem);

  // ---------- selectors ----------
  const galleries = () => Array.from(document.querySelectorAll('media-gallery'));
  const zoomRoot   = (root) => root?.parentElement?.querySelector('zoom-dialog') || null;
  const slidesMain = (root) => Array.from(root?.querySelectorAll(':scope > *:not(zoom-dialog) [data-media-type="image"]') || []);
  const slidesAll  = (root) => Array.from(root?.querySelectorAll(':scope > *:not(zoom-dialog) [data-media-type]') || []);
  const dotsAll    = (root) => Array.from(root?.querySelectorAll('slideshow-controls [ref="dots[]"]') || []);
  const slidesZoom = (root) => Array.from(zoomRoot(root)?.querySelectorAll('.dialog-zoomed-gallery [data-media-type="image"]') || []);
  const thumbsZoom = (root) => Array.from(zoomRoot(root)?.querySelectorAll('.dialog-thumbnails-list__thumbnail[data-media-type="image"]') || []);

  const inQuickAdd = (root) => !!root?.closest?.('#quick-add-modal-content, .quick-add-modal__content');
  const isMobile   = () => window.matchMedia('(max-width: 749px)').matches;

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

  // ---------- filtering ----------
  const applyList = (root, els, allow) => {
    const useFilter = allow.length > 0;
    let shown = 0;
    const limitToOne = inQuickAdd(root) && isMobile(); // quick add: show only one 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);
      // no inline styles — CSS hides by attribute
    });
  };

  const applyDots = (root, allow) => {
    // quick add (mobile): hide dots completely
    if (inQuickAdd(root) && isMobile()) {
      root.querySelectorAll('slideshow-controls').forEach(sc => sc.style.display = 'none');
      return;
    }

    const useFilter = allow.length > 0;
    const slides = slidesAll(root);
    const dots = dotsAll(root);
    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).classList.toggle('vg-dot-hidden', !show);
    });

    // if current dot becomes hidden — softly select the first visible one
    const dotsNow = dotsAll(root);
    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'));
      firstVisible?.click?.();
    }
  };

  const ensureVisibleSlide = (root) => {
    if (dotsAll(root).length) return; // dots control the active state
    const slides = slidesAll(root);
    const firstVisible = slides.find(s => !s.hasAttribute('data-vg-hidden'));
    firstVisible?.scrollIntoView?.({ block: 'nearest', inline: 'center', behavior: 'auto' });
  };

  const apply = (root, variantId) => {
    if (!root) return;

    if (!HAS_VG) { // map is empty — do nothing
      root.setAttribute('data-vg-ready', '1');
      return;
    }

    const allow = allowStemsFor(variantId);
    applyList(root, slidesMain(root), allow);
    applyList(root, slidesZoom(root), allow);
    applyList(root, thumbsZoom(root), allow);
    applyDots(root, allow);
    ensureVisibleSlide(root);

    root.setAttribute('data-vg-ready', '1');
    root.dispatchEvent(new CustomEvent('variantMediaFiltered', { bubbles: true, detail: { variantId } }));
  };

  // ---------- wiring / lifecycle ----------
  const wireGallery = (root) => {
    if (!root || root.dataset.vgWired === '1') return;
    root.dataset.vgWired = '1';
    root.setAttribute('data-vg-ready', '0');

    const vidFromRoot = root.dataset.initialVariantId || root.getAttribute('data-initial-variant-id');
    const vid = String(vidFromRoot || getVariantIdGlobal() || '');
    apply(root, vid);
  };

  const wireAll = () => { galleries().forEach(wireGallery); };

  // watch only for new media-gallery nodes
  const mo = new MutationObserver((mut) => {
    for (const m of mut) {
      m.addedNodes && m.addedNodes.forEach((n) => {
        if (n?.nodeType === 1) {
          if (n.tagName === 'MEDIA-GALLERY') wireGallery(n);
          n.querySelectorAll?.('media-gallery')?.forEach(wireGallery);
        }
      });
    }
  });
  mo.observe(document.documentElement, { childList: true, subtree: true });

  // rAF-debounce for variant change
  let rafToken = 0;
  const onVariantChange = (vidMaybe) => {
    cancelAnimationFrame(rafToken);
    rafToken = requestAnimationFrame(() => {
      const vid = String(vidMaybe || getVariantIdGlobal() || '');
      galleries().forEach((root) => {
        if (!root.dataset.vgWired) wireGallery(root);
        else apply(root, vid);
      });
    });
  };

  // standard variant selector change
  ['change','input'].forEach(t => document.addEventListener(t, (e) => {
    if (e.target?.name === 'id') onVariantChange(e.target.value);
  }, { capture:true, passive:true }));

  // theme/Shopify events
  ['selectVariant','variant:change','variant:changed','shopify:section:load'].forEach(evt => {
    document.addEventListener(evt, (ev) => {
      const vid = ev?.detail?.variant?.id || ev?.detail?.id || null;
      onVariantChange(vid);
    }, { passive:true });
  });

  // init
  const init = () => { wireAll(); onVariantChange(); };
  if (document.readyState !== 'loading') init(); else document.addEventListener('DOMContentLoaded', init, { once:true });
})();
</script>

<style>
  /* Basic safety rules and anti-FOUC */
  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; }

  /* Hiding elements via attribute/class — no inline styles */
  [data-vg-hidden], [data-vg-prehide] { display: none !important; }
  .vg-dot-hidden { display: none !important; }

  /* Hide dots until ready to avoid intermediate states */
  media-gallery:not([data-vg-ready="1"]) slideshow-controls .slideshow-controls__dots { display: none !important; }

  /* QUICK ADD (mobile): before initialization — show only the first allowed image, hide dots */
  @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;
    }
    #quick-add-modal-content slideshow-controls,
    .quick-add-modal__content  slideshow-controls {
      display: none !important;
    }
  }
</style>

Step 5. Configure product variants and galleries

If a variant has the Variant Gallery metafield filled, only the relevant images will be shown on the product page. If the metafield is empty, everything will work according to the theme’s default logic.

The first image loads without flickering (thanks to initial server-side hiding), and when you switch variants, the gallery is updated by the script.

In this version, only the main Product media modes are supported: Grid + Hint or Grid + Dots. The “single image in Mobile quick add” behavior applies only inside the modal on screens ≤ 749 px.

3 Likes