Jewelry Configurator

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).

Ring try-on. The block setting Show ring try-on adds a Try on button over the viewer: shoppers photograph the back of their hand (or upload a photo) and see the ring on their finger. It adds &tryon=1 and allow="camera" to the iframe. The photo is processed in the shopper's browser and never uploaded — not to us, not to the store (section 8).

Two-tone. Split the metal into parts in the same metafield (say head and band) and a theme script can give each its own metal when the shopper picks a two-tone option (section 5).

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 top bar).
controls0 / 101 shows our studio controls (metal, stones, components, size, engraving, specs, and a feature tour). 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).
sizeEU 44–70as madeThe ring shown in that size (as set-ring-size). Models without a measured size ignore it.
settingsilver, gold, rose_gold—Two-tone: the setting (head part) in this metal.
variantsslot:variant pairs, comma-separated, e.g. halo:off,accents:offthe model's defaultsSwapped components (as set-variant). A slot or variant the model doesn't have is skipped.
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.
lightstudio-round, studio-stones, showroom, studio, studio-camerastudio-roundstudio-stones lights the stones like a jewellery photo studio (defined softboxes: calm coloured stones, white diamonds) and the metal with a photographed studio. showroom is the older look: a darker map for the stones, which then want a brighter gem light. studio lights the metal with the jewellery studio too: darker, so metal colours want brightening for it. studio-camera is studio-stones with the photographer's side of the stones' studio dark (the camera and its black card, which a product shot mirrors in a stone's table): deeper coloured stones, a darker heart face-up. studio-round, the default, is studio-stones with small lights all round the stones' studio: glints and fire from every side. Stone colours you send with set-part-material are tuned for the default.
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.
tryon1off1 shows a Try on button over the viewer (section 8). Give the iframe allow="camera".
langen, ukenThe language of the configurator's texts: try-on, its phone QR, the Try on button and the part card. Another language, or a regional tag (uk-UA), takes its base language or English. The Shopify block sets it to the storefront's language. The studio controls (controls=1) are English.
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 .glb, or a .gltf whose buffers are embedded as data URIs (a buffer in a side file is refused), of at most 32 MiB (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.

Compressed models load faster, and we recommend them: meshopt compression and quantized geometry, which is what glTF Transform and gltfpack produce. Draco compression (Blender's “Compression” export option) is not supported yet, so export without it.

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.
set-part-material{ id, material: { color?, roughness?, metalness?, ior?, dispersion?, softness?, axisColor?, axis?, haze?, inclusions? } | null, model? }Give one part its own metal, e.g. a white-gold setting on a yellow-gold band, or a stone its own colour and optics (section 5). null clears it; material is required. Always replies: part-material-set or part-material-error.
set-ring-size{ size: number | string | { system, value }, model?: string }Show the ring in the shopper's size (section 5). Always replies: ring-size-set or ring-size-error.
set-pose{ pose: 'default' | 'upside-down' | 'side', model?: string, frame?: boolean, duration?: number }Lay the piece on the floor as made, upside down or on its side, turning it over duration seconds (section 5). Always replies: pose-set or pose-error.
get-dimensions{ model?: string }Ask for the piece's measured size in mm and its ring size (section 5). Always replies: dimensions, or dimensions-error (e.g. a bad model URL).
set-variant{ slot: string, variant: string, model?: string }Show one alternative of an interchangeable part, e.g. the halo off or another head (section 5). Always replies: variant-set or variant-error.
try-on-open{ hand?: 'left' | 'right', finger?: 'index' | 'middle' | 'ring' | 'little' }Open ring try-on for the piece on screen, as configured (section 8). hand preselects which hand the shopper photographs, finger which finger the ring goes on (default ring); any other value is left out. Answered by try-on-opened, or try-on-error. Ignored while try-on is open or still loading.
try-on-close—Close try-on. Answered by try-on-closed. Sent while try-on is still loading, it cancels the open: no try-on-opened follows.
set-page-url{ url: string }The page the viewer is on, for try-on's "use your phone" QR code (section 8). Must be a page of the origin the message comes from, over HTTPS. Replies page-url-set with { accepted }.
apply-link{ query: string }The configurator's part of a link to your page — its jc_ parameters, prefix dropped (gem=ruby&size=60&open=try-on). Applied once the scene is up; open=try-on then opens try-on. Replies link-accepted. Section 8.
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, with these exceptions: set-model answers a URL it refuses with model-error, and set-part-material, set-variant, set-ring-size and get-dimensions always answer (section 5). 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, dimensions } — dimensions is the piece's measured size (section 5), or null if it was never measured.
dimensionsAfter get-dimensions. Carries { model, dimensions }; dimensions is null for a model never measured, or one not loaded (yet) (section 5).
dimensions-errorget-dimensions could not be answered. Carries { model, reason, message } (section 5): invalid-args (a bad model URL, model exactly as you sent it), viewer-unavailable, read-failed.
ring-size-setAfter set-ring-size. Carries { size, model, pending, dimensions }; pending: true means the model you named is still loading and the size applies when it arrives (dimensions is then null); false, that it is shown already (section 5).
ring-size-errorset-ring-size was refused. Carries { size, model, reason, message } (section 5): message says what was wrong, for your logs; model is the URL as model-set would report it (null when you sent none), except on invalid-args, where size and model are exactly as you sent them.
pose-setAfter set-pose. Carries { pose, model, pending }; pending: true means the model you named is still loading and lies so when it arrives (section 5).
pose-errorset-pose was refused. Carries { pose, model, reason, message } (section 5).
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 (not while try-on or another of its dialogs is open — Escape closes that first).
try-on-openedTry-on opened. Carries { model } (without any query string).
try-on-closedTry-on closed — by the shopper, by try-on-close, or by pause.
try-on-resultThe ring is on the shopper's photo. Carries { model } only — never the photo or anything measured from it.
try-on-errorTry-on could not load or failed. Carries { reason }: load (its code could not be fetched), model (no model on screen to try on, e.g. after a model-error, or the ring's model could not be prepared), init (try-on could not start: the hand tracker, its model or the ring's measurement), camera (the camera could not be started: none, refused, or in use), photo (the chosen photo could not be read), result (the ring could not be drawn on the photo). A photo try-on can't use (no hand found, the palm side) is not an error: try-on asks the shopper for another.
page-url-setAfter set-page-url. { accepted: false } when the URL isn't a page of your origin over HTTPS.
link-acceptedAfter apply-link.
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).
part-material-setAfter set-part-material. Carries { id, material, model, pending }; pending: true means the model you named is still loading and the material applies when it arrives; false, that it is on the model already (section 5).
part-material-errorset-part-material was refused. Carries { id, model, reason, message } (section 5): message says what was wrong, for your logs; model is the URL as model-set would report it (null when you sent none), except on invalid-args, where id and model are exactly as you sent them.
variant-setAfter set-variant. Carries { slot, variant, model, pending }; pending: true means the model you named is still loading and the variant shows when it arrives; false, that it is showing already (section 5).
variant-errorset-variant was refused. Carries { slot, variant, model, reason, message } (section 5): message says what was wrong, for your logs; model is the URL as model-set would report it (null when you sent none), except on invalid-args, where slot, variant and model are exactly as you sent them.

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. Two-tone: a different metal per part

Give one part of the piece its own metal: white-gold prongs on a yellow-gold band, a rose-gold setting, a sapphire centre stone among diamonds. The parts are the same parts list as in section 4, so first split the metal into the zones you want to colour separately:

"parts": [
  { "id": "head", "nodes": ["Prong_1", "Prong_2", "Prong_3", "Prong_4", "Basket"], "label": "Setting" },
  { "id": "band", "nodes": "metal", "label": "Band" }
]

"metal" gives the band every metal node the setting didn't claim. Order matters: each node goes to the first part that lists it, so list the setting before the catch-all. The other way round, the band takes every metal node, head gets none, and a material for head is accepted but changes nothing. Then:

send({ kind: 'apply-preset', preset: { metal: 'gold' } });   // the piece's metal
send({ kind: 'set-part-material', id: 'head',
       material: { color: [0.47, 0.47, 0.47] } });           // white-gold setting
send({ kind: 'set-part-material', id: 'head', material: null }); // back to the piece's metal
On Shopify the parts come from the jewelshop.model_roles metafield. To offer two-tone as a product option, have a theme script send set-part-material when the shopper picks it (section 3).

Interchangeable parts (variants)

A model can also carry alternatives for a part of the piece — a halo or none, a solitaire or a three-stone row, two head styles — all in the one .glb, laid out in place. List them in the manifest's slots:

"slots": [
  { "id": "halo", "label": "Halo", "default": "on",
    "variants": [
      { "id": "on",  "label": "With halo", "nodes": ["Halo_Stone_*", "Halo_Frame"] },
      { "id": "off", "label": "No halo",   "nodes": [] }
    ] }
]
send({ kind: 'set-variant', slot: 'halo', variant: 'off' });

Size in millimetres

Once a model is measured (node tools/measure_ring.mjs ring.glb --size "US 7", see docs/model_format.md), the viewer knows its real size and hands it to your page — for the spec block next to the viewer:

send({ kind: 'get-dimensions' });
// → { kind: 'dimensions', model: null, dimensions: {
//      innerDiameterMm: 17.32, madeAtMm: 17.32, resizable: true,
//      bandWidthMm: 2.1, bandThicknessMm: 1.6,
//      ringSize: { mm: 17.5, EU: 54, US: 7, UK: 'N½' },
//      stones: [ { part: 'center-stone', count: 1, sizeMm: [6.5, 6.5, 3.9] } ] } }

model-loaded carries the same object. get-dimensions always answers: dimensions is null for a model nobody measured, or one not loaded (yet) — a .glb has no trustworthy scale of its own. A malformed model URL is not taken for an unmeasured model: it gets dimensions-error with reason: 'invalid-args' and model exactly as you sent it. Its other reasons are viewer-unavailable (the viewer could not start) and read-failed (message says why).

Show the piece in the shopper's size — the band changes, the stones and the head keep their real size, so a small size makes the stone look as big as it will on the finger:

send({ kind: 'set-ring-size', size: 'US 6' });   // or 'US 6½', 'EU 52', 'UK L½', '16.5 mm', 16.5, { system: 'us', value: 6 }
// → { kind: 'ring-size-set', size: 'US 6', model: null, pending: false,
//      dimensions: { innerDiameterMm: 16.51, madeAtMm: 17.32, resizable: true, … } }

Poses: upside down, on its side

Show the piece lying another way on the floor: default (as the model was made), upside-down (a half turn: a ring's head at the bottom) or side (a quarter turn: a ring standing up lies flat; one made lying down stands up on its band). The turn is about the horizontal axis the piece is longest along, and the piece is set down on the floor again with its shadow under it.

send({ kind: 'set-pose', pose: 'side' });
// → { kind: 'pose-set', pose: 'side', model: null, pending: false }
send({ kind: 'set-pose', pose: 'default', duration: 0 }); // at once

6. 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>

7. 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>

8. Ring try-on

The shopper photographs the back of their hand and sees the ring — the one on screen, with its stone, metal, engraving and part colours — on their finger, sized and placed from the photo itself.

<iframe id="jc" src="https://jewelshop.ai/demo/?embed=1&tryon=1"
        allow="camera" allowfullscreen></iframe>
document.getElementById('try-on').onclick = () => send('try-on-open', { hand: 'left' });
addEventListener('message', (e) => {
  if (e.data?.source !== 'jewelshop') return;
  if (e.data.kind === 'try-on-result') analytics.track('ring tried on', { model: e.data.model });
});

On the shopper's phone (QR code)

On a computer, try-on can show a QR code: the shopper scans it and the same product page opens on their phone, with the ring as configured and try-on already open — for a computer with no webcam, or a phone camera that is simply better. The code leads back to your page, with the configuration in parameters that start with jc_ (…/products/ring?variant=42&jc_gem=ruby&jc_size=60&jc_open=try-on). Two things need a few lines on your page, so the viewer shows the code only once you add them:

  1. tell the viewer which page it is on (set-page-url) — an iframe can't read its parent's address;
  2. on the phone, hand the jc_ parameters to the viewer (apply-link) and drop them from the address bar.

The Shopify theme block does both when Show ring try-on is on. Anywhere else, add this after the iframe:

<script>
  (function () {
    var frame = document.getElementById('jc');
    var page = new URL(location.href), link = new URLSearchParams();
    Array.from(page.searchParams.keys()).forEach(function (key) {
      if (key.indexOf('jc_') !== 0) return;
      link.set(key.slice(3), page.searchParams.get(key));
      page.searchParams.delete(key);
    });
    var told = false;
    window.addEventListener('message', function (e) {
      var d = e.data;
      if (told || e.source !== frame.contentWindow || !d || d.source !== 'jewelshop' || d.kind !== 'ready') return;
      told = true;
      var send = function (msg) { msg.source = 'jewelshop'; frame.contentWindow.postMessage(msg, e.origin); };
      send({ kind: 'set-page-url', url: page.href });
      if (link.toString()) {
        send({ kind: 'apply-link', query: link.toString() });
        history.replaceState(history.state, '', page.href);
      }
    });
  })();
</script>

The viewer takes only a page of the origin the message comes from, over HTTPS, so no page can put another site's address in the code. The link carries the stone, metal, ring and its look (size, setting, variants) — the same values as the URL parameters in section 2 — never the shopper's photo, which stays on the computer. Your own parameters (variant=42) are kept, so the phone opens the same variant.

Privacy. The photo is taken and processed in the shopper's browser — the hand tracking runs there too — and is never uploaded. Events tell your page only that try-on opened, closed, produced a result or failed, never the photo or a measurement.

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.