Shopify Tutorial · Dawn Theme · No App

Image-Based Color Swatches in Shopify — No App Required

In this tutorial, I'll show you how to display image-based swatches for products that are actually separate Shopify listings — completely free, no app required. We'll use Liquid, metaobjects, CSS, and a few lines of vanilla JavaScript. Built and tested on the Dawn theme, but the approach works on any theme with a product page.
Sample Products CSV Ready to import into Shopify
Download CSV
Video Tutorial
Step-by-step guide
01
Create the Metaobject definition

Go to Settings → Custom data → Metaobjects and click Add definition. Name it exactly:

name
Type Variant Product

Add two fields to this definition:

· Heading — Single line text (the shared model name, e.g. The Fred Perry Shirt)
· Products — List of products (every colour variant of that model)

💡
The heading value is converted to a handle using downcase | replace: ' ', '-' in Liquid. Shopify usually auto-generates a matching handle when you save the entry — double-check it matches.
02
Add your product groups as entries

Go to Content → Metaobjects → Type Variant Product and click Add entry.

· heading: The shared model name (e.g. The Fred Perry Shirt)
· products: Select every product that is a colour variant of this model

Repeat for each product group in your store. One entry = one set of swatches.

Each product should appear in only one entry. Adding the same product to multiple entries means the snippet will always match whichever entry it finds first.
03
Create the snippet file

Go to Online Store → Themes → Edit Code → Snippets and create a new file. Name it exactly:

filename
product-type-variant.liquid

Paste the full snippet below into that file and save:

Liquid
<fieldset class="product-form__input product-form__input--block">
  {% assign color = product.variants.first.option1 %}
  <div class="form__label">
    {{ product.options_with_values[0].name }}
    <span class="form__label__value">{{ color }}</span>
  </div>

  <div class="product-type-variant__container">

    {% assign metaObjectVariant = metaobjects.type_variant_product.values %}

    {% paginate metaObjectVariant by 250 %}

      {% assign typeVariantFound = false %}

      {% for typeVariant in metaobjects.type_variant_product.values %}
        {% assign typeVariantHeading = '' %}

        {% for typeVariantProduct in typeVariant.products.value %}
          {% if product.id == typeVariantProduct.id %}
            {% assign typeVariantHeading = typeVariant.heading.value %}
            {% assign typeVariantFound = true %}
            {% break %}
          {% endif %}
        {% endfor %}

        {% if typeVariantFound %}
          {% assign typeVariantHandle = typeVariantHeading | downcase | replace: ' ', '-' %}
          {% assign brand_images = shop.metaobjects["type_variant_product"][typeVariantHandle] %}
          {% assign swatch_count = brand_images.products.value | size %}

          <div class="product-type-variant__clap{% if swatch_count > 6 %} product-type-variant__clap--collapsed{% endif %}" data-clap>

            {% if swatch_count > 6 %}
              <div class="product-type-variant__chevron-wrap" data-clap-toggle>
                <span class="svg-wrapper">{{- 'icon-caret.svg' | inline_asset_content -}}</span>
              </div>
            {% endif %}

            <div class="product-type-variant__swatches">

              {%- comment -%} Active swatch first {%- endcomment -%}
              {% for productType in brand_images.products.value %}
                {% if productType.handle == product.handle %}
                  <a href="{{ productType.url }}" class="product-type-variant__product product-type-variant__product--active">
                    {{
                      productType.featured_image
                      | image_url: width: 500
                      | image_tag: widths: '50, 75, 100, 150, 200, 300, 400, 500', alt: productType.title
                    }}
                  </a>
                {% endif %}
              {% endfor %}

              {%- comment -%} Then all others {%- endcomment -%}
              {% for productType in brand_images.products.value %}
                {% unless productType.handle == product.handle %}
                  <a href="{{ productType.url }}" class="
                    product-type-variant__product
                    {% if productType.selected_or_first_available_variant.available == false %}product-type-variant__product--sold-out{% endif %}
                  ">
                    {{
                      productType.featured_image
                      | image_url: width: 500
                      | image_tag: widths: '50, 75, 100, 150, 200, 300, 400, 500', alt: productType.title
                    }}
                  </a>
                {% endunless %}
              {% endfor %}

            </div>

            {% if swatch_count > 6 %}
              <div class="product-type-variant__gradient"></div>
            {% endif %}

          </div>
          {% break %}
        {% endif %}
      {% endfor %}

      <div class="varian-type-pagination" style="display:none">
        {{- paginate | default_pagination -}}
      </div>

    {% endpaginate %}

  </div>
</fieldset>
💡
This is the corrected version — the {% if metaObjectVariant.count <= 250 %} wrapper has been removed. The {% paginate %} tag handles the 250-entry fetch on its own, so the extra conditional was unnecessary.
04
Add the CSS

Paste these styles inside a <style> block at the bottom of the snippet, or add them to your theme's base.css:

CSS
.product-type-variant__clap { position: relative; }

.product-type-variant__swatches {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.product-type-variant__clap--collapsed .product-type-variant__swatches {
  max-height: 150px;
  overflow: hidden;
}

/* Chevron — top right corner */
.product-type-variant__chevron-wrap {
  position: absolute;
  top: 0;
  right: 0;
  z-index: 2;
  cursor: pointer;
  padding: 2px;
  line-height: 0;
}
.product-type-variant__chevron-wrap svg {
  width: 20px;
  height: 20px;
  transition: transform 0.2s ease;
}
.product-type-variant__clap:not(.product-type-variant__clap--collapsed)
.product-type-variant__chevron-wrap svg {
  transform: rotate(180deg);
}

/* Gradient fade — visible only when collapsed */
.product-type-variant__gradient {
  display: none;
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 65px;
  background: linear-gradient(to bottom, transparent, #ffffff);
  cursor: pointer;
}
.product-type-variant__clap--collapsed .product-type-variant__gradient {
  display: block;
}

.product-type-variant__product {
  height: 115px;
  overflow: hidden;
}

.product-type-variant__container img {
  width: 100% !important;
  height: 100% !important;
  object-fit: cover;
}

/* Active swatch */
.product-type-variant__product--active {
  border: 1.5px solid #000;
}

/* Sold-out state */
.product-type-variant__product--sold-out { opacity: 0.45; }

@media (max-width: 550px) {
  .product-type-variant__chevron-wrap { top: -30px; }
}
05
Add the JavaScript toggle

Paste this at the bottom of the snippet inside a <script> tag. It handles the chevron collapse/expand and the gradient click-to-reveal:

JavaScript
// Chevron — expands AND collapses
document.querySelector('.product-type-variant__chevron-wrap').addEventListener('click', function(e) {
  const clap = e.target.closest('[data-clap]');
  clap.classList.toggle('product-type-variant__clap--collapsed');
});

// Gradient — expand only
document.querySelector('.product-type-variant__gradient').addEventListener('click', function(e) {
  const clap = e.target.closest('[data-clap]');
  clap.classList.remove('product-type-variant__clap--collapsed');
});
06
Render the snippet on the product page

Open product-variant-picker.liquid and find the {%- for option in product.options_with_values -%} loop. Wrap it so the Color option uses your snippet while all other options render normally:

product-variant-picker.liquid
{% for option in product.options_with_values %}
  {% if option.name == 'Color' %}

    {% render 'product-type-variant', product: product, option: option, block: block, section: section %}

  {% else %}
    {%- liquid
      assign swatch_count = option.values | map: 'swatch' | compact | size
      assign picker_type = block.settings.picker_type
      ...rest of original loop
    -%}
  {% endif %}
{% endfor %}
💡
The snippet renders nothing if the current product isn't found in any metaobject entry — so it's completely safe to place on every product page globally.
🎉

Save all files and preview

Open any product that belongs to a metaobject group and you'll see its colour siblings rendered as image swatches. If there are more than 6, they collapse with a gradient fade — click the chevron or the gradient to expand. Adjust the threshold by changing swatch_count > 6 in the snippet.

Available for new projects

Want this built for your store?

I implement this kind of custom Shopify development daily — no apps, clean code, done fast. Tell me what you need and I'll get it done right.

60+ custom stores built
5★ Upwork rating
Reply within 24hrs
Book a Free Call Send a message
NO COMMITMENT · REPLIES WITHIN 24HRS
Need help implementing this? Custom Shopify dev — no apps
Book Free Call →