Developer Documentation

One tag embeds the viewer. Everything beyond that is optional.

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

Quick Start

One script and one tag. There is no second way of doing this, so nothing here can be the wrong choice.

The <glb-viewer> tag

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.

  • The engine runs in its own frame: on a customer's live site the same model held the page's main thread for 444 ms inline and 2 ms in a frame
  • await viewer.ready hands you the whole API — every call is a promise
  • Most attributes are live: change one and the viewer re-applies it without reloading
html
<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 line instead of two

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.

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

Common setups

The things people ask for most are one attribute each. Drop the line inside the tag.

Your own interface

Hides the built-in configurator panel so you can draw the swatches yourself and set colours through the API.

ui="none"

Opening colourway

The model appears already configured — no flash of the original colours.

colors="body:#111111,seats:#d8c9a6"

The tour plays itself

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"

Scrolling drives the tour

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"

Walk inside

First-person mode — for interiors, cabins and rooms.

camera-mode="walk"

Slow turntable

The model rotates gently until a visitor touches it.

auto-rotate

Keep the camera still

By default picking a zone flies to its saved view. This hands that decision back to you.

zone-focus="none"

Interface language

Viewer labels in your site's language: en, ru, es.

lang="ru"

The model holds its place by itself

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"

Dwelling on the model while the page scrolls

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.

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

A section the model never leaves

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

html
<section style="height: 240vh">
  <glb-viewer share-token="prj_your_token"></glb-viewer>
</section>

The height is yours, including window-minus-header

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.

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

Attributes

Set them in HTML, or change them at runtime — everything marked live is re-applied immediately.

AttributeLiveWhat it does
share-tokenRequiredProject token — the model and its settings come from the project
scenarioOptionalWhich slice of the catalogue this embed shows, named in the cabinet
pageOptionalWhich 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
uiOptionalauto shows the built-in configurator, none hides it so you can draw your own
langOptionalInterface language: en, ru, es
layoutOptionalThe shape the panel is drawn in when it has the room for a choice
lead-captureOptionalWhether a finished build can be sent to you as a request
zone-focusOptionalauto flies to the view saved for a zone when it is picked, none leaves the camera to you
colorsOptional✓Opening colourway, e.g. body:#111111,seats:#d8c9a6
camera-modeOptional✓orbit, free or walk
auto-rotateOptional✓Slowly turn the model while nobody is interacting
panel-sideOptional✓Which side the buyer's panel stands on: right, left or below
tourOptional✓How the tour is watched: button (the default), auto, or scroll — the page's own scroll winds it
stageOptional✓the hold as the page scrolls is on by itself: none calls it off, screen is simply the height of the screen
titleOptional✓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.

JavaScript API

The element exposes exactly one object, frozen, with eight namespaces. Everything else inside the viewer stays private, so your integration keeps working across releases.

colors

Colours and zones

The zones with their palettes, zone selection, recolouring one zone or a whole colourway at once, and a reset to the original.

options

Non-colour options

The choices a project carries beyond colour — trims, quantities, free text — with the current selection and anything the rules had to adjust.

tour

Camera tour

Play, pause and stop the fly-through, set the speed, read progress, and switch off the built-in resume prompt.

camera

Camera

Position and target, mode (orbit, free flight, walk), limits, auto-rotate, and a flight back to the opening view.

animations

Animations

The animations baked into the model: list, play and stop — doors, hoods, moving parts.

build

The finished build

Save what the visitor configured and get a link back to it, or send it to you as a request with their contact details attached.

panel

The buyer's panel

The panel's colour, translucency, corners and side, the shape the options are drawn in, and what the panel is standing on.

scene

The scene

What surrounds the model: the background, and whether the viewer is looking at the outside or standing inside.

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

A panel in tone with your site

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.

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

Reading state

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.

javascript
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 }] }

Events

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.

EventPayloadWhen 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.
javascript
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);

Domains and access

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 key only works on your domains

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.

A copied iframe is stopped by the browser

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.

A private project is closed on the server

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.

Refusals
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 published

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

What the widget stores

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.

KeyWhat forHow long it lives
glb-viewer:visitorA 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:sessionOne 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

We honour a refusal to be recognised

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.

We do not store IP addresses

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.

Erasure on request

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.

How long we keep it

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.

React, Vue and the rest

There are no framework packages and none are needed: <glb-viewer> is a plain element, so it works anywhere without a wrapper.

React
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 }}
    />
  );
}
Vue / Nuxt
<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.

Errors

Every error carries a stable code — build your logic on that. The message is human copy and may change.

CodeWhat happened
PROJECT_NOT_FOUNDNo project for this token, or it hasn't been published yet
NOT_PUBLISHEDThe project has never been published, so visitors aren't served it
PRIVATE_PROJECTThe project isn't public — only its owner and signed-in users have access
SUBSCRIPTION_INACTIVEThe subscription is inactive, so the viewer is switched off
AUTH_INVALID_KEYThe access key is wrong or has been revoked
AUTH_FORBIDDENYour domain isn't allowed for this key
MODEL_OR_TOKEN_REQUIREDNeither a project token nor a model URL was given
MODEL_URL_REQUIREDThe project has no model attached
MODEL_LOAD_FAILEDThe model file didn't load — network trouble or a corrupted file
WEBGL_NOT_SUPPORTEDThe browser or device has no WebGL
WEBGL_CONTEXT_LOSTThe browser took the WebGL context back, usually out of video memory
INIT_NETWORK_ERRORThe project data couldn't be fetched — network or server
javascript
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);
});

TypeScript

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.

typescript
/// <reference types="@glb-viewer/cdn" />

const viewer = document.querySelector('glb-viewer')!;  // GLBViewerElement
const { colors } = await viewer.ready;                 // GLBViewerApi
await colors.setColor('seats', '#d8c9a6');

Questions about your integration?

Write to us — we'll help you embed the configurator and set the experience up around your product.