Jewelry Configurator Live demo →

Embed API

Embed the 3D configurator on your storefront and drive it from your own UI.

There are two ways to use the configurator:

  1. Shopify theme block (no code) — add the Jewelry Configurator block to your product template and set the options in the theme editor. Section 1 below.
  2. Direct iframe (developers) — embed the iframe yourself and drive it from your own buttons via postMessage. Sections 2 onwards.

1. Shopify — each product's own model

Add the Jewelry Configurator block to your product template once. It then appears on every product page and works out which model belongs to the product the shopper is looking at, in this order — first hit wins:

  1. The jewelshop.model metafield — a product metafield holding the URL of a .glb. Always wins, and it is what we fill in for you when we produce your models.
  2. The product's own 3D model — a .glb you uploaded to the product in the Shopify admin (Products → your product → Add media). Nothing to configure at all.
  3. The sample ring — a product with neither keeps showing the demo ring chosen in the block settings, so a half-finished catalog never renders an empty box.

To add the metafield: Settings → Custom data → Products → Add definition, namespace and key jewelshop.model, type URL (single-line text also works). Then fill it in per product.

A model resolved this way is shown exactly as you authored it — your materials, textures and colours are left alone. Letting shoppers change the stone or the metal additionally needs a role manifest (see Showing your own model). For a model uploaded as Shopify product media, put the manifest in the jewelshop.model_roles product metafield (type JSON); the block forwards it for you.

The block renders the viewer chromeless — 3D only, no buttons of ours. Your product page already has the shopper's option pickers between the title and the price; a second set of controls inside the frame would drive nothing purchasable. To make the viewer react to those pickers, drive it over postMessage — section 3.

Part info on click. The block setting Show part info on click lets shoppers click or tap a stone or the band and see a small card with its name and your notes. The parts and notes come from the parts list in the jewelshop.model_roles metafield (section 4). To show your own design instead — photos, video, your fonts — leave the setting off and add a theme script that turns picking on and draws the note (section 4, option B).

2. Embedding the iframe

<iframe
  src="https://jewelshop.ai/demo/?embed=1"
  width="100%"
  height="550"
  style="border:none;"
  allowfullscreen></iframe>

URL parameters

ParamValuesDefaultMeaning
embed1—Required. Runs the chromeless embed (no landing page, no intro).
controls0 / 101 shows the built-in gem/metal/ring UI. Omit / 0 = clean viewer you drive yourself.
gemdiamond, ruby, emerald, blue_topaz, amberdiamondInitial gemstone.
metalsilver, gold, rose_goldsilverInitial metal.
ring0, 1, 20Initial ring style (demo set only — ignored when model is given).
modelURL of a .glb—Show your own model instead of our demo rings. See below.
rolesURL-encoded role manifest JSON—The model's role manifest inline, instead of a .roles.json sidecar. Only with model. See below.
picking1off1 turns on part info: shoppers click or hover a stone or the band. See section 4.
loader0—0 hides the loading screen, so an inline embed shows nothing but the 3D view from the first paint.
bgRRGGBB (hex, # optional), e.g. fefefee7e7e7 (light grey); ffffff with loader=0Background of the loading screen. With loader=0, the page colour behind the viewer until the first frame, so it blends with your page.
ground0—0 hides the floor and its shadow: just the piece.
shopyour-store.myshopify.com—License identity on Shopify. The theme block sets this automatically.
keyyour license key—License identity for a direct (non-Shopify) integration (contact us).

By default the embed is chromeless — just the rotating 3D viewer. Add &controls=1 only if you want our built-in switcher instead of your own UI.

Embedded on a site of yours, the loading screen shows only the progress bar, with no text or logo, whatever bg is. loader=0 hides the screen altogether.

Showing your own model

https://jewelshop.ai/demo/?embed=1&model=https://cdn.example.com/rings/solitaire.glb

Percent-encode the model value when you build the URL, so a ?, & or # in your model's own URL stays part of it (the example above has none):

const src = 'https://jewelshop.ai/demo/?embed=1&model=' + encodeURIComponent(modelUrl);

model is read only together with embed=1, and only while a page frames the viewer: opened in a tab of its own, the viewer ignores it. The URL must be https (or a path on jewelshop.ai itself), the file a self-contained .glb of at most 32 MiB (a .gltf is refused: its buffers are always a side file or a data URI; a larger file is cancelled mid-download), and the host must send CORS headers allowing jewelshop.ai to fetch it. You hear how the load went the same way as after set-model: model-loaded, or model-error and an empty viewer. A URL that isn't https (or ours) gets model-error {reason:'invalid-url'} without a download, and the viewer stays empty too.

Your model is rendered with the materials you authored. We only take over a material when a role manifest tells us to: a small JSON file served next to the model, at the model's URL with .roles.json appended (solitaire.glb → solitaire.glb.roles.json):

{ "gem_nodes": ["Diamond_Round"], "metal_nodes": "*" }

No manifest means no recolouring — the piece is shown exactly as exported, which is what you want for a fixed showcase. Add one when the shopper should be able to change the stone or the metal.

When you can't serve a sidecar (&roles=)

A sidecar only works if you can put a file next to the model. When the model lives somewhere you don't control the paths — Shopify 3D media, served from the store's own domain, is the common case — pass the same JSON inline on the URL instead, URL-encoded:

…&model=https%3A%2F%2Fyour-store.myshopify.com%2Fcdn%2Fshop%2F3d%2Fmodels%2F…%2Fsolitaire.glb&roles=%7B%22gem_nodes%22%3A%5B%22Diamond_Round%22%5D%2C%22metal_nodes%22%3A%22*%22%7D

roles takes precedence over the sidecar: if it is present the viewer uses it and never fetches .roles.json. Omit it and the sidecar lookup happens as usual. A roles that isn't JSON of the manifest's shape (the same check as set-model's) is ignored with a console warning, and the sidecar is looked up instead. roles counts only next to an accepted model: it never applies to our demo rings. This is the URL twin of the set-model message's inline roles (below), and it is how a Shopify store keeps both the model and its roles entirely on Shopify — the roles ride along from a jewelshop.model_roles product metafield that the theme block forwards here automatically.

The whole iframe URL may be at most 32 KB; a longer one is refused with 414 URI Too Long before the viewer loads. URL-encoding inflates JSON (every quote, brace and colon becomes three characters), so a manifest with long part notes (parts[].data) can get there — keep the notes short, serve a sidecar, or send the manifest with set-model instead.

3. Driving it from your own UI (postMessage)

Every message — both directions — carries source: "jewelshop". Ignore any message without it.

Handshake — wait for ready

The viewer posts ready as soon as it is listening, and again once the 3D renderer has finished loading. Wait for it before sending commands.

const frame = document.querySelector('iframe').contentWindow;

window.addEventListener('message', (e) => {
  if (e.data?.source !== 'jewelshop') return;
  if (e.data.kind === 'ready' && e.data.rendererLoaded) {
    // Safe to send commands now.
  }
});

Commands you send (host → viewer)

kindPayloadEffect
apply-preset{ preset: { gem, metal, ring } }Change gem/metal/ring (any subset). Values outside the lists above are ignored. Replies preset-applied when something changed.
set-camera{ position:[x,y,z], target:[x,y,z], duration?, ease? }Move the camera to a fixed angle. duration in seconds (default 0.8, clamped to 0–5; 0 snaps), ease a GSAP ease name. Replies camera-set; a malformed payload is ignored with no reply.
set-model{ url: 'https://…/piece.glb', roles? }Swap the displayed model — e.g. when the shopper picks a different product. Once the embed has started. Replies model-set immediately, then model-loaded or model-error when the download settles (model-loaded straight away for the model already shown). A set-model overtaken by a newer one before it settles, even one for the same URL, gets no second reply.
set-controls{ visible: true | false }Show/hide the built-in UI at runtime, once the embed has started. Replies controls-set.
set-picking{ enabled?, card?, hoverDelay? }Part info (section 4). Off in an embed until enabled: true (or ?picking=1). card: false hides our built-in card so you can draw your own. hoverDelay: ms the pointer must rest on a part before part-hover (default 200; 0 = instant). Send at least one field; a command with none, or with a malformed one, is refused with no reply. Replies picking-set with the settings now in force.
select-part{ id: string | null }Highlight a part (a parts id from the model's manifest), or clear the highlight, e.g. when your note is closed. Only while picking is on. Replies part-selected.
pause—Stop rendering + input (park a hidden viewer), once the viewer has started. Replies paused.
resume—Resume rendering + input. Replies resumed.
embed-init{ preset? }Only needed if you embed WITHOUT ?embed=1; boots embed mode.

A command that can't apply gets no reply rather than a misleading one. Send commands after ready with rendererLoaded: true.

frame.postMessage(
  { source: 'jewelshop', kind: 'apply-preset', preset: { gem: 'ruby', metal: 'gold' } },
  'https://jewelshop.ai'   // the configurator's origin, so nothing else receives it
);

Passing the role manifest inline

set-model takes an optional roles object — the same manifest that would otherwise be served as <model>.glb.roles.json:

frame.postMessage({
  source: 'jewelshop',
  kind: 'set-model',
  url: 'https://cdn.example.com/rings/solitaire.glb',
  roles: { gem_nodes: ['Diamond_Round'], metal_nodes: ['Band'] }
}, 'https://jewelshop.ai');

Use this when the model sits somewhere you cannot add files next to it — an S3 bucket, a CDN, Shopify media — while the node roles live in your own database. Nothing is fetched; the manifest goes straight to the renderer.

Anything else — a string, an array, an object whose gem_nodes isn't a list of names or whose metal_nodes isn't "*" or a list, or a list of more than 1000 names or with a name over 256 characters — is refused with model-error {reason:'invalid-roles'} rather than being coerced into something that might repaint the piece. And because loads are deduplicated by URL, the roles that arrive with the first load of a given URL are the ones that apply.

Events you receive (viewer → host)

kindWhen
readyViewer is listening (rendererLoaded:false), then again when the 3D scene is loaded (rendererLoaded:true).
preset-appliedAfter an apply-preset that changed something. Carries the resulting { gem, metal, ring }.
camera-setAfter set-camera.
model-setThe set-model URL was accepted. Carries { url }. Loading has only just started.
model-loadedThe model finished loading and is on screen. Carries { url }.
model-errorThe model could not be shown. Carries { url, reason }; reason is invalid-url, invalid-roles, or load-failed (404, a refused cross-origin fetch, a file that isn't a usable glTF), with the loader's message in detail. The viewer keeps showing the previous model.
controls-setAfter set-controls. Carries { visible }.
paused / resumedAfter pause / resume.
close-requestThe user pressed Escape inside the viewer.
part-hoverThe mouse has rested on a part for hoverDelay ms, or just left it (part: null). Sweeping across the piece sends nothing. Mouse and pen only, never touch. Section 4.
part-clickClick or tap on the piece. part: null means empty space.
part-dismissThe viewer dropped the clicked part's highlight because the part may no longer be where the note points: the shopper orbited or zoomed, the camera moved (your set-camera, or the viewer's own camera animation), the ring or model changed (including your apply-preset and set-model), or the viewer switched between its intro and configurator views. Sent only while a clicked part is selected. Close any note you pinned to it.
picking-set / part-selectedAfter set-picking / select-part. picking-set carries { enabled, card, hoverDelay? }: the settings now in force, enabled as the viewer reports it (false if picking could not start), hoverDelay only once one was set. part-selected carries { id }, the id you sent (null for cleared).

4. Part info on click and hover

Let shoppers click a stone to read about it, or hover the band to see the alloy — and show whatever you like when they do: a description, a photo, a video, a link. It is off in an embed until you turn it on.

Name the parts

Parts come from a parts list in the model's role manifest — the same JSON as above (the .roles.json sidecar, &roles=, or on Shopify the jewelshop.model_roles product metafield):

{
  "gem_nodes": ["Diamond_Cushion_Square", "diamond_side_001", "diamond_side_002"],
  "metal_nodes": "*",
  "parts": [
    { "id": "center-stone", "nodes": ["Diamond_Cushion_Square"], "label": "Center stone",
      "data": { "description": "1.2 ct cushion cut", "video": "https://…/stone.mp4" } },
    { "id": "side-stones", "nodes": ["diamond_side_*"], "label": "Side stones" },
    { "id": "band", "nodes": "metal", "label": "Band",
      "data": { "notes": ["14k gold", "lifetime warranty"] } }
  ]
}

Without parts, each node in gem_nodes is its own part (named after the node) and the metal is one part, metal.

Option A: our built-in card (no code)

Add &picking=1 (on Shopify: the block setting Show part info on click). Hovering a part shows a small card with its label plus data.description and data.notes; clicking pins it; rotating or zooming closes it.

Option B: your own note (any HTML)

Turn picking on without our card and draw the note on your page. It is your DOM, so it can hold anything — a video included — and use your fonts. Each event carries:

{
  source: 'jewelshop', kind: 'part-click',
  part: { id: 'band', label: 'Band', role: 'metal', node: 'Ring', nodeRole: 'metal',
          model: 'https://…/piece.glb', data: { /* manifest data, verbatim */ } } | null,
  anchor:  { x, y } | null,  // the part's centre
  bounds:  { left, top, right, bottom } | null,  // the part's on-screen box
  pointer: { x, y } | null,  // where the pointer was
  pointerType: 'mouse' | 'pen' | 'touch'
}

anchor, bounds and pointer are CSS pixels inside the iframe; add the iframe's getBoundingClientRect() to place something on your page. Place a note just outside bounds (e.g. at bounds.right) so it doesn't cover the stone.

const frame = document.querySelector('iframe#jewel');
const send = (msg) => frame.contentWindow.postMessage({ source: 'jewelshop', ...msg }, 'https://jewelshop.ai');

window.addEventListener('message', (e) => {
  if (e.source !== frame.contentWindow || e.data?.source !== 'jewelshop') return;
  const { kind, part, pointer } = e.data;
  if (kind === 'ready' && e.data.rendererLoaded) send({ kind: 'set-picking', enabled: true, card: false });
  if (kind === 'part-click' && part) {
    const r = frame.getBoundingClientRect();
    showNote(part, r.left + pointer.x, r.top + pointer.y); // your UI, e.g. a <template> per part.id
  }
  if (kind === 'part-dismiss' || (kind === 'part-click' && !part)) hideNote();
});
// your note's close button: hideNote(); send({ kind: 'select-part', id: null });

Good to know

5. Fullscreen

The iframe already allows fullscreen. Wrap it and fullscreen the wrapper so your own buttons stay on top:

<div id="jc-wrap" style="position:relative">
  <iframe id="jc" src="https://jewelshop.ai/demo/?embed=1" allowfullscreen></iframe>
  <button style="position:absolute;top:12px;right:12px"
          onclick="document.getElementById('jc-wrap').requestFullscreen()">
    Fullscreen
  </button>
</div>

6. Example — "View in 3D" over a photo gallery

Show product photos; the 3D viewer loads hidden and is paused once it is ready, so it uses no GPU until the shopper asks for it. On click, swap it in and resume.

<div id="gallery"><!-- your photos --></div>
<iframe id="jc" src="https://jewelshop.ai/demo/?embed=1"
        style="display:none" allowfullscreen></iframe>
<button id="view3d">View in 3D</button>

<script>
  const ORIGIN = 'https://jewelshop.ai';
  const frame = document.getElementById('jc');
  const send = (kind, extra = {}) =>
    frame.contentWindow.postMessage({ source: 'jewelshop', kind, ...extra }, ORIGIN);

  let ready = false;
  window.addEventListener('message', (e) => {
    if (e.origin !== ORIGIN || e.data?.source !== 'jewelshop') return;
    if (e.data.kind === 'ready' && e.data.rendererLoaded) {
      ready = true;
      if (frame.style.display === 'none') send('pause');   // hidden: park it
    }
  });

  document.getElementById('view3d').onclick = () => {
    document.getElementById('gallery').style.display = 'none';
    frame.style.display = 'block';
    if (ready) send('resume');   // not ready yet: it starts running anyway
  };

  // Your own gem buttons, for example:
  document.querySelectorAll('[data-gem]').forEach((b) =>
    b.addEventListener('click', () =>
      send('apply-preset', { preset: { gem: b.dataset.gem } })));
</script>
Commands only change what the viewer shows and whether it renders — they never touch billing or licensing — so they are accepted from any page that frames the viewer. Only that page counts: a command from any other window (another frame on the same page, or a page that opened the viewer in a tab of its own) is ignored. On a licensed Shopify store the theme block handles the shop identity and the license check automatically; you only need the postMessage layer for custom UI.