← Documentation

How-to · Theme code · Updated October 27, 2026

Shopify theme code for the Jewel Shots 3D viewer

Four optional snippets that take the Jewel Shots viewer further than the app's settings: 3D on product card hover, a viewer that follows your Metal option, a phone layout with the 3D below the photos, and a gallery that keeps the 3D loaded when a variant changes. They are the code running on our live demo store, where you can try each one first.

Tested on Shopify's Clarity theme (Horizon 4.1.3) in Chrome, at desktop, tablet and phone sizes. Snippets 3 and 4 rely on Horizon's product page markup. Snippet 1 needs one selector changed on other themes. Snippet 2 finds your option by its input name, which most themes set, but we have only tested it on Horizon.

Before you start

  • Your products are published from Jewel Shots, so each one has a 3D item whose alt text starts Jewel Shots interactive 3D. See the Shopify setup guide.
  • Snippets 2 and 4 work on the viewer in your product gallery, so turn on the Jewel Shots gallery app embed. Snippet 2 also follows the 3D viewer block and the embed code. Snippets 1 and 3 add viewers of their own.
  • The viewer only loads on your own domains. To try a theme preview link, add shopifypreview.com as your test domain (Where the viewer runs).
  • Work on a copy of your theme: Online Store → Themes → ⋯ → Duplicate. Preview the copy, then publish it when you are happy.

The snippets show the same product viewers the app publishes, so they use no extra live embeds.

Add a snippet to your theme

  1. On your theme copy, click ⋯ → Edit code.
  2. Under Snippets, click Add a new snippet, give it the name shown above the code (for example jewel-shots-card-hover), paste the code and click Save.
  3. Open layout/theme.liquid and add one line for the snippet just above </head>:
    {% render 'jewel-shots-card-hover' %}
  4. Save, then preview the theme.

Add each snippet you want the same way, one line each. They do not depend on each other, but snippet 3 is best paired with snippet 2.

Shopify's theme updates do not carry over code edits. After updating your theme to a new version, add the snippets and their lines in theme.liquid again.

1. 3D on product card hover

Resting the pointer on a product card swaps its photo for the product's 3D model, live: shoppers can drag it, switch the metal, and click to open the product. It works wherever your theme shows product cards, such as collections, search and the home page.

  • Mouse and trackpad only. Touch screens keep the photo.
  • Once the page is idle, it reads each card's product data (a small request per product) to find its 3D. The model itself loads only when its card is hovered.
  • A “Loading 3D” label shows until the model is ready, which takes a few seconds the first time.
snippets/jewel-shots-card-hover.liquid
Show the code
{%- comment -%}
  Jewel Shots: product cards show the 3D model on hover.
  Hovering a card whose product has a published Jewel Shots model shows the model in place
  of the photo. Shoppers can drag it, switch the metal, and click to open the product.
  Mouse and trackpad only: touch screens keep the photo.
  Load it from layout/theme.liquid with: render 'jewel-shots-card-hover'
  Written for Horizon themes. On another theme, change CARD below to its card's media box.
{%- endcomment -%}
<style>
  .jewel-card-3d { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; opacity: 0; pointer-events: none; transition: opacity .3s; }
  .jewel-card-3d.is-on { opacity: 1; pointer-events: auto; }
  /* The card's photo arrows would page photos hidden under the 3D. */
  .card-gallery:has(.jewel-card-3d.is-on) slideshow-arrows { visibility: hidden; }
  /* Shown from the hover until the model is ready (a few seconds on a first visit). */
  .jewel-card-wait { position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%); z-index: 3; display: none; align-items: center; gap: 8px; padding: 7px 12px; border-radius: 999px; background: rgba(255,255,255,.94); border: 1px solid rgba(10,11,13,.08); font: 500 10px/1 ui-monospace, Menlo, monospace; letter-spacing: .08em; text-transform: uppercase; color: #0A0B0D; white-space: nowrap; pointer-events: none; }
  .jewel-card-wait::before { content: ''; width: 10px; height: 10px; border-radius: 50%; border: 1.5px solid rgba(10,11,13,.2); border-top-color: #0A0B0D; animation: jewel-spin .8s linear infinite; }
  [data-jewel-hover][data-jewel-loading] .jewel-card-wait { display: flex; }
  @keyframes jewel-spin { to { transform: rotate(360deg); } }
</style>
<script>
  (function () {
    if (!window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
    var CARD = '.card-gallery'; // the card's media box: the 3D covers it
    var shop = {{ shop.permanent_domain | json }};
    var origin = 'https://ringfacet.com';
    var prefix = 'Jewel Shots interactive 3D';
    var models = {};

    // The product's Jewel Shots 3D media id, from the storefront's product JSON.
    function modelFor(url) {
      var path = url.split(/[?#]/)[0];
      if (!models[path]) {
        models[path] = fetch(path + '.js')
          .then(function (response) { return response.ok ? response.json() : {}; })
          .then(function (product) {
            var model = (product.media || []).filter(function (media) {
              return media.media_type === 'model' && String(media.alt || '').indexOf(prefix) === 0;
            })[0];
            return model ? String(model.id) : '';
          })
          .catch(function () { return ''; });
      }
      return models[path];
    }

    function cardOf(node) { return node && node.closest ? node.closest(CARD) : null; }

    document.addEventListener('pointerover', function (event) {
      var card = cardOf(event.target);
      if (!card || card.contains(event.relatedTarget) || card.hasAttribute('data-jewel-none')) return;
      card.setAttribute('data-jewel-hover', '');
      var frame = card.querySelector('.jewel-card-3d');
      if (frame) { if (frame.hasAttribute('data-jewel-ready')) frame.classList.add('is-on'); return; }
      if (card.hasAttribute('data-jewel-pending')) return;
      var link = card.querySelector('a[href*="/products/"]');
      if (!link) return;
      card.setAttribute('data-jewel-pending', '');
      card.setAttribute('data-jewel-loading', '');
      var wait = document.createElement('div');
      wait.className = 'jewel-card-wait';
      wait.textContent = 'Loading 3D';
      card.appendChild(wait);
      modelFor(link.getAttribute('href')).then(function (media) {
        card.removeAttribute('data-jewel-pending');
        if (!media) { card.removeAttribute('data-jewel-loading'); card.setAttribute('data-jewel-none', ''); return; }
        frame = document.createElement('iframe');
        frame.className = 'jewel-card-3d';
        frame.title = 'Interactive 3D product view';
        frame.tabIndex = -1;
        frame.setAttribute('aria-hidden', 'true');
        frame.src = origin + '/embed.html?shop=' + encodeURIComponent(shop) + '&media=' + encodeURIComponent(media) + '&autoplay=1';
        // Straight after the photo's link, so the card's badges and quick add stay on top.
        link.after(frame);
      });
    });

    document.addEventListener('pointerout', function (event) {
      var card = cardOf(event.target);
      if (!card || card.contains(event.relatedTarget)) return;
      card.removeAttribute('data-jewel-hover');
      var frame = card.querySelector('.jewel-card-3d');
      if (frame) frame.classList.remove('is-on');
    });

    window.addEventListener('message', function (event) {
      var data = event.data || {};
      if (event.origin !== origin || data.type !== 'ringfacet:jewel-viewer') return;
      document.querySelectorAll('.jewel-card-3d').forEach(function (frame) {
        if (frame.contentWindow !== event.source) return;
        var card = cardOf(frame);
        if (data.status === 'ready') {
          frame.setAttribute('data-jewel-ready', '');
          if (card) card.removeAttribute('data-jewel-loading');
          if (card && card.hasAttribute('data-jewel-hover')) frame.classList.add('is-on');
        } else if (data.status === 'error') {
          frame.remove();
          if (card) { card.removeAttribute('data-jewel-loading'); card.setAttribute('data-jewel-none', ''); }
        } else if (data.status === 'click' && card) {
          var link = card.querySelector('a[href*="/products/"]');
          if (link) window.location.href = link.href;
        }
      });
    });

    // Look up every card's model once the page is idle, so a hover goes straight to
    // loading it. The model itself still loads only when its card is hovered.
    function prefetch() {
      document.querySelectorAll(CARD + ' a[href*="/products/"]').forEach(function (link) {
        modelFor(link.getAttribute('href'));
      });
    }
    if ('requestIdleCallback' in window) window.addEventListener('load', function () { requestIdleCallback(prefetch); });
    else window.addEventListener('load', function () { setTimeout(prefetch, 1500); });
  })();
</script>

On a theme other than Horizon

Change CARD to the element that holds the card's photo. It must contain the link to the product and be positioned (for example position: relative), because the 3D is laid over it. The rule hiding slideshow-arrows is Horizon's; drop it or point it at your theme's photo arrows.

Check it

Open a collection and rest the pointer on a product with a published model. You should see “Loading 3D”, then the model. Move away and back: it shows again straight away. Click without dragging: the product opens.

2. The 3D follows the Metal option

When a shopper picks a metal on the product page, every Jewel Shots viewer of that product on the page turns to the matching metal: the gallery, the 3D viewer block, the embed code and the box from snippet 3. The viewer's own metal switch keeps working, so shoppers can still compare metals in 3D without changing the variant.

snippets/jewel-shots-metal-sync.liquid
Show the code
{%- comment -%}
  Jewel Shots: the 3D viewer follows the product's Metal option.
  When a shopper picks a metal, every Jewel Shots viewer of this product on the page
  (gallery, 3D viewer block, embed code) turns to the metal whose name the chosen value
  contains: "14K White Gold" selects a metal named "White". The viewer's own metal
  switch keeps working.
  Load it from layout/theme.liquid with: render 'jewel-shots-metal-sync'
  Set OPTION to your option's name if it is not "Metal".
{%- endcomment -%}
{%- if template.name == 'product' -%}
  {%- liquid
    assign jewel_media = ''
    for media in product.media
      if media.media_type == 'model' and media.alt contains 'Jewel Shots interactive 3D'
        assign jewel_media = media.id
        break
      endif
    endfor
  -%}
  {%- if jewel_media != blank -%}
    <script>
      (function () {
        var OPTION = 'metal'; // matched against the option's input name, ignoring case
        var origin = 'https://ringfacet.com';
        var own = new RegExp('[?&](product={{ product.id }}|media={{ jewel_media }})(&|$)');

        function viewers() {
          return [].filter.call(document.querySelectorAll('iframe[src^="' + origin + '/embed"]'), function (frame) {
            return own.test(frame.src);
          });
        }
        // A radio button or dropdown for the option, outside product cards and quick add.
        function isOption(element) {
          return (element.type === 'radio' || element.tagName === 'SELECT') && element.name &&
            element.name.toLowerCase().indexOf(OPTION) > -1 && !element.closest('product-card, dialog');
        }
        function chosen() {
          var inputs = document.querySelectorAll('input[type=radio]:checked, select');
          for (var i = 0; i < inputs.length; i++) if (isOption(inputs[i])) return inputs[i].value;
          return '';
        }
        function send(frame) {
          var name = chosen();
          if (name && frame.contentWindow) frame.contentWindow.postMessage({ type: 'rfMetal', name: name }, origin);
        }

        document.addEventListener('change', function (event) {
          if (isOption(event.target)) viewers().forEach(send);
        });
        // A viewer opens on the model's first metal: match the page once it has loaded.
        window.addEventListener('message', function (event) {
          var data = event.data || {};
          if (event.origin !== origin || data.type !== 'ringfacet:jewel-viewer' || data.status !== 'ready') return;
          viewers().forEach(function (frame) { if (frame.contentWindow === event.source) send(frame); });
        });
      })();
    </script>
  {%- endif -%}
{%- endif -%}

Name your metals to match

The viewer picks the first of the model's metals whose name appears in the chosen value, ignoring case. With metals named Yellow, White and Rose in the editor, choosing 14K White Gold selects White.

  • Use a word that appears in your option values and in no other metal's name: Yellow, White, Rose, Platinum.
  • Avoid a metal named just Gold: 14K White Gold contains it too, and it would win if it comes first in the model's list.
  • If your option is not called Metal, change OPTION to a word in its name, in lower case.

Only the metal follows the page. Stone colours stay on the viewer's own switch.

Check it

On a product page, pick each metal in turn. The 3D should change to it within a moment, and the viewer's metal switch should move with it.

3. Phones: photos swipe, 3D below

On a phone, a 3D slide in the photo swipe catches the shopper's thumb: dragging it turns the model, so the swipe stops there. On screens under 750px wide, this snippet takes the 3D out of the swipe and its thumbnails, and shows it in its own square box right below the gallery. The photos swipe as usual, a thumb on the 3D turns it, and a vertical swipe over it scrolls the page.

snippets/jewel-shots-phone-3d.liquid
Show the code
{%- comment -%}
  Jewel Shots on phones: the photos swipe, and the 3D sits in its own box below them.
  On a 3D slide a shopper's thumb turns the model, so the swipe stops there. This takes the
  3D out of the swipe on phones (under 750px) and shows it below the gallery instead.
  Load it from layout/theme.liquid with: render 'jewel-shots-phone-3d'
  Written for Horizon themes (the gallery is .product-information__media). Pair it with
  jewel-shots-metal-sync so the box follows the Metal option too.
{%- endcomment -%}
{%- if template.name == 'product' -%}
  {%- liquid
    assign jewel_media = ''
    for media in product.media
      if media.media_type == 'model' and media.alt contains 'Jewel Shots interactive 3D'
        assign jewel_media = media.id
        break
      endif
    endfor
  -%}
  {%- if jewel_media != blank -%}
    <style>
      @media (max-width: 749px) {
        media-gallery slideshow-slide:has(img[alt^="Jewel Shots interactive 3D"]),
        media-gallery .slideshow-controls__thumbnail:has(img[alt^="Jewel Shots interactive 3D"]) { display: none !important; }
        .jewel-3d-below { position: relative; aspect-ratio: 1; margin-top: 8px; }
        .jewel-3d-below iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
      }
      @media (min-width: 750px) { .jewel-3d-below { display: none; } }
    </style>
    <script>
      document.addEventListener('DOMContentLoaded', function () {
        if (!window.matchMedia('(max-width: 749px)').matches) return;
        var gallery = document.querySelector('.product-information__media');
        if (!gallery) return;
        var box = document.createElement('div');
        box.className = 'jewel-3d-below';
        // The product layout is a grid on phones: take the same columns as the photos.
        box.style.gridColumn = getComputedStyle(gallery).gridColumn;
        var frame = document.createElement('iframe');
        frame.title = 'Interactive 3D product view';
        frame.allow = 'fullscreen';
        frame.src = 'https://ringfacet.com/embed.html?shop=' + encodeURIComponent({{ shop.permanent_domain | json }}) + '&media={{ jewel_media }}&autoplay=1';
        box.appendChild(frame);
        gallery.insertAdjacentElement('afterend', box);
      });
    </script>
  {%- endif -%}
{%- endif -%}

Horizon only: it places the box after .product-information__media, the product gallery's column, and gives it the same grid columns. Pair it with snippet 2 so the box follows the Metal option.

Check it

Open a product on a phone, or in your browser's phone view. The swipe should hold only your photos, and the 3D should fill the width below them.

4. Keep the 3D loaded on a variant change

When a shopper picks a variant, Horizon replaces the whole product gallery with a fresh copy, so it can move that variant's photo to the front. The 3D viewer inside the gallery reloads each time, and the shopper waits for the model again. This snippet keeps the current gallery when the fresh copy holds the same photos, so the 3D stays as it is and only turns to the new metal (with snippet 2).

snippets/jewel-shots-steady-gallery.liquid
Show the code
{%- comment -%}
  Jewel Shots in a Horizon gallery: the 3D stays loaded when a shopper picks a variant.
  On every variant change, Horizon replaces the whole product gallery with a fresh copy,
  which reloads the 3D viewer inside it. When the fresh copy holds the same photos (only
  their order differs), this keeps the current gallery, so the 3D stays as it is.
  When the photos differ, for example with "Hide unselected variant media" on, the theme
  swaps the gallery as usual. Tablets and desktops only (750px and up): on phones the
  gallery still moves the chosen variant's photo first.
  Load it from layout/theme.liquid with: render 'jewel-shots-steady-gallery'
{%- endcomment -%}
{%- if template.name == 'product' -%}
  <script>
    (function () {
      function photos(gallery) {
        var seen = {};
        gallery.querySelectorAll('img[src]').forEach(function (img) { seen[img.getAttribute('src').split('?')[0]] = 1; });
        return Object.keys(seen).sort().join(' ');
      }
      // Capture on document runs before the gallery's own listener, so this sees the
      // fresh copy first.
      document.addEventListener('shopify:product:select', function (event) {
        if (!event.promise || !window.matchMedia('(min-width: 750px)').matches) return;
        if (!(event.target instanceof Element) || event.target.closest('product-card, dialog')) return;
        var section = event.target.closest('.shopify-section');
        var current = section && section.querySelector('media-gallery');
        if (!current) return;
        event.promise.then(function (result) {
          var html = result && result.detail && result.detail.html;
          var fresh = html && html.querySelector('media-gallery');
          if (fresh && photos(fresh) === photos(current)) fresh.remove();
        }, function () {});
      }, true);
    })();
  </script>
{%- endif -%}
  • Tablets and desktops only (750px and up). On phones the theme still moves the chosen variant's photo to the front, and with snippet 3 the 3D is not in the gallery there.
  • The photos stay in their order. The chosen variant's photo is no longer moved to the front on a variant change; the 3D turning to the new metal shows the change instead.
  • With “Hide unselected variant media” on, each variant shows different photos, so the theme swaps the gallery as usual and the 3D reloads with it.

Check it

On a desktop, open a product and wait for the 3D to load. Pick another metal: the 3D should change colour without going back to its loading state.

Viewer reference: address and messages

Everything above is built on the product viewer's address and two kinds of message. You can use them for your own placements.

Address

https://ringfacet.com/embed.html?shop=YOUR-STORE.myshopify.com&product=PRODUCT_ID&autoplay=1
  • shop: your store's .myshopify.com address. In Liquid, {{ shop.permanent_domain }}.
  • product: the product's ID, or instead media: the ID of the product's Jewel Shots 3D item (the gallery embed uses this).
  • autoplay=1 loads the model straight away. Without it, the viewer shows the model's picture and a play button.

Messages from the viewer

A product viewer tells the page around it how it is doing:

{ type: 'ringfacet:jewel-viewer', status: 'ready' }
statusSent whenTypical use
readyThe model has loaded and is showingReveal the frame; send it the current metal
errorThe viewer cannot show this product here (no model, or a domain it does not run on)Hide the frame
clickA click on the model that was not a dragOpen the product from a card

Check event.origin === 'https://ringfacet.com', and compare event.source with each frame's contentWindow to know which viewer sent it.

Message to the viewer

frame.contentWindow.postMessage({ type: 'rfMetal', name: '14K White Gold' }, 'https://ringfacet.com');

Selects the model's metal whose name appears in name (see Name your metals to match). The viewer acts on it once its model has loaded, so send it again when the viewer reports ready, as snippet 2 does.

Troubleshooting

What you seeLikely causeWhat to do
Nothing happens on hoverA touch screen, the product has no published model, or CARD does not match your theme's cardsTry a mouse; check the product in the setup guide tests; set CARD
The 3D never appears in a card, or in the galleryThe viewer reported error: often a domain it does not run on, such as a preview linkAdd your test domain (Where the viewer runs)
The 3D does not follow the Metal optionThe option's input name does not contain OPTION, or no metal name appears in the chosen valueSet OPTION; rename the model's metals in the editor
The wrong metal is pickedAnother metal's name also appears in the value and comes firstGive each metal a distinct word (Name your metals to match)
No 3D box on phonesThe theme is not a Horizon theme, or the product has no Jewel Shots 3D itemUse the 3D viewer block on phones instead
The 3D still reloads on a variant change“Hide unselected variant media” is on, or the theme is not a Horizon themeExpected in both cases: the gallery's photos change with the variant

See all four running on a live store.