Developer Documentation
A framework-agnostic, embeddable JavaScript 3D viewer library that matches Google <model-viewer> in drop-in simplicity and online-3d-viewer (3dviewer.net) in multi-format engineering power.
1. Introduction & Architectural Pillars
The Universal 3D Viewer library is engineered from the ground up for modern web platforms, e-commerce stores (Shopify), content management systems (WordPress), and headless applications.
Shadow DOM Encapsulation
100% CSS isolation. Host themes in WordPress and Shopify never distort canvas aspect ratios or controls.
Core Web Vitals Ready
Native loading="lazy" and instant poster preview thumbnails prevent WebGL from slowing down page LCP.
Universal 3D Ingestion
Supports glTF, OBJ, STL, PLY, FBX, 3MF, CAD (STEP/IGES), BIM (IFC), Rhino 3DM and 24 legacy formats through Assimp WebAssembly.
Headless & Custom UI
Completely disable the built-in UI and control every aspect via external buttons or custom slotted markup.
2. Installation & Setup
The library ships as the universal-3d-viewer npm package. Version 0.2.0 is not published yet;
the pinned jsDelivr URLs below go live as soon as it is. Until then, build the same files from the repository
(npm run build:lib) and self-host them.
Method A: Script Tag (Zero Build)
The UMD bundle, dist/library/universal-3d-viewer.umd.js in the npm package, has three
inside and registers <universal-3d-viewer> as soon as it loads. Load it from jsDelivr, or copy it
and the decoder folders (public/draco, public/basis, public/wasm) to your
own site and point asset-base-path at them.
<script src="https://cdn.jsdelivr.net/npm/universal-3d-viewer@0.2.0/dist/library/universal-3d-viewer.umd.js"></script>
<universal-3d-viewer
src="/model.glb"
camera-controls
asset-base-path="https://cdn.jsdelivr.net/npm/universal-3d-viewer@0.2.0/public/"
style="width: 100%; height: 500px;"
></universal-3d-viewer>
Do not load universal-3d-viewer.es.js with <script type="module">: it imports the
bare specifier three, which a browser cannot resolve without an import map.
The repository also commits two smaller standalone files, lib/universal-3d-viewer.min.js (all 14
loaders) and lib/universal-3d-viewer.web.min.js (web formats only), for hosts that vendor a file,
such as a WordPress plugin. They carry the core API only: there is no
<universal-3d-viewer> element in them, so you create the viewer from script:
<div id="viewport" style="width: 600px; height: 400px"></div>
<script src="/assets/universal-3d-viewer.min.js"></script>
<script src="/assets/my-viewer.js"></script>
// my-viewer.js
const viewer = new Universal3DViewer.Universal3DViewer({
container: document.getElementById('viewport'),
assetBasePath: '/assets/decoders/',
})
viewer.load('/model.glb').catch((err) => {
if (!Universal3DViewer.isAbortError(err)) console.error(err)
})
Method B: npm Package (Vite, Next.js, Nuxt)
npm install universal-3d-viewer three
Import into your JavaScript or TypeScript codebase:
// Automatically registers the <universal-3d-viewer> custom element
import 'universal-3d-viewer'
// Or import the headless programmatic class
import { Universal3DViewer } from 'universal-3d-viewer'
3. Quick Start
Embedding a 3D model takes just one custom HTML tag:
<universal-3d-viewer
src="https://example.com/models/sneaker.glb"
poster="https://example.com/models/sneaker-poster.webp"
camera-controls
auto-rotate
ar
style="width: 100%; height: 500px;"
></universal-3d-viewer>
4. Web Component Reference (<universal-3d-viewer>)
HTML Attributes & Properties
| Attribute | Type | Default | Description |
|---|---|---|---|
src |
string |
"" |
URL or Blob to the 3D model (.glb, .gltf, .obj, .stl, .step, .ifc, etc.). |
ios-src |
string |
"" |
Optional .usdz URL for Apple AR Quick Look on iPhones and iPads. |
poster |
string |
"" |
Thumbnail preview displayed before 3D loads. Optimizes Google LCP score. |
loading |
"auto" | "lazy" |
"auto" |
When set to "lazy", defers WebGL initialization until scrolled into view. Init-only: it decides when the engine starts, so changing it afterwards has no effect. |
reveal |
"auto" | "manual" |
"auto" |
Anything other than "auto" keeps the poster up and the engine unstarted until the poster is activated (click, Enter or Space) or dismissPoster() is called. Init-only, like loading. |
asset-base-path |
string |
page URL | Base URL the draco/, basis/ and wasm/ decoder folders are served from. Set it whenever the page URL does not describe where they live (a CDN, a WordPress plugin folder, any permalink route). Init-only: read once when the engine is built and not observed afterwards, so set it before the element starts. |
camera-controls |
boolean |
false |
Enables mouse orbit, pan, and mobile pinch-to-zoom navigation. |
auto-rotate |
boolean |
false |
Enables smooth idle turntable rotation. Under prefers-reduced-motion: reduce the attribute does not start rotation; the toolbar toggle still does, as an explicit request. |
auto-rotate-delay |
number |
3000 |
Idle milliseconds after user drag before auto-rotation resumes. |
rotation-speed |
number |
2.0 |
Rotation speed multiplier. |
disable-orbit |
boolean |
false |
Disables rotational orbit while keeping pan and zoom active. |
disable-pan |
boolean |
false |
Disables right-click and two-finger translation panning. |
disable-zoom |
boolean |
false |
Disables scroll wheel and pinch zoom. |
edges |
boolean |
false |
Toggles CAD-style feature edge lines across model meshes. |
edge-color |
string (hex) |
"#222222" |
Hex color of CAD edge lines. |
edge-threshold |
number |
30 |
Crease angle threshold (in degrees) for calculating sharp edges. |
environment-preset |
string |
"studio" |
PBR lighting preset: "studio", "neutral", "sunset", "warehouse", "night". |
environment-image |
string |
"" |
URL to custom equirectangular HDR/EXR/JPEG environment map. |
skybox-image |
string |
"" |
URL to 360-degree background image. |
skybox |
boolean |
false |
Toggles visibility of the 360 skybox background. |
with-credentials |
boolean |
false |
Sends cookies with cross-origin model requests, for models behind a session (a customer portal, a private CDN). The server must send matching CORS headers. |
generate-schema |
boolean |
false |
Publishes a schema.org/3DModel JSON-LD block describing src, so search engines index the page as carrying a 3D asset. Written into the light DOM, since crawlers do not read shadow roots. |
no-default-ui |
boolean |
false |
Hides the built-in floating toolbar, AR badge, and progress overlay (Headless mode). |
ar |
boolean |
false |
Enables the Augmented Reality badge button. The badge hides itself automatically if no mode in ar-modes is available on the device. |
ar-scale |
"auto" | "fixed" |
"auto" |
When set to "fixed", locks model to 1:1 true scale and disables AR scaling gestures. |
ar-placement |
"floor" | "wall" |
"floor" |
Initial AR placement plane: "floor" (horizontal) or "wall" (vertical surface). |
ar-modes |
string |
"webxr scene-viewer quick-look" |
Space-separated AR mechanisms, tried in the order written. webxr presents in-page with hit-test placement, scene-viewer hands off to Google Scene Viewer on Android, and quick-look hands off to Apple AR Quick Look on iOS. Unknown tokens are ignored. |
xr-environment |
boolean |
false |
Uses WebXR light estimation to light the model from the real room while presenting, when the device supports it. |
ar-status |
"not-presenting" | "session-started" | "object-placed" | "failed" |
"not-presenting" |
Read-only, written by the element. Reflected so page CSS can react, e.g. universal-3d-viewer[ar-status="object-placed"] { … }. |
camera-orbit |
string |
auto framing | Camera position as "θ φ r", e.g. "45deg 55deg 2.5m". Angles take deg/rad; the radius takes m/cm/mm, a % of the auto-fit distance, or auto. A named view (front, back, left, right, top, bottom, isometric) also works. |
camera-target |
string |
auto |
Orbit pivot as "x y z", e.g. "0m 1m 0m", or auto for the model's centre. |
min-camera-orbit / max-camera-orbit |
string |
unbounded | Orbit limits in the same "θ φ r" syntax; auto leaves that bound free. e.g. max-camera-orbit="auto 90deg auto" keeps the camera above the horizon. |
min-field-of-view / max-field-of-view |
number |
10 / 120 |
Clamps for field-of-view, in degrees. |
camera-projection |
"perspective" | "orthographic" |
"perspective" |
Orthographic removes perspective foreshortening, which is what CAD reviewers usually want for dimensional comparison. Switching preserves the framing. |
up-axis |
"y" | "z" |
"y" |
Set "z" for Z-up sources (most CAD formats), which otherwise load lying on their side. |
touch-action |
string |
"none" |
CSS touch-action for the canvas. "pan-y" hands vertical swipes back to the page so a viewer embedded mid-article doesn't trap scrolling. |
orbit-sensitivity |
number |
1 |
Multiplier on drag-to-orbit speed. |
interpolation-decay |
number |
100 |
Time constant of camera transitions, in ms. Lower settles faster. |
interaction-prompt |
"auto" | "none" |
"auto" |
Shows a drag affordance if the viewer sits untouched after loading. Dismissed for good on the first interaction. |
interaction-prompt-style |
"basic" | "wiggle" |
"basic" |
"wiggle" also nudges the model to show it turns. |
interaction-prompt-threshold |
number |
3000 |
Idle time before prompting, in ms. |
background-color |
string |
null |
Solid canvas background color (hex e.g. "#0b0f19" or "transparent"). |
background-image |
string |
"" |
2D image URL applied to canvas background. |
animation-name |
string |
"" |
Name of specific animation clip to play on load. |
autoplay |
boolean |
false |
Auto-plays first animation clip on model load. |
time-scale |
number |
1.0 |
Playback speed multiplier for keyframe animations. |
animation-crossfade-duration |
number |
300 |
Milliseconds to blend when switching clips. 0 cuts straight over. |
variant-name |
string |
null |
Selects a glTF KHR_materials_variants variant by name. Removing the attribute restores the default. |
shadow-intensity |
number |
0.5 |
Darkness of the contact shadow, 0–1. 0 turns it off; anything above turns it on. Composes with the active theme rather than replacing it. |
shadow-softness |
number |
0 |
Blur radius of the contact shadow. |
tone-mapping |
"aces" | "neutral" | "commerce" | "agx" | "linear" | "reinhard" | "cineon" |
"aces" |
Tone-mapping curve. commerce is an alias of neutral, matching <model-viewer>. |
min-polar-angle |
string | number |
"0deg" |
Minimum vertical camera angle. Supports "10deg", "0.5rad". |
max-polar-angle |
string | number |
"180deg" |
Maximum vertical camera angle (e.g. "90deg" prevents camera from dipping below ground). |
min-azimuth-angle |
string | number |
null |
Minimum horizontal turntable rotation sweep (e.g. "-45deg"). |
max-azimuth-angle |
string | number |
null |
Maximum horizontal turntable rotation sweep (e.g. "45deg"). |
gallery |
boolean |
false |
Generates multi-angle snapshots and displays them in a horizontal row strip. |
show-gallery |
boolean |
false |
Controls visibility of the built-in horizontal gallery row strip. |
hidden-nodes |
string |
"" |
Comma-separated list of mesh/node names to hide initially (e.g. "Roof, Spoiler"). |
Every other attribute applies live after the engine has started. Removing environment-image,
skybox-image, animation-name, exposure, time-scale,
rotation-speed, auto-rotate-delay, edge-color or edge-threshold
reverts to the default.
Keyboard, Focus & Reduced Motion
-
The canvas is exposed as
role="img", labelled byalt(falling back to "Interactive 3D model"), and is focusable. While it has focus the arrow keys orbit the camera by 10° per press, whenevercamera-controlsallows orbiting (not withdisable-orbit). The page does not scroll while the model has focus. -
The canvas, the poster, the toolbar buttons, the gallery thumbnails and the AR buttons show a focus ring on
keyboard focus. Its colour is
--focus-ring-color(default#60a5fa). -
Under
prefers-reduced-motion: reduce, theauto-rotateattribute does not start rotation, camera moves the element makes (reset, gallery views,camera-orbit/camera-targetchanges, hotspot focus, arrow keys) jump instead of tweening, the fit on load jumps, and the interaction prompt neither animates nor wiggles the camera. From script,reducedMotionin the config orviewer.setReducedMotion(true | false | null)overrides the OS setting (nullfollows it again).
Slots & Projection
Project custom markup directly into the component's Shadow Root:
| Slot Name | Purpose | Example |
|---|---|---|
slot="toolbar" |
Replaces the built-in glass toolbar with custom HTML controls. | <div slot="toolbar">...</div> |
slot="ar-button" |
Replaces the default AR pill button with a custom branded button. | <button slot="ar-button">View in Room</button> |
slot="poster" |
Custom loading poster preview overlay. | <div slot="poster">...</div> |
slot="progress-bar" |
Custom loading progress bar. | <div slot="progress-bar">...</div> |
slot="gallery" |
Replaces the built-in horizontal gallery row strip with custom gallery HTML. | <div slot="gallery">...</div> |
slot="hotspot-{id}" |
Declarative 3D annotation pin. Requires data-position="x y z". |
<button slot="hotspot-1" data-position="0 1 0">...</button> |
CSS Shadow Parts (::part)
Style internal elements from external CSS without breaking encapsulation. The parts are
canvas, hotspots, poster, progress-bar,
ar-container, ar-button, ar-exit-button, interaction-prompt,
gallery-strip, gallery-thumb (each thumbnail in the gallery strip),
toolbar and toolbar-button.
universal-3d-viewer::part(toolbar) {
bottom: 24px;
background: rgba(0, 0, 0, 0.9);
}
universal-3d-viewer::part(ar-button) {
background: #10b981;
font-weight: 700;
}
universal-3d-viewer::part(progress-bar) {
background: linear-gradient(90deg, #f59e0b, #ef4444);
}
universal-3d-viewer::part(gallery-thumb) {
border-radius: 4px;
}
CSS Custom Properties
Set these on the element (or any ancestor) to retheme the built-in chrome without overriding parts wholesale.
| Property | Default | Description |
|---|---|---|
--min-hotspot-opacity |
0 |
Opacity of a hotspot that is occluded by geometry or behind the camera. Raise it to dim hidden pins instead of hiding them. |
--max-hotspot-opacity |
1 |
Opacity of a fully visible hotspot. |
--poster-color |
#0f1115 |
Backdrop behind the poster image while the model loads. |
--progress-bar-color |
blue gradient | Fill of the loading bar. Accepts any background value. |
--progress-bar-height |
3px |
Thickness of the loading bar. |
--progress-mask |
rgba(255,255,255,0.1) |
Track behind the loading bar. |
--focus-ring-color |
#60a5fa |
Keyboard focus outline on the canvas, poster, toolbar buttons, gallery thumbnails and AR buttons. |
universal-3d-viewer {
/* Keep occluded pins faintly visible rather than hiding them outright */
--min-hotspot-opacity: 0.25;
--max-hotspot-opacity: 1;
--progress-bar-color: linear-gradient(90deg, #10b981, #34d399);
--progress-bar-height: 4px;
}
/* Each hotspot also reflects its own state */
universal-3d-viewer [slot^="hotspot-"]:not([data-visible]) {
pointer-events: none;
}
Custom DOM Events
The Web Component dispatches standard bubbling DOM CustomEvents:
| Event Name | Event Detail (e.detail) |
Description |
|---|---|---|
load |
{ scene, stats: { polyCount, meshCount, boundingBox } } |
Fired when 3D model finishes parsing and rendering. |
progress |
{ percent } |
Fired during download and triangulation. |
error |
{ error, stage } |
Fired if file fetching or parsing fails. |
surface-click |
{ point, normal, meshName, originalEvent } |
Fired when user clicks the 3D surface (for dropping pins). |
camera-change |
{ orbit: { theta, phi, radius }, target, fov, source } |
Fired when the camera moves or rotates. source is "user-interaction" when the move came from a drag/zoom, otherwise "none". |
camera-projection-change |
{ projection } |
Fired when the viewer switches between perspective and orthographic. |
hotspot-visibility |
{ id, visible } |
Fired when a hotspot becomes occluded or visible again. Only on change, not every frame. |
scene-graph-ready |
{ materialCount } |
Fired once viewer.model is populated for a newly loaded model. |
play / pause |
{ name } |
Fired when a clip starts/resumes, or is paused/stopped. |
finished |
{ name } |
Fired when a non-looping clip reaches its end. |
loop |
{ name } |
Fired each time a looping clip wraps. |
ar-status |
{ status } |
Fired when an AR session starts, the user places the model, the session ends, or no mode could launch. Mirrors the ar-status attribute. |
ar-tracking |
{ status: "tracking" | "not-tracking" } |
Fired while presenting when the device loses or regains world tracking. |
5. JavaScript Programmatic SDK Reference
For full programmatic control, instantiate the Universal3DViewer class directly or call methods on the <universal-3d-viewer> DOM element.
Camera & Navigation API
const viewer = document.querySelector('universal-3d-viewer');
// Reset camera to initial home position with smooth animation
viewer.resetCamera(true);
// Frame model bounding box with padding
viewer.fitToScreen(1.25, true);
// Toggle or configure auto-rotation
viewer.toggleAutoRotate();
viewer.viewer.setAutoRotate(true, { speed: 3.0, delay: 2000 });
// Granular control locks
viewer.viewer.setZoomEnabled(false);
viewer.viewer.setPanEnabled(false);
viewer.viewer.setOrbitEnabled(true);
// Set specific orbit angles (theta, phi, radius)
viewer.viewer.setCameraOrbit(Math.PI / 4, Math.PI / 3, 2.5, true);
Framing, Projection & Picking
// Read and write the view in camera-orbit syntax
viewer.cameraOrbit = '45deg 60deg 120%'; // animates
console.log(viewer.cameraOrbit); // "45deg 60deg 3.6m"
viewer.cameraTarget = '0m 1m 0m';
// Jump straight there instead of animating
viewer.setCameraOrbitSpec('top', false);
// Constrain how far the user can orbit
viewer.setCameraOrbitLimits('auto 20deg 1m', 'auto 90deg 8m');
// Orthographic for dimensional comparison, perspective for presentation
viewer.setCameraProjection('orthographic');
// Stand a Z-up CAD model upright
viewer.setUpAxis('z');
// Measurements from the loaded model
const size = viewer.getDimensions(); // THREE.Vector3
const centre = viewer.getBoundingBoxCenter();
// What is under this pixel?
const hit = viewer.positionAndNormalFromPoint(event.clientX, event.clientY);
if (hit) {
viewer.addHotspot({ id: 'pin', position: hit.position, normal: hit.normal, element: pinEl });
}
Authored Cameras, Fit Insets & Reduced Motion
getAuthoredCameras() lists the loaded file's own cameras (glTF cameras) as
{ index, name, type: 'perspective' | 'orthographic' }, and viewFromAuthoredCamera(index, smooth?)
views the model through one, switching projection if that camera needs it; it returns false when there is
no camera at index. smooth defaults to animating unless reduced motion is in effect. Afterwards the
camera orbits as usual.
Automatic framing (on load, fitToScreen, an up-axis change) fits the corners of the model's bounding box.
fitInsets in the config, or setFitInsets({ top, bottom, left, right }), tells it how many canvas
pixels your own overlay chrome covers, so the model is framed inside the rest; getFitInsets() reads them back and
setFitInsets(null) clears them. The default is no insets. The <universal-3d-viewer> element has the same
setFitInsets() / getFitInsets(); it keeps the insets you set and adds its built-in toolbar's height
to the bottom inset by itself.
reducedMotion in the config, or setReducedMotion(true | false | null), makes every camera move jump
instead of animate: the fit on load, a hotspot's fly-to, and API calls asked to be smooth (resetCamera,
fitToScreen, setCameraOrbit, viewFromAuthoredCamera). null, the default, follows the user's
prefers-reduced-motion setting; isReducedMotion() reports the value in effect, on the viewer and on the element.
import { Universal3DViewer } from 'universal-3d-viewer'
const viewer = new Universal3DViewer({
container: document.getElementById('viewport'),
fitInsets: { top: 64 }, // a 64 px header floats over the canvas
reducedMotion: false, // always animate, whatever the OS says
})
await viewer.load('/scene.glb')
for (const cam of viewer.getAuthoredCameras()) {
console.log(cam.index, cam.name, cam.type)
}
viewer.viewFromAuthoredCamera(0)
// Later, when the header collapses
viewer.setFitInsets({ top: 0 })
viewer.fitToScreen()
viewer.setReducedMotion(null) // follow prefers-reduced-motion again
Loading, Cancellation & Errors
viewer.load(source, options?) takes a URL, a File or a three.js object and resolves with the model's
root group. options.companions passes the sibling files (a .bin, textures, an .mtl) that the
main file refers to by relative URI, for a multi-file upload; options.signal is an AbortSignal that
cancels the download and parse. filesFromDataTransfer(event.dataTransfer) turns a drop, folders included, into
that list of files.
A failed load rejects with a ModelLoadError: message is a sentence meant for a person,
code is one of 'unsupported-format', 'corrupt', 'missing-resource',
'empty', 'too-large', 'parse-failed' or 'aborted', fileName names
the file, and the parser's own error is on cause.
A load that is superseded by a newer load(), aborted through signal, or cut short by
dispose() rejects with an error named AbortError and is not reported on the
error event. Code that calls load() without a .catch therefore logs
"Uncaught (in promise)" when a user switches models quickly. Filter aborts with isAbortError:
import { Universal3DViewer, ModelLoadError, isAbortError, filesFromDataTransfer } from 'universal-3d-viewer'
viewer.load(url).catch((err) => { if (!isAbortError(err)) throw err })
// A drop of model.gltf + model.bin + textures, cancellable
const controller = new AbortController()
dropZone.addEventListener('drop', async (event) => {
event.preventDefault()
const files = await filesFromDataTransfer(event.dataTransfer)
const main = files.find((f) => /\.(gltf|glb|obj|stl|step|stp|ifc)$/i.test(f.name))
if (!main) return
try {
await viewer.load(main, { companions: files.filter((f) => f !== main), signal: controller.signal })
} catch (err) {
if (isAbortError(err)) return
if (err instanceof ModelLoadError) showMessage(err.message) // e.g. code 'missing-resource'
else throw err
}
})
cancelButton.addEventListener('click', () => controller.abort())
Live Textures & Color Swatches
Dynamically update mesh colors or apply new texture maps at runtime:
// 1. Change PBR color (pass part name or '*' for all meshes)
viewer.setPartColor('Cushion', '#ff3366');
// 2. Live Texture Swapping (color, roughness, metalness, normal)
await viewer.updateTexture('Cushion', 'map', 'https://cdn.example.com/leather-texture.webp', {
repeat: [2, 2],
flipY: true
});
// 3. Inspect all discovered parts and materials
const parts = viewer.viewer.getParts();
console.log(parts); // [{ name: "Seat", materialName: "Leather", color: "#3b82f6", ... }]
Scene Graph Material API (viewer.model)
Once a model is loaded, viewer.model exposes its materials in the
<model-viewer> scene-graph shape. Edits write straight to the live material, so they show up
on the next frame and travel into exportScene().
viewer.addEventListener('scene-graph-ready', async () => {
const material = viewer.model.getMaterialByName('Body');
// Base PBR
material.pbrMetallicRoughness.setBaseColorFactor([0.8, 0.1, 0.1, 1]);
material.pbrMetallicRoughness.setBaseColorFactor('#cc1111'); // hex also works
material.pbrMetallicRoughness.setMetallicFactor(1);
material.pbrMetallicRoughness.setRoughnessFactor(0.25);
// Emission, transparency, culling
material.setEmissiveFactor('#221100');
material.setAlphaMode('BLEND'); // 'OPAQUE' | 'MASK' | 'BLEND'
material.setDoubleSided(true);
// glTF extensions (MeshPhysicalMaterial only; warns otherwise)
material.setClearcoatFactor(1);
material.setTransmissionFactor(0.9);
material.setIor(1.5);
// Textures
const texture = await viewer.createTexture('/textures/carbon.jpg');
material.pbrMetallicRoughness.baseColorTexture.setTexture(texture);
material.normalTexture.setTexture(null); // clear a slot
// Undo everything above. Prefer material.reset() over
// viewer.resetNodeMaterial(name) — most CAD output leaves materials
// unnamed, and a name lookup cannot reach those.
material.reset();
});
// Material variants authored into the glTF
console.log(viewer.availableVariants); // ['Chrome', 'Matte']
viewer.variantName = 'Chrome';
CAD Feature Edges
Toggle crisp CAD wireframe lines with anti-Z-fighting offsets:
// Toggle edges on or off
viewer.toggleEdges();
// Or configure explicitly
viewer.viewer.setEdges(true, {
color: '#38bdf8',
thresholdAngle: 25 // Crease angle in degrees
});
Environment, Skybox & Lighting
// 1. Switch lighting presets ('studio' | 'neutral' | 'sunset' | 'warehouse' | 'night')
viewer.setEnvironmentPreset('sunset');
// 2. Load custom HDR / EXR / WebP environment map
await viewer.setEnvironmentImage('https://cdn.example.com/lighting/studio.hdr');
// 3. Set 360 Skybox Background
await viewer.setSkyboxImage('https://cdn.example.com/skybox/city.jpg');
viewer.toggleSkybox(true);
// 4. Tone mapping & Exposure
viewer.viewer.setExposure(1.2);
viewer.viewer.setToneMapping('aces');
3D Hotspots & Pins
Hotspots automatically project from 3D space to 2D screen pixels and hide when occluded behind the model:
// Add a hotspot programmatically
viewer.addHotspot({
id: 'turbocharger',
position: [0.15, 0.45, -0.1],
normal: [0, 1, 0], // Optional surface normal
element: `
<div class="hotspot-pin">
<span>1</span>
<div class="tooltip">High-Efficiency Turbocharger</div>
</div>
`,
occlude: true, // Auto-hide when behind model
onClick: (id) => alert('Clicked hotspot: ' + id)
});
// Remove a hotspot
viewer.removeHotspot('turbocharger');
// Fly camera smoothly to focus on a hotspot
viewer.focusHotspot('turbocharger');
// Clear all hotspots
viewer.viewer.clearHotspots();
A string element is parsed as HTML (innerHTML). Only pass markup your page itself
controls. Text that came from a user, a URL parameter or a CMS field must be passed with
asText: true, which sets it as plain text (textContent); otherwise it can inject script into the
page. Passing a DOM node you built yourself is always safe.
// A label typed by a user: plain text, never HTML
viewer.addHotspot({
id: 'note-' + note.id,
position: note.position,
element: note.label,
asText: true,
});
Scene Graph Hierarchy & Mesh Visibility
Inspect the full parent-child hierarchy and toggle visibility of any mesh or assembly:
// 1. Get full recursive tree
const tree = viewer.getSceneTree();
console.log(tree); // { name: "Root", children: [{ name: "Engine", polyCount: 12400, ... }] }
// 2. Hide or show specific parts (e.g. for exploded view / x-ray)
viewer.setNodeVisibility('Engine_Cover', false);
viewer.setNodeVisibility('Wheel_Rear_Right', true);
// 3. Reset all nodes to visible
viewer.resetVisibility();
Keyframe Animations (Mixer Control)
Discover, play, cross-fade, and scrub embedded 3D animations:
// 1. List all animation clips in the model
const animations = viewer.getModelAnimations(); // (or viewer.getAnimations() on headless SDK)
console.log(animations); // [{ name: "Run", duration: 1.4, index: 0 }, { name: "Jump", ... }]
// 2. Play with options (loop, speed, cross-fade)
viewer.playAnimation('Run', {
loop: true,
timeScale: 1.5,
crossFadeDuration: 0.4
});
// 3. Playback controls
viewer.pauseAnimation();
viewer.resumeAnimation();
viewer.setAnimationTime(0.75); // Scrub to 0.75 seconds
viewer.stopAnimation();
Camera Rotation & Turntable Constraints
Lock camera rotation to keep the model above ground or restrict horizontal sweep:
viewer.setRotationLimits({
minPolarAngle: '15deg',
maxPolarAngle: '90deg', // Prevents camera from dipping below the floor
minAzimuthAngle: '-45deg', // Restricts horizontal turntable sweep
maxAzimuthAngle: '45deg'
});
Multi-Angle Gallery Generator & Horizontal Row Strip
Automatically generate multi-angle snapshots (front, right, back, left, top, perspective) with clean Blob URLs. Snapshots display one after another in a clean horizontal row, or can be hidden completely so you can render your own custom UI:
// 1. Generate multi-angle snapshots and retrieve Blob URLs
const images = await viewer.generateGalleryImages({
views: ['front', 'right', 'back', 'left', 'top', 'isometric'],
format: 'image/webp',
quality: 0.95
});
// 2. Build your own Custom UI (Row after row, custom thumbnail list, etc.)
const customGalleryRow = document.getElementById('my-custom-gallery');
images.forEach((img) => {
const thumb = document.createElement('img');
thumb.src = img.blobUrl;
thumb.title = img.label;
thumb.onclick = () => {
// Smoothly fly camera to this angle
viewer.setCameraOrbit(img.orbit.theta, img.orbit.phi, img.orbit.radius, true);
};
customGalleryRow.appendChild(thumb);
});
// 3. Built-in Strip Show / Hide Controls
viewer.showGallery(false); // Hide built-in row strip (ideal when using custom UI)
viewer.showGallery(true); // Show built-in horizontal row strip
viewer.toggleGallery(); // Toggle visibility
console.log(viewer.isGalleryVisible()); // Check current visibility state
Interactive Surface Picking (Raycasting)
Listen to surface-click to drop pins exactly where the user clicks:
viewer.addEventListener('surface-click', (e) => {
const { point, normal, meshName } = e.detail;
console.log(`Clicked on ${meshName} at [${point.x}, ${point.y}, ${point.z}]`);
// Drop a new pin at the hit location with fly-to enabled
viewer.addHotspot({
id: 'pin-' + Date.now(),
position: [point.x, point.y, point.z],
normal: [normal.x, normal.y, normal.z],
flyToOnClick: true,
element: '<div class="hotspot-pin">📍</div>'
});
});
Capturing High-Res Screenshots
const dataUrl = viewer.captureScreenshot({
format: 'image/webp',
quality: 0.95
});
// Download image or show in modal
const link = document.createElement('a');
link.download = 'model-render.webp';
link.href = dataUrl;
link.click();
Exporting the Scene
exportScene() writes the model currently in the viewport to any supported format and resolves with a
Blob. Everything you changed through the API — material edits, the active variant, node visibility —
is part of what gets written, so this doubles as a "save my configuration" button on a product configurator.
| Option | Type | Default | Description |
|---|---|---|---|
format |
"glb" | "gltf" | "obj" | "stl" | "ply" | "usdz" |
"glb" |
Output format. CAD sources (STEP, IGES, IFC…) are tessellated, so they export as mesh formats. |
onlyVisible |
boolean |
true |
Skips nodes hidden with setNodeVisibility(). Applies uniformly to every format. |
animations |
boolean |
true |
Writes the model's keyframe clips into glTF/GLB output. |
optimize |
boolean |
false |
Runs the glTF geometry optimizer (prune, dedupe, weld). Loads on demand, so it costs nothing unless used. |
binary |
boolean |
true |
Binary encoding for formats that have both. Only STL is affected — for glTF, format already decides. |
fileName |
string |
source file name | Base name, without extension. |
// Save the configured model as a GLB
const blob = await viewer.exportScene({ format: 'glb', optimize: true });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'configured-model.glb';
link.click();
// Upload it instead
const body = new FormData();
body.append('model', blob, 'configured-model.glb');
await fetch('/api/save-configuration', { method: 'POST', body });
// Rendered frame as a Blob or data URL
const png = await viewer.toBlob({ mimeType: 'image/png' });
const dataUrl = viewer.toDataURL('image/webp', 0.9);
Augmented Reality (WebXR, Scene Viewer & Quick Look)
activateAR() walks ar-modes in order and launches the first mode the device supports.
On a WebXR device the model is presented in-page: a reticle tracks real surfaces, a tap places the model, one finger
rotates it and a pinch scales it (unless ar-scale="fixed"). Everything about the studio viewport —
camera, background, grid, shadows and edges — is restored when the session ends.
On iOS, ios-src is optional: with no .usdz supplied the viewer exports one from the model
currently loaded, so any of the supported CAD/mesh formats can be opened in Quick Look.
const viewer = document.querySelector('universal-3d-viewer');
// Only offer AR when this device can actually run one of the modes
if (await viewer.canActivateAR()) {
arButton.hidden = false;
}
viewer.addEventListener('ar-status', (e) => {
// 'session-started' -> 'object-placed' -> 'not-presenting' ('failed' if nothing could launch)
console.log('AR:', e.detail.status);
});
viewer.addEventListener('ar-tracking', (e) => {
hintEl.hidden = e.detail.status === 'tracking';
});
await viewer.activateAR();
// End the immersive session programmatically
await viewer.exitAR();
Model Cache & Postprocessing
Parsed models are cached by URL across every viewer on the page, so re-opening one skips both the download and the parse — worth the most on CAD formats, where tessellation in the WASM kernel is the slow step. Each load still gets its own geometry and materials, so edits in one viewer never leak into another.
import { Universal3DViewer } from 'universal-3d-viewer';
Universal3DViewer.modelCacheSize = 10; // default 5; 0 disables caching
// Estimated bytes of parsed geometry and textures the cache may hold
// (default 256 MB); least recently used models are evicted beyond it.
Universal3DViewer.modelCacheByteBudget = 128 * 1024 * 1024;
Universal3DViewer.clearModelCache();
// Hand the render pass to a postprocessing composer. You build it from the
// engine's renderer/scene/camera and own its passes; the viewer just calls
// render() each frame instead of renderer.render(), and resizes it with the
// canvas. Rebuild your passes on camera-projection-change — switching
// projection replaces the camera object.
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
const { renderer, scene, camera } = viewer.engine;
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
viewer.registerEffectComposer(composer);
viewer.unregisterEffectComposer(); // back to the plain renderer
6. Custom UI & Headless Integration
You can completely hide the viewer's built-in toolbar by adding no-default-ui and controlling the viewer with your own buttons:
<!-- Headless Viewer -->
<universal-3d-viewer id="my-viewer" src="model.glb" no-default-ui></universal-3d-viewer>
<!-- Bespoke External HTML Controls -->
<div class="custom-control-bar">
<button onclick="document.getElementById('my-viewer').resetCamera()">Reset View</button>
<button onclick="document.getElementById('my-viewer').toggleAutoRotate()">Spin</button>
<button onclick="document.getElementById('my-viewer').toggleEdges()">CAD Lines</button>
<button onclick="document.getElementById('my-viewer').toggleFullscreen()">Fullscreen</button>
</div>
7. Extensibility & Plugin Architecture
Universal 3D Viewer features a modular Plugin Architecture allowing developers to extend the 3D engine with custom tools, render loop updates, scene lifecycle hooks, and automatic Shadow DOM toolbar actions without modifying the core codebase.
The ViewerPlugin Interface & Lifecycle
Any custom extension implements the ViewerPlugin interface:
import { ViewerPlugin, PluginContext, PluginToolbarAction } from 'universal-3d-viewer';
export class CustomWatermarkPlugin implements ViewerPlugin {
public readonly id = 'watermark';
public readonly name = 'Company Watermark Overlay';
public readonly version = '1.0.0';
private context: PluginContext | null = null;
// 1. Initialized with full access to Three.js scene, camera, renderer & overlays
public init(context: PluginContext): void {
this.context = context;
// ...add your meshes or DOM nodes, then ask for a frame (see below)
context.invalidate();
}
// 2. Render loop hook executed before every frame
public update(delta: number): void {
// Animate custom 3D objects, update particle physics, or lerp overlays
}
// 3. Called when a new 3D model is loaded and centered
public onModelLoaded(model: THREE.Group): void {
console.log('Model loaded into scene:', model);
}
// 4. Optional toolbar button automatically injected into the Shadow DOM toolbar
public getToolbarAction(): PluginToolbarAction | null {
return {
id: this.id,
label: 'Toggle Watermark',
iconSvg: '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">...</svg>',
onClick: (active) => console.log('Toggled active:', active)
};
}
// 5. Cleanup when plugin or viewer is disposed
public dispose(): void {
this.context = null;
}
}
The viewer renders on demand. An idle viewer draws no frames, so a plugin that changes the scene
(moves parts, edits materials or clipping planes, adds helpers) must call context.invalidate() afterwards, or
the change stays invisible until something else redraws. needsContinuousRender(), if you implement it, only
keeps an animation that is already running alive: it is consulted at the end of a frame that is being drawn and
cannot wake a parked loop. A plugin that starts an animation calls context.invalidate() once and returns
true from needsContinuousRender() until the animation ends.
Registering Plugins
Plugins can be registered programmatically on the SDK or declarative Web Component:
import { Universal3DViewer, MeasurementPlugin, ExplodedViewPlugin, ClippingPlanePlugin } from 'universal-3d-viewer';
// 1. Programmatic registration on Universal3DViewer or <universal-3d-viewer>
const viewer = new Universal3DViewer({ container: document.getElementById('viewer-div') });
await viewer.use(new MeasurementPlugin({ unit: 'mm' }));
await viewer.use(new ExplodedViewPlugin());
await viewer.use(new ClippingPlanePlugin());
// Retrieve an active plugin instance at any time:
const measurement = viewer.getPlugin('measurement');
measurement.activate();
// Or remove a plugin:
viewer.removePlugin('measurement');
Or declaratively using the plugins HTML attribute on <universal-3d-viewer>:
<!-- Automatically instantiates and registers Measurement, Exploded View, and Clipping Plane -->
<universal-3d-viewer
src="assembly.glb"
camera-controls
plugins="measurement,exploded,clipping">
</universal-3d-viewer>
Built-in: 3D Tape Measure (MeasurementPlugin)
Enables interactive point-to-point dimensioning on any 3D model surface with real-time 2D floating badges and metric/imperial unit formatting:
import { MeasurementPlugin } from 'universal-3d-viewer';
const measure = new MeasurementPlugin({ unit: 'mm' });
await viewer.use(measure);
measure.activate(); // Crosshair cursor mode
measure.setUnit('in'); // Switch between 'mm', 'cm', 'm', 'in'
console.log(measure.getMeasurement()); // { distance: 124.5, formatted: "124.5 mm", ... }
measure.clear(); // Clear measurements and markers
// Three measurement modes
measure.setMode('distance'); // two points (default)
measure.setMode('angle'); // three points; the middle one is the vertex
measure.setMode('path'); // any number of points, finished explicitly
measure.setUnit('mm'); // 'mm' | 'cm' | 'm' | 'in' — relabels existing readings
// A path ends on Enter, on Escape, or programmatically
measure.finishPath();
// Measurements accumulate rather than replacing each other
const all = measure.getMeasurements();
measure.removeMeasurement(all[0].id);
measure.cancelActive(); // abandon the one being placed
measure.clear(); // remove every measurement
viewer.addEventListener('measurement-change', (e) => {
if (!e.detail) return;
const { type, distance, angle, totalLength, formatted } = e.detail;
label.textContent = formatted; // "25.40 mm" or "90.0°"
});
Built-in: Exploded Assembly View (ExplodedViewPlugin)
Calculates radial mesh offsets from the model center and smoothly translates parts outwards with smooth easing:
import { ExplodedViewPlugin } from 'universal-3d-viewer';
const exploded = new ExplodedViewPlugin();
await viewer.use(exploded);
exploded.setExplosion(0.75); // Set explosion factor (0.0 = assembled, 1.0 = fully disassembled)
exploded.animateTo(1.0, 0.8); // Smoothly animate to exploded state over 0.8 seconds
exploded.reset(); // Instantly reassemble
Built-in: Cross-Section Cutaway (ClippingPlanePlugin)
Slices through 3D meshes along X, Y, or Z axis with filled edges, essential for architectural BIM and mechanical CAD inspection:
import { ClippingPlanePlugin } from 'universal-3d-viewer';
const clipping = new ClippingPlanePlugin();
await viewer.use(clipping);
clipping.enable(); // Activate section cutting plane
clipping.setAxis('z'); // Cut along 'x', 'y', or 'z' axis
clipping.setOffset(0.25); // Position plane (-1.0 to 1.0 across model bounds)
clipping.flip(); // Invert cut normal direction
// Cut on more than one axis at once — the planes intersect
clipping.addPlane('x', 0.5); // a second cut, on another axis
clipping.setPlaneOffset('x', -0.2);
clipping.flipPlane('x');
clipping.removePlane('x');
// Or reduce the model to a box: an opposed pair of planes on every axis
clipping.setBoxClip(-0.5, 0.5);
console.log(clipping.getPlaneCount()); // 6
clipping.clearExtraPlanes(); // back to the single primary cut
Custom 3D Format Loaders
Extend the viewer's file ingestion pipeline with custom CAD parsers, point clouds, or proprietary formats at runtime:
import { Universal3DViewer } from 'universal-3d-viewer';
// Register custom format loader adapter
Universal3DViewer.registerLoader({
id: 'custom-format-adapter',
supportedExtensions: ['.custom3d', '.myformat'],
canHandle(file, magicBytes) {
return file.name.endsWith('.custom3d');
},
async load(file, onProgress) {
const group = new THREE.Group();
// Parse custom file buffer and construct Three.js meshes...
return { scene: group, animations: [], stats: { polyCount: 500, meshCount: 1 } };
}
});
8. Shopify Liquid Integration
Drop the following snippet into snippets/universal-3d-viewer.liquid in your Online Store 2.0 theme
(the full version, with variant swatches, is examples/shopify/snippet.liquid in the repository).
It loads the UMD bundle as a classic script; the ES build cannot be loaded with
<script type="module"> because it imports the bare specifier three.
{%- assign model_media = product.media | where: "media_type", "model" | first -%}
{%- if model_media -%}
{%- comment -%}
The UMD bundle: a classic script with three.js inside that registers the element.
Pinned to 0.2.0; the URL resolves once that version is published to npm.
{%- endcomment -%}
{%- assign u3d_cdn = 'https://cdn.jsdelivr.net/npm/universal-3d-viewer@0.2.0' -%}
<script src="{{ u3d_cdn }}/dist/library/universal-3d-viewer.umd.js" defer></script>
<universal-3d-viewer
id="product-viewer-{{ product.id }}"
src="{{ model_media.sources[0].url }}"
asset-base-path="{{ u3d_cdn }}/public/"
poster="{{ model_media.preview_image | image_url: width: 1000 }}"
alt="{{ product.title | escape }}"
camera-controls
auto-rotate
ar
loading="lazy"
environment-preset="studio"
style="width: 100%; height: 500px;"
>
<button slot="ar-button" class="shopify-ar-pill">View in your room (AR)</button>
</universal-3d-viewer>
<script>
// Live product variant switching
document.addEventListener('variant:change', function(e) {
if (e.detail && e.detail.variant) {
const color = e.detail.variant.metafields.custom.color_hex;
const viewer = document.getElementById('product-viewer-{{ product.id }}');
if (viewer && color) {
viewer.setPartColor('Body', color);
}
}
});
</script>
{%- endif -%}
9. WordPress Shortcode & Gutenberg Block
Use the shortcode anywhere in WordPress posts, pages, or WooCommerce products:
[universal_3d_viewer
src="https://example.com/models/watch.glb"
poster="https://example.com/models/watch-poster.webp"
camera_controls="true"
auto_rotate="true"
edges="true"
environment_preset="studio"
ar="true"
height="550px"
]
10. React & Vue 3 Integration
React (React 18 / 19)
import React, { useRef } from 'react';
import 'universal-3d-viewer';
export function Product3DViewer({ modelUrl, posterUrl }: { modelUrl: string; posterUrl: string }) {
const viewerRef = useRef(null);
const handleReset = () => {
viewerRef.current?.resetCamera();
};
return (
<div style={{ width: '100%', height: '500px', position: 'relative' }}>
<universal-3d-viewer
ref={viewerRef}
src={modelUrl}
poster={posterUrl}
camera-controls
auto-rotate
ar
style={{ width: '100%', height: '100%' }}
>
<button slot="hotspot-details" data-position="0 0.5 0">
Feature Info
</button>
</universal-3d-viewer>
</div>
);
}
Vue 3
<template>
<div class="viewer-wrapper">
<universal-3d-viewer
ref="viewer"
:src="modelSrc"
camera-controls
auto-rotate
@surface-click="onSurfaceClick"
></universal-3d-viewer>
</div>
</template>
<script setup>
import { ref } from 'vue';
import 'universal-3d-viewer';
const modelSrc = ref('https://example.com/model.glb');
const viewer = ref(null);
function onSurfaceClick(event) {
console.log('Surface clicked:', event.detail.point);
}
</script>
11. 3-Tier Multi-Format Support Matrix
| Tier | Formats | Engine / Kernel | Bundle Loading Strategy |
|---|---|---|---|
| Tier 1 (Core) | .glb, .gltf, .obj, .stl, .ply, .fbx |
Native Three.js + Draco + Meshopt | In the main bundle, parsed on the page's thread. The Draco, Meshopt and Basis decoders load only for files that use them. |
| Tier 2 (CAD/BIM) | .step, .stp, .iges, .brep, .ifc |
OpenCascade WASM & web-ifc WASM | Dynamic On-Demand: WASM kernel loaded only when a CAD/BIM file is opened, and run in a Web Worker (on the page's thread where no worker can start). |
| Tier 3 (Universal) | 24 legacy formats (.3ds, .dae, .dxf, .lwo, .x, .off, etc.) |
Assimp WebAssembly Normalizer | Dynamic On-Demand: WASM loaded on first use and run in a Web Worker, where the file is converted to a GLB buffer (on the page's thread where no worker can start). |