Embed API
Embed the 3D configurator on your storefront and drive it from your own UI.
There are two ways to use the configurator:
- 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.
- 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:
- The
jewelshop.modelmetafield — 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. - The product's own 3D model — a
.glbyou uploaded to the product in the Shopify admin (Products → your product → Add media). Nothing to configure at all. - 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.
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.
2. Embedding the iframe
<iframe
src="https://jewelshop.ai/demo/?embed=1"
width="100%"
height="550"
style="border:none;"
allowfullscreen></iframe>
URL parameters
| Param | Values | Default | Meaning |
|---|---|---|---|
embed | 1 | — | Required. Runs the chromeless embed (no landing page, no intro). |
controls | 0 / 1 | 0 | 1 shows the built-in gem/metal/ring UI. Omit / 0 = clean viewer you drive yourself. |
gem | diamond, ruby, emerald, blue_topaz, amber | diamond | Initial gemstone. |
metal | silver, gold, rose_gold | silver | Initial metal. |
ring | 0, 1, 2 | 0 | Initial ring style (demo set only — ignored when model is given). |
model | URL of a .glb | — | Show your own model instead of our demo rings. See below. |
bg | RRGGBB (hex, no #) | — | Loading-screen background colour. |
shop | your-store.myshopify.com | — | License identity. The Shopify theme block sets this automatically; direct integrators need a license (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.
Showing your own model
https://jewelshop.ai/demo/?embed=1&model=https://cdn.example.com/rings/solitaire.glb
The URL must be https, and the host must send CORS headers allowing jewelshop.ai to fetch it — otherwise the load fails and you receive a model-error event.
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": "*" }
gem_nodes— nodes rendered as gemstones, with ray-traced refraction.metal_nodes— nodes whose colour follows the metal setting;"*"means every remaining material.
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.
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)
| kind | Payload | Effect |
|---|---|---|
apply-preset | { preset: { gem, metal, ring } } | Change gem/metal/ring (any subset). Replies preset-applied. |
set-camera | { position:[x,y,z], target:[x,y,z], duration?, ease? } | Move the camera to a fixed angle. Replies camera-set. |
set-model | { url: 'https://…/piece.glb', roles? } | Swap the displayed model — e.g. when the shopper picks a different product. Replies model-set immediately, then model-loaded or model-error. |
set-controls | { visible: true | false } | Show/hide the built-in UI at runtime. Replies controls-set. |
pause | — | Stop rendering + input (park a hidden viewer). Replies paused. |
resume | — | Resume rendering + input. Replies resumed. |
embed-init | { preset? } | Only needed if you embed WITHOUT ?embed=1; boots embed mode. |
frame.postMessage(
{ source: 'jewelshop', kind: 'apply-preset', preset: { gem: 'ruby', metal: 'gold' } },
'*'
);
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'] }
}, '*');
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.
- omitted — the viewer fetches the sidecar next to the model. The normal case.
- an object — used verbatim, no fetch.
null— no roles at all; don't even look for a sidecar. The model renders exactly as authored.
Anything else (a string, an array) 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)
| kind | When |
|---|---|
ready | Viewer is listening (rendererLoaded:false), then again when the 3D scene is loaded (rendererLoaded:true). |
preset-applied | After apply-preset. Carries the resulting { gem, metal, ring }. |
camera-set | After set-camera. |
model-set | The set-model URL was accepted. Carries { url }. Loading has only just started. |
model-loaded | The model finished loading and is on screen. Carries { url }. |
model-error | The model could not be shown — bad URL, unusable roles, 404, or the host refused the cross-origin fetch. Carries { url, reason }. |
controls-set | After set-controls. Carries { visible }. |
paused / resumed | After pause / resume. |
close-request | The user pressed Escape inside the viewer. |
4. 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>
5. Example — "View in 3D" over a photo gallery
Show product photos; on click, swap in the 3D viewer and resume it. Hide it again and pause to save resources.
<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 frame = document.getElementById('jc');
const send = (kind, extra = {}) =>
frame.contentWindow.postMessage({ source: 'jewelshop', kind, ...extra }, '*');
document.getElementById('view3d').onclick = () => {
document.getElementById('gallery').style.display = 'none';
frame.style.display = 'block';
send('resume');
};
// Your own gem buttons, for example:
document.querySelectorAll('[data-gem]').forEach((b) =>
b.addEventListener('click', () =>
send('apply-preset', { preset: { gem: b.dataset.gem } })));
</script>
shop identity and the license check automatically; you only need the postMessage layer for custom UI.