Build your own UI
Headless mode hides Togenar’s built-in option panel while the configuration engine and SDK keep running. Your design system renders the swatches; the SDK drives the model.
1. Go headless
Section titled “1. Go headless”<togenar-embed id="tg" project="…" configurator picker="off" controls="off"></togenar-embed>Two switches, two surfaces:
| Switch | Turns off | You draw instead |
|---|---|---|
picker="off" |
The option panel — swatches, part sections, summary. (mode="headless" is equivalent.) |
Your swatches, driven with getOptions() + select() |
controls="off" |
The whole control surface — AR button, snapshot button, ruler icon, reset-view button. | Your buttons, driven with the commands in step 6 |
Take either on its own: a page that wants Togenar’s panel but its own AR button sets only
controls="off". controls is one switch for the entire surface — named after
<video controls> — so a control added to the viewer later never needs a new attribute here, and
you never end up with two AR buttons stacked on the same corner.
2. Enumerate the options
Section titled “2. Enumerate the options”getOptions() returns every part with all of its variants, ready to render:
const parts = await tg.getOptions();/* [{ partId, partKey: 'body', label: 'Body', defaultVariantId, inPicker, // does the published panel offer this part to visitors? link, // { role: 'driver' | 'driven', … } when it belongs to a link group variants: [{ variantId, handle: 'walnut', label: 'Walnut', sku: 'OSLO-BODY-WALNUT', swatch, // color hex, when the variant defines one swatchImage, // swatch texture URL, when it defines one modelName, isDefault, available }, …] }, …] */Render these in your own components — swatch / swatchImage give you native-looking
pickers without re-deriving anything.
Render the options, not the parts. A product with coordinated finishes has one part the
shopper chooses and many that follow it — offering the followers invites contradicting
picks. inPicker already carries that decision from the panel, including the author’s
per-part exceptions, so filter on it rather than hardcoding part keys:
const options = parts.filter((p) => p.inPicker);3. Drive the model
Section titled “3. Drive the model”Selection keys are the same handles getOptions() returned (they’re also what the
share URL uses after ?part=):
const { ok, selection } = await tg.select('body', 'walnut');select() resolves after the model swap settles — so you can update price, cart state
or imagery exactly when the shopper sees the change. It rejects on unknown handles.
reset() returns the configuration to the published default.
4. Keep it in sync with your catalogue
Section titled “4. Keep it in sync with your catalogue”// Live stock — false marks a variant out of stock:await tg.setAvailability({ body: { walnut: false, oak: true } });
// Price display — keyed by the same SKUs the events report:await tg.setPrices({ currency: 'EUR', locale: 'de-DE', items: { 'OSLO-BODY-WALNUT': 129.9 }, total: 259.8,});The viewer never computes prices — your page stays the single source of pricing truth. See Prices in the configurator.
5. Listen for changes
Section titled “5. Listen for changes”Even in headless mode the engine emits togenar:configurator:selection_change on load and
on every select() — treat it as your single source of selection state instead of
mirroring state in your own code.
6. Draw your own controls
Section titled “6. Draw your own controls”With controls="off" the viewer renders no buttons of its own, and each one has a command:
| Your button | Command |
|---|---|
| View in your room | enterAR() — call it from your own click handler; WebXR needs an in-page gesture |
| Snapshot | getSnapshot() — resolves with a PNG data URL |
| Measurements | setDimensions(on?) — omit the argument to flip it |
| Reset view | resetCamera() |
A button with nothing behind it is worse than no button: measurements can be switched off for the project, AR is not launchable for every configuration, and “reset view” means nothing while the camera is still in its opening framing. So the viewer reports what it would have offered, and your buttons follow it:
tg.addEventListener('togenar:controls', (e) => { const { ar, photo, dimensions, resetView } = e.detail; arBtn.hidden = !ar; snapshotBtn.hidden = !photo; measureBtn.hidden = !dimensions.enabled; measureBtn.setAttribute('aria-pressed', String(dimensions.on)); resetBtn.hidden = !resetView;});getControls() returns the same shape synchronously, for a first paint
before any event has fired. The event fires on every change — a shopper dragging the model, an
option swap that changes AR availability, the overlay being toggled from your own button.
