Skip to content

Shopify integration with the Mini Viewer SDK

Shopify Viewer Integration

Use the iJewel Mini Viewer SDK to add an interactive 3D viewer to a Shopify product page. This is the recommended custom integration for Dawn and related themes.

The Mini Viewer SDK is built on WebGI, but it manages the common viewer setup, model loading, UI, configurators, and lifecycle for you. You can still access the underlying WebGI viewer when you need targeted scene, camera, or plugin customization.

Looking for the simplest option?

If you already have an embed URL from iJewel Drive or iJewel Design and do not need the page to control the viewer directly, use Add an Embedded iJewel Viewer to Shopify.

Why use the Mini Viewer SDK?

  • It is the recommended integration for new Shopify viewer projects.
  • It provides a stable API instead of requiring your theme to manage WebGI directly.
  • It can load a public iJewel Drive model by ID or load a standalone project configuration.
  • It includes iJewel viewer UI and support for configured materials, configurators, and Try-On features.
  • It reduces theme code and makes future viewer upgrades easier.
  • It provides the underlying WebGI ViewerApp through the ijewel-viewer-ready event when advanced customization is required.

Use the direct WebGI integration only if your team needs full engine-level control and will maintain the viewer initialization, plugins, UI, and lifecycle itself.

Prerequisites

  • Access to the Shopify admin and theme code
  • A duplicate of the theme for development and testing
  • Basic familiarity with Liquid, HTML, CSS, and JavaScript
  • A public iJewel Drive model ID, or a public URL to a GLB/GLTF model
  • An iJewel3D SDK website license; see Licensing

TIP

If you prefer to have a development team handle the integration, see our Development Partners.

Step 1: Back up the theme

In Shopify admin, open Online Store → Themes, open the theme actions menu, and select Duplicate. Make the integration changes in the duplicate theme and preview it before publishing.

Step 2: Create a product metafield

Create a product metafield that maps each Shopify product to its iJewel Drive model:

  1. Open Settings → Custom data → Products.
  2. Select Add definition.
  3. Set the name to iJewel Model ID.
  4. Set the namespace and key to custom.ijewel_model_id.
  5. Select Single line text as the type and save it.
  6. Open each product and enter its public iJewel Drive file ID in this metafield.

The file must be public

Only files shared publicly in iJewel Drive can load on a storefront. A private file returns Not found and the viewer stays empty, so make the file public in Drive before testing.

If you host GLB/GLTF files yourself instead of using iJewel Drive, create a URL metafield here instead; see Load a GLB file by URL.

Step 3: Add a viewer asset

In Online Store → Themes → Edit code, create assets/ijewel-mini-viewer.js with the following code:

Tested script versions

The examples in this guide use WebGI 0.22.0 with Mini Viewer 0.6.8. Load WebGI first and keep both versions fixed until you have tested an upgrade in your duplicated theme.

js
// Initialize every iJewel viewer container that has not been initialized yet.
function initIjewelViewers(root = document) {
  if (typeof ijewelViewer === 'undefined') return; // SDK bundle not loaded yet

  root.querySelectorAll('[data-ijewel-model-id], [data-ijewel-model-url]').forEach((container) => {
    if (container.dataset.ijewelInitialized === 'true') return;
    container.dataset.ijewelInitialized = 'true';

    // Defaults, merged with any options set from the Shopify admin (see Step 5).
    const viewerOptions = Object.assign(
      {
        showCard: false,
        brandingSettings: {
          enable: true,
        },
        showConfigurator: true,
        transparentBg: true,
      },
      window.iJewelViewerOptions || {},
    );

    if (container.dataset.ijewelModelId) {
      // Load a model from iJewel Drive by its file ID.
      ijewelViewer.loadModelById(
        container.dataset.ijewelModelId,
        container.dataset.ijewelBasename || 'drive',
        container,
        viewerOptions,
      );
    } else {
      // Load a GLB/GLTF file directly from a URL.
      new ijewelViewer.Viewer(
        container,
        { modelUrl: container.dataset.ijewelModelUrl },
        viewerOptions,
      );
    }
  });
}

// Initialize on the first page load.
initIjewelViewers();

// The Shopify theme editor re-renders sections; initialize any new containers.
document.addEventListener('shopify:section:load', (event) => {
  initIjewelViewers(event.target);
});

// Optional: access the underlying WebGI ViewerApp for targeted customization.
// Keep model loading and viewer lifecycle in the Mini Viewer SDK.
window.addEventListener('ijewel-viewer-ready', (event) => {
  const webgiViewer = event.detail.viewer;
  console.log('iJewel viewer ready', webgiViewer);
});

The options shown above are examples. See Viewer Options for all available controls.

Each container loads either an iJewel Drive model (data-ijewel-model-id) or a GLB/GLTF file from a URL (data-ijewel-model-url); see Load a GLB file by URL. The data-ijewel-initialized guard prevents a container from loading twice, and the shopify:section:load listener re-initializes the viewer when Shopify's theme editor re-renders the product section.

Step 4: Add the viewer to the product page

Open sections/main-product.liquid and add the following block where the 3D viewer should appear:

liquid
{%- if product.metafields.custom.ijewel_model_id.value != blank -%}
  <div
    id="ijewel-viewer-{{ section.id }}"
    data-ijewel-model-id="{{ product.metafields.custom.ijewel_model_id.value | escape }}"
    data-ijewel-basename="drive"
    style="width: 100%; height: 600px;"
  ></div>

  <script
    src="https://releases.ijewel3d.com/libs/webgi-v0/bundle-0.22.0.js"
    defer
  ></script>
  <script
    src="https://releases.ijewel3d.com/libs/mini-viewer/0.6.8/bundle.nowebgi.iife.js"
    defer
  ></script>
  <script src="{{ 'ijewel-mini-viewer.js' | asset_url }}" defer></script>
{%- endif -%}

Deferred scripts execute in document order, so WebGI loads before the Mini Viewer SDK and your theme asset runs last.

Enterprise Drive instances

Keep data-ijewel-basename="drive" for the standard iJewel Drive. Enterprise customers with a custom Drive instance should replace drive with the basename supplied by iJewel3D.

Load a GLB file by URL

If your models are GLB/GLTF files hosted outside iJewel Drive, the same asset script can load them with the Viewer class instead of loadModelById:

  1. In Step 2, create the metafield with the URL type — for example custom.ijewel_model_url — and enter each product's model URL.
  2. Use data-ijewel-model-url on the container instead of data-ijewel-model-id:
liquid
{%- if product.metafields.custom.ijewel_model_url.value != blank -%}
  <div
    id="ijewel-viewer-{{ section.id }}"
    data-ijewel-model-url="{{ product.metafields.custom.ijewel_model_url.value | escape }}"
    style="width: 100%; height: 600px;"
  ></div>

  <!-- The same three script tags as in the block above. -->
{%- endif -%}

The asset script detects the attribute and creates the viewer with new ijewelViewer.Viewer(container, { modelUrl }, viewerOptions). The second argument is a project configuration and also accepts scene settings such as a custom environment map; see the Viewer Class reference.

A directly loaded GLB file has no iJewel Drive configuration, so materials, environments, and configurators must be part of the file itself. The file's host must also allow cross-origin requests from your storefront domain.

Step 5: Change viewer options from the admin (optional)

The asset script applies window.iJewelViewerOptions on top of its defaults, so you can adjust the viewer from the Shopify admin later without editing code again.

  1. Open Settings → Custom data → Shop and add a definition named iJewel viewer options with the namespace and key custom.ijewel_viewer_options and the JSON type.

  2. Enter the options you want to override as JSON, for example:

    json
    {
      "showConfigurator": false,
      "runRotateCamera": true
    }
  3. In sections/main-product.liquid, print the metafield before the asset script from Step 4:

    liquid
    {%- if shop.metafields.custom.ijewel_viewer_options.value -%}
      <script>
        window.iJewelViewerOptions = {{ shop.metafields.custom.ijewel_viewer_options.value | json }};
      </script>
    {%- endif -%}

Skip this step if the defaults in the asset script are all you need.

Step 6: Match the theme layout

Adjust the viewer container height for your product-page layout. You can place the standalone container beside the media gallery or in the product-information area, but keep it outside Shopify's native <model-viewer> and <product-model> elements. Dawn-based themes are frequently customized, so confirm the exact section and snippet names in your theme.

The standalone container intentionally leaves Shopify's built-in <model-viewer> and AR/XR behavior unchanged. If you want Mini Viewer to replace 3D files uploaded to Shopify product media instead, use Replace Dawn's built-in 3D viewer. Do not combine both approaches on the same product page.

Step 7: Test the integration

Preview the duplicated theme and verify that:

  1. Products with an iJewel model ID show the viewer.
  2. Products without the metafield continue to use the normal product layout.
  3. The viewer works in the product media area on desktop and mobile.
  4. Product variants, quick-view drawers, and dynamically loaded sections do not create duplicate viewers.
  5. The browser console has no script, CORS, or licensing errors.

If a model cannot be loaded from iJewel Drive, confirm that it is public and that the storefront domain is allowed. See Connect to iJewel Drive for model-loading details.

License

An iJewel3D Evaluation Version watermark and a console warning appear when the SDK is not licensed for the storefront domain. To remove them, purchase a license; see Licensing.

Replace Dawn's built-in 3D viewer

Use this alternative when product 3D models should open in Mini Viewer inside Dawn's existing product gallery and media modal. The override reads each <model-viewer>'s src attribute and supports the same two sources as the standalone container: a GLB file uploaded to Shopify's Media section, or a public iJewel Drive link in the form https://ijewel3d.com/<instance>/files/<fileId> — for example when a customized theme fills src from a URL metafield. Drive links are loaded by instance and file ID, so the model keeps its Drive configuration.

Dawn renders separate <model-viewer> elements for the gallery and modal. A Mini Viewer cannot be reused by calling render() on a second container because its React root remains attached to the container where it was created. The override below creates one persistent host and physically moves that host between the visible gallery or modal element. This follows the same shared-viewer lifecycle used by the tested Shopify implementation.

Theme-specific integration

This override targets current Dawn-style product-model, product-modal, and DeferredMedia elements. Test it against your exact theme version and customizations. The override replaces Shopify's native model viewer and native Shopify XR controls; configure iJewel Try-On separately if required.

Step 1: Create the override asset

In Online Store → Themes → Edit code, create assets/ijewel-model-viewer-override.js:

js
(() => {
  if (window.iJewelShopifyOverrideInitialized) return;
  window.iJewelShopifyOverrideInitialized = true;

  if (customElements.get('model-viewer')) {
    console.error(
      'iJewel override must load before Shopify defines <model-viewer>.',
    );
    return;
  }

  const viewerOptions = Object.assign(
    {
      showCard: false,
      brandingSettings: {
        enable: true,
      },
      showConfigurator: true,
      transparentBg: true,
    },
    window.iJewelViewerOptions || {},
  );

  // The model src supports the same two sources as the standalone container:
  // a public iJewel Drive link — https://ijewel3d.com/<instance>/files/<fileId> —
  // loaded by instance and file ID so the model keeps its Drive configuration,
  // or a direct GLB/GLTF URL.
  function getDriveFileRef(src) {
    const match = src.match(/ijewel3d\.com\/([^/]+)\/files\/([^/?#]+)/);
    return match ? { basename: match[1], fileId: match[2] } : null;
  }

  // Mini Viewer creates its React root in this element. Move this same host
  // between Dawn's gallery and modal instead of creating a second viewer.
  const sharedHost = document.createElement('div');
  Object.assign(sharedHost.style, {
    width: '100%',
    height: '100%',
    minHeight: '420px',
  });

  let sharedViewer = null;
  let currentOwner = null;
  let currentSrc = null;
  let sceneReady = false;

  function finishSceneLoad() {
    sceneReady = true;
    currentOwner?.markLoaded();
  }

  class IJewelModelViewer extends HTMLElement {
    constructor() {
      super();
      this.modelLoaded = false;
      this.visibilityObserver = null;
    }

    connectedCallback() {
      // Prevent Shopify's Google Model Viewer feature loader from handling
      // this element.
      delete this.dataset.shopifyFeature;

      // Modal media is activated when product-modal opens. Gallery media is
      // initialized only when it becomes visible.
      if (this.closest('product-modal')) return;

      this.visibilityObserver = new IntersectionObserver((entries) => {
        if (!entries.some((entry) => entry.isIntersecting)) return;
        this.activate();
        this.visibilityObserver?.disconnect();
        this.visibilityObserver = null;
      }, { threshold: 0.01 });
      this.visibilityObserver.observe(this);
    }

    disconnectedCallback() {
      this.visibilityObserver?.disconnect();
      this.visibilityObserver = null;
    }

    doOnLoad(callback) {
      this.onLoad = callback;
      if (this.modelLoaded) callback();
    }

    activate() {
      const src = this.getAttribute('src');
      if (!src || typeof ijewelViewer === 'undefined') return;

      if (sharedHost.parentElement !== this) {
        this.appendChild(sharedHost);
      }
      currentOwner = this;

      if (!sharedViewer) {
        currentSrc = src;
        sceneReady = false;
        window.addEventListener('ijewel-scene-ready', finishSceneLoad, {
          once: true,
        });
        const driveFile = getDriveFileRef(src);
        if (driveFile) {
          // Store the promise immediately so a second activate() cannot
          // create another viewer while this model is still loading.
          sharedViewer = ijewelViewer
            .loadModelById(driveFile.fileId, driveFile.basename, sharedHost, viewerOptions)
            .then((viewer) => (sharedViewer = viewer));
        } else {
          sharedViewer = new ijewelViewer.Viewer(
            sharedHost,
            { modelUrl: src },
            viewerOptions,
          );
        }
      } else if (currentSrc !== src && typeof sharedViewer.render === 'function') {
        currentSrc = src;
        sceneReady = false;
        this.modelLoaded = false;
        window.addEventListener('ijewel-scene-ready', finishSceneLoad, {
          once: true,
        });
        const driveFile = getDriveFileRef(src);
        if (driveFile) {
          // Passing the existing viewer makes loadModelById render into it
          // instead of creating a second one.
          ijewelViewer.loadModelById(driveFile.fileId, driveFile.basename, sharedHost, {
            ...viewerOptions,
            viewer: sharedViewer,
          });
        } else {
          sharedViewer.render({ modelUrl: src });
        }
      } else if (sceneReady) {
        this.markLoaded();
      }

      requestAnimationFrame(() => {
        sharedViewer?.viewerApp?.resize?.();
      });
    }

    markLoaded() {
      if (currentOwner !== this) return;
      this.modelLoaded = true;
      this.onLoad?.();
      requestAnimationFrame(() => {
        sharedViewer?.viewerApp?.resize?.();
      });
    }

    pause() {
      // Dawn calls pause() when another media item becomes active.
    }
  }

  customElements.define('model-viewer', IJewelModelViewer);

  // Replace Dawn's product-model wrapper so it clones the template without
  // loading Shopify's ModelViewerUI.
  if (!customElements.get('product-model')) {
    customElements.define(
      'product-model',
      class extends DeferredMedia {
        constructor() {
          super();
          const modelViewer =
            this.querySelector('template')?.content.querySelector('model-viewer');
          if (modelViewer) delete modelViewer.dataset.shopifyFeature;
        }

        loadContent(focus = true) {
          super.loadContent(focus);
          const modelViewer = this.querySelector('model-viewer');
          if (!modelViewer) return;

          modelViewer.doOnLoad(() => {
            const poster = this.querySelector('.deferred-media__poster');
            if (poster) poster.style.display = 'none';
          });
          modelViewer.activate();
        }

        setupModelViewerUI() {}
      },
    );
  }

  function findModalViewer(modal) {
    return (
      modal.querySelector('[data-media-id].active model-viewer') ||
      modal.querySelector('.active model-viewer')
    );
  }

  function findGalleryViewer(src) {
    return Array.from(
      document.querySelectorAll('product-model model-viewer'),
    ).find(
      (modelViewer) =>
        !modelViewer.closest('product-modal') &&
        modelViewer.getAttribute('src') === src,
    );
  }

  function syncModal(modal) {
    if (modal.hasAttribute('open')) {
      requestAnimationFrame(() => {
        findModalViewer(modal)?.activate();
      });
      return;
    }

    if (!currentSrc) return;
    findGalleryViewer(currentSrc)?.activate();
  }

  function observeProductModals(root = document) {
    root.querySelectorAll('product-modal').forEach((modal) => {
      if (modal.dataset.ijewelObserved === 'true') return;
      modal.dataset.ijewelObserved = 'true';

      new MutationObserver(() => syncModal(modal)).observe(modal, {
        attributes: true,
        attributeFilter: ['open'],
      });
    });
  }

  observeProductModals();
  document.addEventListener('shopify:section:load', () => {
    // Dawn can move product-modal under <body>, outside the reloaded section.
    observeProductModals(document);
  });

  // Native Shopify XR is disabled by this replacement. Hide Dawn's native XR
  // button, including buttons added by a later section render.
  const xrStyle = document.createElement('style');
  xrStyle.textContent = '[data-shopify-xr] { display: none !important; }';
  document.head.appendChild(xrStyle);
  window.ProductModel = {
    loadShopifyXR() {},
    setupShopifyXR() {},
  };
})();

The important detail is sharedHost: Mini Viewer is created in that element once, and the exact same element is moved into whichever model-viewer is visible. When another model is selected, the project is swapped on the same viewer — render() for GLB URLs, or loadModelById with the viewer option for Drive links — without changing the React root.

Step 2: Replace Dawn's product-model script

In sections/main-product.liquid, find the block that loads product-model.js. Some Dawn versions include the same block in sections/featured-product.liquid.

liquid
{%- if first_3d_model -%}
  <script type="application/json" id="ProductJSON-{{ product.id }}">
    {{ product.media | where: 'media_type', 'model' | json }}
  </script>
  <script src="{{ 'product-model.js' | asset_url }}" defer></script>
{%- endif -%}

Keep ProductJSON, but replace the product-model.js script with the tested WebGI and Mini Viewer bundles followed by the override:

liquid
{%- if first_3d_model -%}
  <script type="application/json" id="ProductJSON-{{ product.id }}">
    {{ product.media | where: 'media_type', 'model' | json }}
  </script>
  <script
    src="https://releases.ijewel3d.com/libs/webgi-v0/bundle-0.22.0.js"
    defer
  ></script>
  <script
    src="https://releases.ijewel3d.com/libs/mini-viewer/0.6.8/bundle.nowebgi.iife.js"
    defer
  ></script>
  <script
    src="{{ 'ijewel-model-viewer-override.js' | asset_url }}"
    defer
  ></script>
{%- endif -%}

Deferred scripts execute in document order. The override must run after both viewer bundles and before any other script defines <model-viewer>.

WARNING

Do not replace product-modal.js; it controls Dawn's media modal and is used by the shared-host lifecycle.

Step 3: Upload and test a Shopify model

  1. Edit a product and upload a GLB file in its Media section.
  2. Preview the duplicated theme and select the 3D media item in the product gallery.
  3. Open the media modal and confirm that the same loaded viewer moves into it.
  4. Close the modal and confirm that the viewer returns to the gallery.
  5. Repeat the test with more than one Shopify 3D model, on mobile, and in the theme editor.
  6. Confirm that the browser console contains no custom-element, WebGL-context, or script-order errors.

If your theme fills the model-viewer src from an iJewel Drive URL metafield instead of uploaded media, also verify that the Drive model loads with its configured materials and configurator.

For iJewel Try-On or store-specific variant-to-material behavior, extend this lifecycle rather than creating another Mini Viewer. The working Shopify implementation uses the same persistent viewer for those features.

Next steps

Contact

For integration or licensing assistance, email contact@ijewel3d.com.