Your own interface
Hides the built-in configurator panel so you can draw the swatches yourself and set colours through the API.
ui="none"One tag embeds the viewer. Everything beyond that is optional.
<script src="https://3d-lion.com/viewer/loader.js" defer></script>
<glb-viewer
share-token="prj_your_token"
style="display: block; width: 100%; height: min(78svh, 760px)">
</glb-viewer>One script and one tag. There is no second way of doing this, so nothing here can be the wrong choice.
A plain element on your page. It opens the model in a frame of its own and hands you the API right on the element, so your page carries none of the 3D and still drives all of it.
<script src="https://3d-lion.com/viewer/loader.js" defer></script>
<glb-viewer
share-token="prj_your_token"
style="display: block; width: 100%; height: min(78svh, 760px)">
</glb-viewer>The script writes the same tag where its own line stands: between paragraphs, in a column — wherever you dropped it. Everything else the project says about itself, so the line carries nothing but its token. Being short costs one thing: a viewer written this way appears after the page is parsed, so the model starts loading tens of milliseconds later than one you wrote by hand.
<script
src="https://3d-lion.com/viewer/loader.js"
data-token="prj_your_token" defer></script>Size and caption are data-height and data-title; the exception for a single page is data-tour. A tag in <head> has no place on the page: put an empty <div data-glb-viewer></div> where the model should stand.
That's it. Colour zones, the camera tour and the controls all work — you write no JavaScript.
Your project token (prj_…) is on the project page in your dashboard. Nothing else is needed — your domain is checked on the server.
The things people ask for most are one attribute each. Drop the line inside the tag.
Hides the built-in configurator panel so you can draw the swatches yourself and set colours through the API.
ui="none"The model appears already configured — no flash of the original colours.
colors="body:#111111,seats:#d8c9a6"The camera walks the route you recorded in the editor as soon as the model arrives — like a film in a shop window.
tour="auto"The reader winds the camera along the route themselves — forward, back, or stopped halfway. The model holds the middle of the window while they do, and the page still scrolls as it always did: carry on a little further and it moves on. No section has to be set aside for it — the viewer takes its own room. On a phone the button is offered instead.
tour="scroll"First-person mode — for interiors, cabins and rooms.
camera-mode="walk"The model rotates gently until a visitor touches it.
auto-rotateBy default picking a zone flies to its saved view. This hands that decision back to you.
zone-focus="none"Viewer labels in your site's language: en, ru, es.
lang="ru"As the reader reaches it, the page holds the model in the middle of the window for one gesture of scroll and then lets it go. Nothing to ask for — and if your layout would rather it scrolled, say so.
stage="none"Only the page can hold the model still — the engine sits in a frame and a frame is not allowed to touch the page around it. That is what the tag is for: it stands in your layout and holds the model for you, so this is one attribute and nothing else in the markup. How the tour is watched is normally chosen in the cabinet; the attribute overrules that setting for one page.
<glb-viewer
share-token="prj_your_token"
tour="scroll">
</glb-viewer>What gets held is whichever box keeps a place on the page. A viewer laid out as position: absolute keeps none of its own, so we hold the nearest box that does — your card, frame and all. Give that box the height.
An ordinary block scrolls away with everything around it, so the reader meets the model once whole and once half off the screen. That no longer happens: as the reader reaches it, the page holds the model in the middle of the window for one gesture of scroll and then lets it go, and nothing has to be asked for. Give it a section taller than the screen and it is held for the whole of that section instead, leaving with it. The height stays yours: the model is held at exactly the height your own layout gave it, centred in what the window leaves free. Want the whole screen — say stage="screen".
<section style="height: 240vh">
<glb-viewer share-token="prj_your_token"></glb-viewer>
</section>Your own layout decides how tall the model stands, in ordinary media queries: a band on a wide screen, the whole window on a phone. One number is missing from CSS — how much of that window your pinned site header takes. We have already measured it, because we stand the model under it, and we hand it back as --glb-stage-free — on the model itself and on the page's root, so it reaches the box that decides the height even when that box is a wrapper around the model. Write it with a fallback: on a page that refused the hold there is no header to stand under either.
.model-stage {
height: var(--glb-stage-free, 100svh);
}
@media (min-width: 1024px) {
.model-stage {
height: min(var(--glb-stage-free, 100svh), 760px);
}
}
.model-stage {
--glb-stage-top: 72px;
}We look for the header ourselves, and once in a while we can be wrong — a header that only appears on the way back up, say. Name the number instead: --glb-stage-top on the same box calls off the search and is taken at its word.
The ones marked live in the table above can be changed at any time with setAttribute — the viewer applies the change without reloading the model.
Set them in HTML, or change them at runtime — everything marked live is re-applied immediately.
| Attribute | Live | What it does | |
|---|---|---|---|
share-token | Required | Project token — the model and its settings come from the project | |
scenario | Optional | Which slice of the catalogue this embed shows, named in the cabinet | |
page | Optional | Which page of your site this is, so reports can be read page by page. Filled in from the address on its own — name it only when you want a different label | |
ui | Optional | auto shows the built-in configurator, none hides it so you can draw your own | |
lang | Optional | Interface language: en, ru, es | |
layout | Optional | The shape the panel is drawn in when it has the room for a choice | |
lead-capture | Optional | Whether a finished build can be sent to you as a request | |
zone-focus | Optional | auto flies to the view saved for a zone when it is picked, none leaves the camera to you | |
colors | Optional | ✓ | Opening colourway, e.g. body:#111111,seats:#d8c9a6 |
camera-mode | Optional | ✓ | orbit, free or walk |
auto-rotate | Optional | ✓ | Slowly turn the model while nobody is interacting |
panel-side | Optional | ✓ | Which side the buyer's panel stands on: right, left or below |
tour | Optional | ✓ | How the tour is watched: button (the default), auto, or scroll — the page's own scroll winds it |
stage | Optional | ✓ | the hold as the page scrolls is on by itself: none calls it off, screen is simply the height of the screen |
title | Optional | ✓ | The caption shown above the model |
The ones without a tick decide what the viewer IS rather than how it behaves — the catalogue comes out of the server already sliced by scenario, the panel's shape is settled before the model is revealed, and a language is a different document. Change one of those and the viewer re-opens at a new address instead of quietly ignoring you.
The element exposes exactly one object, frozen, with eight namespaces. Everything else inside the viewer stays private, so your integration keeps working across releases.
colorsThe zones with their palettes, zone selection, recolouring one zone or a whole colourway at once, and a reset to the original.
optionsThe choices a project carries beyond colour — trims, quantities, free text — with the current selection and anything the rules had to adjust.
tourPlay, pause and stop the fly-through, set the speed, read progress, and switch off the built-in resume prompt.
cameraPosition and target, mode (orbit, free flight, walk), limits, auto-rotate, and a flight back to the opening view.
animationsThe animations baked into the model: list, play and stop — doors, hoods, moving parts.
buildSave what the visitor configured and get a link back to it, or send it to you as a request with their contact details attached.
panelThe panel's colour, translucency, corners and side, the shape the options are drawn in, and what the panel is standing on.
sceneWhat surrounds the model: the background, and whether the viewer is looking at the outside or standing inside.
const viewer = document.querySelector('glb-viewer');
const { colors, options, tour, camera, animations } = await viewer.ready;
// Every call is a promise: the model runs in its own frame and answers from there.
await colors.getZones(); // [{ id, label, group, palette, defaultColor, hasView }]
await colors.selectZone('seats'); // flies to the view the author saved for the zone
await colors.selectZone(null); // visitor left the zone — flies back out
await colors.setColor('seats', '#d8c9a6');
await colors.setColor('seats', '#d8c9a6', { focus: false }); // recolour, camera stays put
await colors.setColors({ seats: '#d8c9a6' }); // whole colourway, camera untouched
await colors.reset();
await options.getGroups(); // the non-colour choices the project carries
await options.select(groupId, optionId);
await tour.play(); await tour.togglePause(); await tour.stop();
await tour.setSpeed(2.4);
await tour.setResumePrompt(false); // you draw your own resume control
await camera.home(); // back to the opening view
await camera.setAutoRotate(true);
await camera.setMode('orbit'); // 'orbit' | 'free' | 'walk'
await camera.setLimits({ minDistance: 2, maxPolarAngle: 1.5 });
await animations.getEntries(); // [{ id, label }]
await animations.play(id); await animations.stop(id);Awaiting viewer.ready leaves nothing to check and nothing to race: it resolves with the same API whenever you ask, before or long after the viewer boots.
Every namespace is always there and every call is a promise, because the answer comes back from the viewer's own frame. Ask a project with no zones to recolour one and the promise rejects — it never quietly returns undefined.
The buyer's panel is painted in your colour and in nothing else. Being in tone with any site is what the translucency is for — and that translucency has a computed floor, because the panel has to hold its own text at 4.5:1 over whatever it stands on.
const { panel } = await viewer.ready;
panel.setSkin({
surface: '#faf7f2', // your colour — null follows the side your page is on
surfaceOpacity: 0.7, // a request: the floor below may raise it
blur: 30, // 0…40 px, dropped once nothing shows through anyway
accent: '#c07a3e', // the chosen option and the primary button
radius: 14, // 0…32 px
side: 'right', // 'right' | 'left' | 'below'
});
// What the panel is standing on. Buys density, never colour.
panel.setBackdrop('#faf7f2');
panel.setBackdrop('#101010', { live: true }); // video, slideshow — counts as nothing said
panel.setBackdrop(null); // take the answer back
panel.setOptionDisplay('tiles'); // 'flat' | 'tiles'
panel.getSkin();The opacity you set is a request, not a result. With no backdrop named, readability has to be guaranteed over every backdrop there is — so the panel settles denser than you asked. Name the backdrop and the request is honoured.
setBackdrop buys density and never colour. The panel on your page and the panel on somebody else's are the same colour; knowing the backdrop only changes how much shows through. Over the whole screen the scene answers instead: your page is no longer behind the panel there.
Name surface explicitly. The viewer stands in a frame of its own, and nothing is inherited across a frame boundary — it reads its own document, not the page beneath it, so it cannot guess whether your site is light or dark.
The accent never adapts. A button that changed colour because of a photograph on the page is a mistake in any brand's eyes.
Every namespace answers getState() with its current snapshot. Use it for values that change too often to broadcast — the tour progress, the camera pose — or to catch up if you attached your listeners late.
const { colors, options, tour, camera, animations } = await viewer.ready;
await colors.getState(); // { zones, colors, selectedZoneId }
await options.getGroups(); // [{ id, label, options: [{ id, chosen, unmet }] }]
await tour.getState(); // { isPlaying, isPaused, progress, hasWaypoints }
await camera.getState(); // { position: [x, y, z], target: [x, y, z] }
await animations.getState(); // { entries: [{ id, label, status }] }The viewer tells you what happened, so you never poll it. Everything arrives as an ordinary DOM event on the element — addEventListener, and e.detail is the payload.
| Event | Payload | When it fires |
|---|---|---|
glb-viewer:ready | { zones, tour, animations } | First snapshot: the project's zones, tour and animations |
glb-viewer:model | { modelId, state, progress } | The model's delivery: state is loading, loaded or failed, and progress counts from 0 to 1 |
glb-viewer:zones | { zones } | The zone list changed, or arrived for the first time |
glb-viewer:zone | { zoneId } | A visitor picked a zone; null means the selection was cleared |
glb-viewer:color | { zoneId, hex } | One zone changed colour |
glb-viewer:colors | { colors } | The whole colourway changed — a preset was applied, for example |
glb-viewer:options | { selection, adjusted } | A non-colour option changed. adjusted lists anything the compatibility rules had to move with it |
glb-viewer:tour:status | { isPlaying, isPaused, hasWaypoints } | The tour started, paused or stopped |
glb-viewer:build | { publicId, url } | A build was saved and now has a link of its own |
glb-viewer:build-request | { publicId, url } | A visitor sent their build to you as a request |
glb-viewer:error | { code, message } | Branch on code — it is stable. message is human copy and can change. |
const viewer = document.querySelector('glb-viewer');
viewer.addEventListener('glb-viewer:zones', (e) => e.detail.zones);
viewer.addEventListener('glb-viewer:zone', (e) => e.detail.zoneId); // null = deselected
viewer.addEventListener('glb-viewer:color', (e) => e.detail); // { zoneId, hex }
viewer.addEventListener('glb-viewer:colors', (e) => e.detail.colors);
viewer.addEventListener('glb-viewer:options', (e) => e.detail.selection);
viewer.addEventListener('glb-viewer:tour:status', (e) => e.detail.isPlaying);
viewer.addEventListener('glb-viewer:build', (e) => e.detail.url);
viewer.addEventListener('glb-viewer:error', (e) => e.detail.code);Your key is tied to your domains, and the viewer page is tied to the same list at the browser level. A snippet copied onto someone else's site doesn't work there.
The server reads the domain from the request headers, not from what the snippet sent — it can't be faked in page code. While your domain list is empty nothing is blocked and attempts are written to the log, so turning the check on breaks no existing embed. A * value lifts the check on purpose.
The /v/ page is served with a frame-ancestors policy built from the same list of domains. A site that isn't on the list gets an empty frame — the visitor's own browser decides, before a single line of our code runs.
If a project isn't public, /init answers 403 PRIVATE_PROJECT to everyone but its owner and signed-in users. The check lives in the API, so neither a direct link nor your own copy of the viewer gets around it.
POST /api/v1/viewer/auth/verify
403 { "error": "Domain not allowed" } // the origin is not on the allowlist
GET /api/v1/viewer/prj_your_token
403 { "code": "PRIVATE_PROJECT" } // the project is not shared publicly
404 { "code": "NOT_PUBLISHED" } // the project has never been publishedWe keep the domain list for you — tell us which addresses to add and we'll update it. Self-service management in the dashboard isn't there yet.
The widget runs on your page, so it is you the visitor asks. Here is everything we write to their device and keep on ours, in the same words you can put into your own policy.
| Key | What for | How long it lives |
|---|---|---|
glb-viewer:visitor | A random browser identifier. It answers one question: did this person come back, or is this their first time. It holds no name, email or phone number. | Until storage is cleared. Safari expires it after 7 days |
glb-viewer:session | One sitting. Joins the actions inside a single visit, so that "10 views" is not one person opening the page ten times. | 30 minutes of silence |
If the browser sends Global Privacy Control or Do Not Track, the visitor identifier is neither written nor read — not even one already sitting on the device. The visit is still counted: one tab on one page is a page, not a person.
Only the country is taken from the address; the address itself is never saved. For the same reason the map answers where people watch from, and never who they are.
POST /viewer/forget with the visitor identifier deletes both the events and the folded visits. A request that was already sent stays: it is yours now, and withdrawing it on your behalf is not ours to do.
Raw events are deleted automatically on your plan's retention period. Folded visits — anonymous totals, with none of the actions inside them — stay, so reports can reach back over past years.
This describes how the widget works; it is not a legal document. Agree the wording of your own policy with your own lawyer — especially if you have visitors in the EU, where writing an identifier requires consent.
There are no framework packages and none are needed: <glb-viewer> is a plain element, so it works anywhere without a wrapper.
import { useEffect, useRef } from 'react';
export function Configurator({ token }) {
const ref = useRef(null);
useEffect(() => {
let cancelled = false;
ref.current.ready.then(async ({ colors }) => {
if (!cancelled) await colors.setColors({ seats: '#d8c9a6' });
});
return () => { cancelled = true; };
}, []);
return (
<glb-viewer
ref={ref}
share-token={token}
style={{ display: 'block', width: '100%', height: 600 }}
/>
);
}<template>
<glb-viewer
ref="viewer"
:share-token="token"
style="display: block; width: 100%; height: 600px"
/>
</template>
<script setup>
import { onMounted, ref } from 'vue';
const viewer = ref(null);
onMounted(async () => {
const { colors } = await viewer.value.ready;
await colors.setColors({ seats: '#d8c9a6' });
});
</script>In Vue, add glb-viewer to isCustomElement (vue.compilerOptions.isCustomElement in Nuxt) so the compiler stops looking for a component by that name.
Every error carries a stable code — build your logic on that. The message is human copy and may change.
| Code | What happened |
|---|---|
PROJECT_NOT_FOUND | No project for this token, or it hasn't been published yet |
NOT_PUBLISHED | The project has never been published, so visitors aren't served it |
PRIVATE_PROJECT | The project isn't public — only its owner and signed-in users have access |
SUBSCRIPTION_INACTIVE | The subscription is inactive, so the viewer is switched off |
AUTH_INVALID_KEY | The access key is wrong or has been revoked |
AUTH_FORBIDDEN | Your domain isn't allowed for this key |
MODEL_OR_TOKEN_REQUIRED | Neither a project token nor a model URL was given |
MODEL_URL_REQUIRED | The project has no model attached |
MODEL_LOAD_FAILED | The model file didn't load — network trouble or a corrupted file |
WEBGL_NOT_SUPPORTED | The browser or device has no WebGL |
WEBGL_CONTEXT_LOST | The browser took the WebGL context back, usually out of video memory |
INIT_NETWORK_ERROR | The project data couldn't be fetched — network or server |
viewer.addEventListener('glb-viewer:error', ({ detail }) => {
if (detail.code === 'WEBGL_NOT_SUPPORTED') showStaticGallery();
else if (detail.code === 'PROJECT_NOT_FOUND') hideSection();
else reportToMonitoring(detail.code, detail.message);
});The package ships hand-written declarations for the whole public surface, so the element, its API and every event payload are typed — with nothing internal leaking into your build.
/// <reference types="@glb-viewer/cdn" />
const viewer = document.querySelector('glb-viewer')!; // GLBViewerElement
const { colors } = await viewer.ready; // GLBViewerApi
await colors.setColor('seats', '#d8c9a6');Write to us — we'll help you embed the configurator and set the experience up around your product.