For developers
Embed API
Make your Luxa viewer talk to your shop page. Listen for material and variant changes, drive the viewer from your own buttons, and react to hotspot clicks. All through the browser's own postMessage: no server, no SDK, no build step.
Overview
The viewer lives in an iframe on your product page. By default the iframe and the page around it can't talk to each other (the browser keeps them apart). The Embed API opens a two-way channel with the standard window.postMessage:
- Events go out: the viewer tells your page when the customer picks a material, opens a hotspot, or the model has finished loading.
- Commands come in: your page asks the viewer to switch material, change variant, move the camera, or play an animation.
Both are routed by stable publicName identifiers you set on widgets and items in the Luxa editor, never by internal database IDs that could change. Each viewer keeps its own allowlist of pages allowed to talk to it; messages from any other origin are ignored without a word.
Quick start
The smallest working embed. Put it in any HTML page, replace YOUR_VIEWER_ID, and you will see luxa:material-changed in the console every time a swatch is clicked inside the viewer.
<iframe
id="luxa"
src="https://luxa3d.com/viewer/YOUR_VIEWER_ID"
style="width:100%;aspect-ratio:4/5;border:0"
allow="camera; xr-spatial-tracking; fullscreen"
></iframe>
<script>
window.addEventListener('message', (e) => {
if (e.origin !== 'https://luxa3d.com') return;
if (e.data.type === 'luxa:material-changed') {
console.log('User picked', e.data.item.publicName, e.data.item.metadata);
}
});
</script>Or skip the code. On the Custom plan the editor's Embed → Open snippet builder writes the whole integration for you, wired to your widgets: Shopify Liquid, Wix, Squarespace, WordPress or plain HTML. Buy buttons, external swatches, WhatsApp, add to cart: pick what you need, copy, paste.
Setup in 3 steps
1. Give a
publicNameto each widget and item you want to exposeIn the editor's right panel, open the "Embed API" block under a widget or an item (swatch, variant, hotspot) and type a stable name: lowercase letters, numbers and dashes, like
color-pickerfor the widget andleather-strapfor one of its swatches. Anything without a publicName stays invisible to the API.2. Allow your page's origin
Open the Embed dialog from the editor's top bar. Under Allowed parent origins, add the address of every site that will host the viewer, for example
https://yourbrand.com. Until at least one origin is there, the API is off: no events go out and no commands are accepted.3. Listen on your page
Add
window.addEventListener('message', …), checkevent.origin === 'https://luxa3d.com', and act on the messages. The events and the two full examples below show how.
Events
From the viewer to your page. Every event is an object with a type in the "luxa:<name>" namespace and a payload of its own. Always check event.origin before trusting it.
luxa:ready
Sent once, when the 3D model has loaded and the viewer can be used. The moment to enable a buy button or remove a placeholder. It lists every named widget and, in initialSelection, the swatch each one starts on.
{
"type": "luxa:ready",
"widgets": [
{
"publicName": "color-picker",
"type": "material_switcher",
"metadata": null,
"items": [
{ "publicName": "leather-strap", "label": "Leather", "metadata": { "price": 199, "sku": "LTH-01" } },
{ "publicName": "metal-strap", "label": "Metal", "metadata": { "price": 249, "sku": "MTL-01" } }
]
}
],
"initialSelection": {
"color-picker": { "publicName": "leather-strap", "label": "Leather", "metadata": { "price": 199, "sku": "LTH-01" } }
}
}luxa:material-changed
Sent when the customer picks a swatch in a material_switcher widget. Only for widgets or swatches that have a publicName.
{
"type": "luxa:material-changed",
"widget": { "publicName": "color-picker", "metadata": null },
"item": { "publicName": "leather-strap", "label": "Leather", "metadata": { "price": 199, "sku": "LTH-01" } }
}luxa:variant-changed
Sent when the customer picks a model variant (a saved scene variation). Same shape as material-changed.
{
"type": "luxa:variant-changed",
"widget": { "publicName": "configurator", "metadata": null },
"item": { "publicName": "ultra-titanium", "label": "Ultra · Titanium", "metadata": { "price": 899 } }
}luxa:hotspot-clicked
Sent when the customer clicks an annotation hotspot. Carries the hotspot's position in the 3D scene, in case you want to place your own interface next to it.
{
"type": "luxa:hotspot-clicked",
"widget": { "publicName": "info-pins", "metadata": null },
"item": {
"publicName": "crown-detail",
"label": "Crown",
"metadata": { "section": "specs" },
"position": [0.12, 0.05, -0.08]
}
}Commands
From your page to the viewer, with iframe.contentWindow.postMessage(message, 'https://luxa3d.com'). Always pass the Luxa origin as the second argument, never "*", so the message goes nowhere else. Commands find their target by publicName.
luxa:set-material
Switches the active swatch of a material_switcher widget: the widget by its publicName, the swatch by its own.
{
"type": "luxa:set-material",
"widget": "color-picker",
"item": "leather-strap"
}luxa:set-variant
Switches the active variant of a model_variant widget.
{
"type": "luxa:set-variant",
"widget": "configurator",
"item": "ultra-titanium"
}luxa:set-camera
Moves to one of the views of a camera_tour widget.
{
"type": "luxa:set-camera",
"widget": "tour",
"item": "front-three-quarter"
}luxa:play-animation
Plays an animation clip. 'modelId::clipName' picks a model in a scene with several; a bare 'clipName' plays on the main one.
{
"type": "luxa:play-animation",
"clipName": "open-lid"
}luxa:dress
Gives the whole model a material you define, rather than one the editor configured: colour, metalness, roughness and opacity, plus any texture maps by URL. Send null to take it off again.
{
"type": "luxa:dress",
"material": { "color": "#8a5a3c", "metalness": 0, "roughness": 0.6, "opacity": 1 }
}luxa:pause / luxa:resume
Stops the viewer's rendering and starts it again. The last frame stays on screen. For a page that shows the viewer next to other things and wants it still while it is not being looked at.
{ "type": "luxa:pause" }
{ "type": "luxa:resume" }Security model
An allowlist per viewer. Each viewer has its own list of pages allowed to talk to it, managed in the editor's Embed dialog. Events go only to those origins, and commands are accepted only when event.origin is on the list. An empty list means the API is off, with no exceptions.
Only the page around it. Commands must also come from window.parent. Other iframes, workers and unrelated tabs cannot drive your viewer, even with the right origin.
Check event.origin on your side too. The viewer always speaks from https://luxa3d.com, or from your own domain if you have one set; discard any other origin in your handler.
Nothing on our servers. postMessage happens entirely in the browser: no calls to Luxa, no rate limits, no cost per event. Access is set by plan in the editor; once it is on, using it costs nothing.
Shopify example
A snippet for a Shopify product page. It writes the chosen material's price on the buy button, and sends "Add to cart" to the matching Shopify variant through the standard /cart/add.js.
{%- comment -%}
Drop this inside your product.liquid (or a section snippet).
Replace VIEWER_ID with the published Luxa viewer ID.
Tested on Dawn 14.x; works on most Shopify 2.0 themes.
{%- endcomment -%}
<div class="luxa-product-3d">
<iframe
id="luxa-viewer"
src="https://luxa3d.com/viewer/VIEWER_ID"
style="width: 100%; aspect-ratio: 4/5; border: 0;"
allow="camera; xr-spatial-tracking; fullscreen"
loading="lazy"
></iframe>
<button id="luxa-buy" class="button button--primary" disabled>
Loading…
</button>
</div>
<script>
(function () {
const LUXA_ORIGIN = 'https://luxa3d.com';
const buyBtn = document.getElementById('luxa-buy');
const viewer = document.getElementById('luxa-viewer');
// Variant ID lookup: map publicName from Luxa → Shopify variant ID.
// The merchant maintains this on their side once; everything else is automatic.
const VARIANT_ID_BY_PUBLIC_NAME = {
'leather-strap': {{ product.variants[0].id }},
'metal-strap': {{ product.variants[1].id }},
'fabric-strap': {{ product.variants[2].id }},
};
let currentVariantId = null;
window.addEventListener('message', function (event) {
if (event.origin !== LUXA_ORIGIN) return;
const msg = event.data;
if (!msg || typeof msg.type !== 'string') return;
if (msg.type === 'luxa:ready') {
buyBtn.disabled = false;
buyBtn.textContent = 'Add to cart';
}
if (msg.type === 'luxa:material-changed' && msg.item) {
const meta = msg.item.metadata || {};
const price = meta.price ? '€' + meta.price : '';
buyBtn.textContent = 'Add to cart' + (price ? ' · ' + price : '');
currentVariantId = VARIANT_ID_BY_PUBLIC_NAME[msg.item.publicName] || null;
}
});
buyBtn.addEventListener('click', function () {
if (!currentVariantId) return;
fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: currentVariantId, quantity: 1 }),
}).then(function () { window.location.href = '/cart'; });
});
})();
</script>HTML example
A complete HTML page. It listens for material-changed to update the price and SKU, and has three swatch buttons of its own that drive the viewer with set-material. A starting point for WordPress, Webflow or a static site.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>My Product · 3D Viewer</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
.stage { position: relative; aspect-ratio: 4/5; border-radius: 12px; overflow: hidden; }
.stage iframe { width: 100%; height: 100%; border: 0; }
.row { display: flex; gap: .5rem; align-items: center; margin-top: 1rem; }
button { padding: .75rem 1.25rem; border-radius: 8px; border: 0; background: #111; color: #fff; cursor: pointer; }
button:disabled { opacity: .4; cursor: not-allowed; }
.price { font-weight: 600; font-size: 1.25rem; }
.swatch { padding: .4rem .75rem; border-radius: 999px; border: 1px solid #ccc; background: #fff; cursor: pointer; }
</style>
</head>
<body>
<h1>Wristwatch in 3D</h1>
<div class="stage">
<iframe id="viewer" src="https://luxa3d.com/viewer/VIEWER_ID"
allow="camera; xr-spatial-tracking; fullscreen"></iframe>
</div>
<div class="row">
<span class="price" id="price">€…</span>
<span id="sku" style="color:#888"></span>
</div>
<div class="row">
<button class="swatch" data-pn="leather-strap">Leather</button>
<button class="swatch" data-pn="metal-strap">Metal</button>
<button class="swatch" data-pn="fabric-strap">Fabric</button>
</div>
<div class="row">
<button id="buy" disabled>Add to cart</button>
</div>
<script>
const LUXA_ORIGIN = 'https://luxa3d.com';
const viewer = document.getElementById('viewer');
const priceEl = document.getElementById('price');
const skuEl = document.getElementById('sku');
const buyBtn = document.getElementById('buy');
// Listen for events from the viewer.
window.addEventListener('message', (event) => {
if (event.origin !== LUXA_ORIGIN) return;
const msg = event.data;
if (!msg?.type?.startsWith?.('luxa:')) return;
if (msg.type === 'luxa:ready') {
buyBtn.disabled = false;
}
if (msg.type === 'luxa:material-changed' && msg.item?.metadata) {
priceEl.textContent = '€' + msg.item.metadata.price;
skuEl.textContent = '· ' + msg.item.metadata.sku;
}
});
// Drive the viewer from outside buttons.
document.querySelectorAll('.swatch').forEach((btn) => {
btn.addEventListener('click', () => {
viewer.contentWindow.postMessage({
type: 'luxa:set-material',
widget: 'color-picker',
item: btn.dataset.pn,
}, LUXA_ORIGIN);
});
});
buyBtn.addEventListener('click', () => {
// Plug in your cart logic here.
alert('Buy: ' + skuEl.textContent);
});
</script>
</body>
</html>Roadmap
luxa:open-ar: start AR from a button on your page.- Wildcard origins, like
https://*.yourbrand.com. - Webhooks: the same events sent to your server, for analytics and orders.
- A small SDK on a CDN:
Luxa.on('material:changed', …). luxa:viewer-error, so your page can fall back gracefully on a bad connection.
Need another command or event?
On the Custom plan, feature requests are part of the contract.