Добавление разных изображений для вариантов продукта
Разные изображения для вариантов в теме Horizon от Shopify (без приложений и без Plus). Бесплатное решение от VecomLab.
Актуально для версии темы Horizon 3.1.0

Описание проблемы
По умолчанию в админке Shopify и в настройках темы Horizon каждому варианту можно задать только одно изображение, но не несколько.
Эту проблему платформа частично решает для магазинов на тарифе Plus. Также можно использовать сторонние приложения Shopify, однако это чаще всего невыгодно и неэффективно, так как нередко возникают ошибки, которые приходится исправлять через поддержку месяцами.
Задачу можно решить, используя Metafields and Metaobjects для Variants и редактор кода темы.
Идея
У варианта есть метаполе
custom.variant_gallery(список файлов).Мы считаем «своими» все картинки, чьё имя файла совпадает со стемом из метаполя или начинается со стема +
_или-.На сервере (Liquid) делаем пред-скрытие лишних картинок для первого кадра.
На клиенте (JS) читаем карту «вариант → файлы» и фильтруем при переключении варианта.
Если ни у одного варианта метаполя нет — ничего не меняется, всё работает как в теме.
Что нового: зум и quick add
Зум-диалог теперь тоже фильтруется по варианту: и миниатюры, и большие кадры.
Mobile quick add (модалка на странице коллекции): на мобильных в модалке показывается только одно изображение — featured текущего варианта (как в стандартной Horizon), даже если у варианта есть целая серия.
Шаг 1. Метаполе варианта (единожды на весь магазин)
Settings → Metafields and metaobjects → Variants → Add definition
Name: Variant Gallery
Namespace and key:
custom.variant_galleryType: File → List of files
Validations: только Images
Options: включить Storefront API access
Save
Почему так: это нативный способ связать вариант с файлами в Shopify Files. Ключ совпадает с тем, что использует код.
Шаг 2. Договор по именам файлов
В Files даём серии фото общего варианта общий стем:
ring-gold.jpg
ring-gold_2.jpg
ring-gold-3.jpgВ метаполе варианта прикрепляем один «якорный» файл из серии (например, ring-gold.jpg). Остальные подхватятся по стему автоматически.
Шаг 3. Вставки в _product-media-gallery.liquid
_product-media-gallery.liquidНиже перечислено ровно то, что мы добавили/заменили, и зачем.
3.1. JSON-карта вариант → файлы (сервер → фронт)
Вставить перед {%- 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>Зачем: фронт получает готовый JSON без парсинга DOM. Сразу приводим URL к базовому имени.
3.2. Флаги на <media-gallery>
<media-gallery>В атрибутах тега, рядом с data-presentation и style, добавить:
data-initial-variant-id="{{ selected_product.selected_or_first_available_variant.id }}"
data-vg-ready="0"data-vg-ready="0"— анти-мигание: пока скрипт не закончит, картинки скрыты (ниже будет CSS).data-initial-variant-id— опционально, для отладки/аналитики; сам скрипт берёт id варианта из формы.
3.3. vg_list для первого рендера
vg_list для первого рендераСразу после {{ 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
-%}Зачем: знать список файлов текущего варианта на сервере, чтобы пред-скрыть лишние картинки ещё до JS (правильный первый кадр).
3.4. Серверный пред-скрыт в слайдах (Carousel)
Перед вычислением class/style/attributes каждого медиа:
{% 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 %}И далее добавляем vg-prehide и 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. Единый набор data-атрибутов на слайд
Перед блоком с 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 -%}И заменяем блок attributes на варианты с подстановкой {{- common_data_attrs -}} (для model / image / «без zoom»).
Было:
{% 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 %}Стало:
{%- 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 -%}Зачем: фронту нужны тип и URL картинки. data-vg-allow/data-vg-prehide помогают CSS до инициализации JS.
3.6. То же — для Grid
Внутри <ul class="media-gallery__grid"> перед каждым <li> повторяем логику prehide
{% 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 %}А в самом <li> для Grid заменяем class= "" и style "" на:
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 %}"И добавляем data-атрибуты:
{% 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. Фильтрация внутри зум-диалога (Liquid)
Миниатюры зума (1)
Внутри блока с миниатюрами (кнопки .dialog-thumbnails-list__thumbnail) добавьте пред-скрытие, как мы делали для слайдов/грида:
{% 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 %}В <button> замените class="" и 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 %}"И добавьте атрибуты:
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 %}Большие кадры в зуме (2)
Для <li> внутри .dialog-zoomed-gallery примените ту же проверку prehide и те же data-атрибуты.
Вставьте проверку перед <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 %}Замените class="" и style="" в <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 %}"И добавьте атрибуты:
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 %}Идея та же: обеспечиваем «чистый» первый кадр в зуме ещё до выполнения JS.
3.8. Клиентский скрипт и мини-CSS (после </media-gallery>)
</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, '');
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>
читает
#variant-galleries-json;если у товара нет ни одного варианта с метаполем → ставит
data-vg-ready="1"и выходит (ничего не меняем);иначе фильтрует
image-слайды по текущему варианту; при смене варианта — повторяет;в конце ставит
data-vg-ready="1"(анти-мигание выключается).
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>Зачем: полностью убирает «дрожание/моргание» и промежуточные состояние до готовности. Мы не трогаем миниатюры/стрелки — в «чистом» варианте данной версии скрипта их и не скрываем.
Почему это нужно:
пока стоит
data-vg-ready="0", модалка не «мигает» набором картинок;JS дополнительно гарантирует, что даже после переключений варианта в модалке будет ровно одно изображение.
Что сознательно не трогаем (остается «как в теме»)
Сделаем это в следующих версиях скрипта:
Счётчик / стрелки / миниатюры карусели — не настраиваем и не прячем отдельными правилами.
Небольшой аудит «лишнего»
data-initial-variant-id— опция, можно удалить (скрипт не сломает).MutationObserverсейчас повешен наdocument.bodyдля «неубиваемости» при динамических перерисовках. Можно ужать до наблюдения только за<media-gallery>, но текущий вариант надёжнее ко всем темам/скриптам.Дублирование логики «пред-скрыт на сервере» + «фильтр на клиенте» — осознанно: даёт идеальный первый кадр (без мусора) и корректную работу при дальнейших переключениях. Это не лишнее, а анти-FOUC-слой.
Итог
Если у варианта заполнено метаполе Variant Gallery, на странице показываются только его фотографии (по имени файла). Если метаполя нет — всё работает как раньше. Первый кадр чистый без миганий (пред-скрытие на сервере), при переключении варианта галерея обновляется скриптом. Никаких приложений, конфликтов и замедлений.
Настройка темы
Режимы Product media (Grid + Hint) и Grid + Dots поддерживаются. Поведение «одно изображение в Mobile quick add» включается только внутри модалки на экранах ≤ 749 px; на странице товара и на десктопе остаётся полноценная галерея по варианту.

Вы можете задать вопросы в телеграм-чат.
FAQ
Last updated
Was this helpful?