ModelViewer
The primary exported component. Renders a GLB, GLTF, OBJ, FBX, USDZ, STL, or PLY model with orbit/pan/zoom controls, built-in side panels, and a full callback API.
Presets and grouped props
preset sets ModelViewer up for a common kind of page:
| Preset | For | What it sets |
|---|---|---|
minimal | Just the model | Hides both panels, the download and reset buttons, tour controls, and the material variant buttons |
showcase | A product on display | Everything minimal does, plus auto-rotation |
configurator | Customers changing parts | Shows the selected object’s panel, the reset button, and the material variant buttons; hides the scene objects panel and download |
inspector | Checking a model | Every panel, the view gizmo, the measure, UV checker, exploded-view, section-cut, and isolate tools, and keyboard navigation |
MODEL_VIEWER_PRESETS lists exactly which props each preset sets.
Related props are also available as groups, which keeps a configured viewer readable:
<ModelViewer
modelUrl="/car.glb"
licenseKey="your-license-key"
preset="configurator"
ui={{ download: "car.glb" }}
tools={{ measure: { unit: "cm" } }}
controls={{ autoRotate: 0.5, zoom: false, limits: { maxDistance: 20 } }}
perf={{ profile: "low" }}
xr={{ enabled: true, usdzUrl: "/car.usdz" }}
/>| Group | Fields, and the flat prop each one sets |
|---|---|
ui | objectPanel (showObjectBindingDataPanel), sceneObjects (showSceneObjectsPanel), download (showDownloadButton; a string also sets downloadFilename), reset (showResetButton), fullscreen (showFullscreenButton), loadingOverlay (showLoadingOverlay), viewGizmo (showViewGizmo), mouseController (showMouseController; { position, opacity } also sets mouseControllerPosition and mouseControllerOpacity), annotationNavigation (showAnnotationNavigation), annotationOnHover (showAnnotationOnHover), annotationFilters (showAnnotationFilters), highlightOnHover, materialVariants (showMaterialVariants), objectNavigator (showObjectNavigator) |
tools | measure (showMeasureTools; { unit } also sets measurementUnit), uvChecker (showUvCheckerButton), explode (showExplodeControls), section (showSectionTools), isolate (showIsolateControls), texturePositioning (texturePositioning) |
controls | enabled (enableCameraControls), keyboard (enableKeyboardNavigation), zoom (the opposite of disableZoom), zoomOnSelect (zoomOnSelected), autoRotate (a number also sets autoRotateSpeed), refitOnResize, limits (cameraLimits), moveSensitivity, zoomSensitivity, reducedMotion |
perf | renderMode, maxDpr, profile (performanceProfile), preserveDrawingBuffer, webgpu |
tiles | url (tilesetUrl), errorTarget (tilesErrorTarget), cacheSize (tilesCacheSize) |
xr | enabled (enableXR), usdzUrl, scaleMode (arScaleMode), mobileHandoffUrl |
decoders | draco (dracoDecoderPath), ktx2 (ktx2TranscoderPath), meshopt |
A preset only fills in defaults: a flat prop overrides it, and a grouped prop overrides both. The flat props keep working exactly as before.
Props
type Props = {
preset?: "minimal" | "showcase" | "configurator" | "inspector";
ui?: ModelViewerUiOptions;
tools?: ModelViewerToolsOptions;
controls?: ModelViewerControlsOptions;
perf?: ModelViewerPerfOptions;
tiles?: ModelViewerTilesOptions;
xr?: ModelViewerXrOptions;
decoders?: ModelViewerDecoderOptions;
modelUrl: string;
poster?: string;
loading?: "lazy" | "eager"; // default "lazy"
reveal?: "auto" | "interaction"; // default "auto"
labels?: ModelViewerLabels;
tilesetUrl?: string | null;
tilesErrorTarget?: number;
tilesCacheSize?: number;
onObjectCatalog?: (objects: ViewerObjectInfo[]) => void;
modelFormat?: "glb" | "gltf" | "obj" | "fbx" | "usdz" | "stl" | "ply";
licenseKey: string;
objectBindings?: Record<string, ObjectBinding> | "auto"; // default "auto"
selectedObject?: ObjectBinding | null;
panelHoveredObject?: ObjectBinding | null;
onObjectBindingsChange?: (next: Record<string, ObjectBinding>) => void;
onObjectSelect?: (binding: ObjectBinding | null) => void;
onObjectHover?: (binding: ObjectBinding | null) => void;
onObjectDoubleClick?: (event: ObjectPointerEvent) => boolean | void;
onObjectContextMenu?: (event: ObjectPointerEvent) => void;
onModelLoaded?: (model: ViewerModelInfo) => void;
onLoadError?: (error: unknown) => void;
onProgress?: (event: ProgressEvent) => void;
cameraControllerType?: "orbit" | "pointerLock";
onCameraControllerChange?: (
controller: "orbit" | "pointerLock",
) => void;
onAction?: (event: ObjectActionEvent) => void;
onEngagement?: (event: ViewerEngagementEvent) => void | Promise<void>;
debugEngagement?: boolean;
onHiddenObjectsChange?: (next: Record<string, boolean>) => void;
onCameraChange?: (state: ViewerCameraState) => void;
onViewerReady?: (viewer: ViewerReadyState) => void;
onTextureUpload?: (file: File, objectId: string) => Promise<string>;
texturePositioning?: boolean | TexturePositioningControls;
onAnimationsReady?: (controls: AnimationControls) => void;
onAnnotationsChange?: (annotations: AnnotationMarker[]) => void;
activeAnnotation?: AnnotationMarker | null;
onActiveAnnotationChange?: (annotation: AnnotationMarker | null) => void;
annotationCategories?: string[] | null;
onAnnotationCategoriesChange?: (categories: string[] | null) => void;
showAnnotationFilters?: boolean;
camera?: ViewerCameraConfig;
cameraLimits?: CameraLimits;
backgroundColor?: string;
shadows?: boolean;
showObjectBindingDataPanel?: boolean;
customObjectBindingDataPanel?: (
props: CustomObjectBindingDataPanelProps,
) => React.ReactNode;
customSceneObjectsPanel?: (
props: CustomSceneObjectsPanelProps,
) => React.ReactNode;
showSceneObjectsPanel?: boolean;
showDownloadButton?: boolean;
downloadFilename?: string;
showResetButton?: boolean;
showFullscreenButton?: boolean;
showLoadingOverlay?: boolean;
showMouseController?: boolean;
mouseControllerPosition?:
| "bottom-left"
| "bottom-right"
| "top-left"
| "top-right"
| "center"
| "center-bottom"
| "center-top";
mouseControllerOpacity?: number;
moveSensitivity?: number;
zoomSensitivity?: number;
sceneConfig?: SceneConfig;
disableZoom?: boolean;
zoomOnSelected?: boolean;
highlightOnHover?: boolean;
enableCameraControls?: boolean;
moveModeEnabled?: boolean;
objectTransformSpace?: "local" | "world";
enableKeyboardNavigation?: boolean;
showObjectNavigator?: boolean;
reducedMotion?: "user" | "always" | "never";
onAutoFit?: () => Promise<boolean>;
refitOnResize?: boolean;
renderMode?: "always" | "demand";
maxDpr?: number;
preserveDrawingBuffer?: boolean;
webgpu?: boolean;
performanceProfile?: "auto" | "high" | "low";
dracoDecoderPath?: string | false;
ktx2TranscoderPath?: string | false;
meshopt?: boolean;
showMeasureTools?: boolean;
showUvCheckerButton?: boolean;
showExplodeControls?: boolean;
showSectionTools?: boolean;
isolation?: ViewerIsolation | null;
onIsolationChange?: (isolation: ViewerIsolation | null) => void;
showIsolateControls?: boolean;
showMaterialVariants?: boolean;
cinematic?: boolean | CinematicConfig;
measurementUnit?: string;
enableXR?: boolean;
usdzUrl?: string;
arScaleMode?: "normalized" | "real-world";
mobileHandoffUrl?: string;
showAnnotationNavigation?: boolean;
showAnnotationOnHover?: boolean;
onSceneConfigChange?: (config: SceneConfig) => void;
showViewGizmo?: boolean;
overlay?: React.ReactNode;
unsafe_sceneChildren?: React.ReactNode;
autoRotate?: boolean;
autoRotateSpeed?: number;
};labels
Optional plain-text overrides for built-in viewer text, including button titles, accessible labels, loading messages, tool hints, and AR placement controls. Missing keys retain their English defaults.
ModelViewer and SimpleModelViewer share the same dictionary. Each viewer has its own labels, and changing the prop updates text without reloading its model. See the full label catalog for every key, default, and named placeholder.
labels does not translate annotation content, object metadata, custom panels, or raw server/loader diagnostics. Supply translations for those in your application.
<ModelViewer
licenseKey="your-license-key"
modelUrl="/car.glb"
labels={{
guidedTour: "Visite guidée",
tapToPlace: "Touchez pour placer",
sceneSettings: "Réglages",
objectCount: "Objets : {count}",
}}
/>modelUrl
Required. URL or public path to a .glb, .gltf, .obj, .fbx, .usdz, .stl, or .ply model. Hosted assets must serve external buffers, MTL files, and textures at their declared relative paths; USDZ dependencies are packaged internally.
<ModelViewer modelUrl="/model.glb" ... />modelFormat
Optional explicit format for extensionless or signed URLs. Supported file extensions are detected automatically in normal URLs.
<ModelViewer modelUrl={signedUrl} modelFormat="fbx" ... />This is separate from usdzUrl: modelUrl renders USDZ inside the viewer,
while usdzUrl launches the native Apple Quick Look experience when XR is
enabled.
STL and PLY
STL and PLY files hold geometry only: no object names, materials or animations.
- One object per file, named after the file:
bracket.stlloads as one object keyedbracket, which its bindings use. A text STL with several named solids loads one object per solid. A.zipholding one STL or PLY file also works. - Colour: objects start a matte grey. A PLY file’s vertex colours show as they are, and picking a colour (or texture) replaces them.
- No texture coordinates: STL files have none and PLY files rarely do, so an image can’t be mapped onto them. The viewer’s Change Material says so instead of offering an upload, BindingBuilder turns off its image map fields for that object, and new bindings leave Change Material out. Decals work without texture coordinates. This applies to any object without them, in any format.
- Units and orientation: STL files are usually in millimetres with Z up, so a
part can load lying on its back. Turn it upright with
sceneConfig.model.rotation(BindingBuilder: Scene → model rotation, −90° on X), and setmeasurementUnitto"mm". In AR, the default"normalized"arScaleModefits the model to a metre;"real-world"would read millimetres as metres. - PLY point clouds (points with no faces) can’t be shown; the viewer explains why instead of loading them.
3D Tiles streaming
Use tilesetUrl to stream a processed 3D Tiles model instead of downloading
modelUrl as one monolithic asset. modelUrl remains required by the component
API, but tilesetUrl is the render source while streaming is enabled.
<ModelViewer
modelUrl="/models/campus.glb"
tilesetUrl="/models/campus/tileset.json"
tilesErrorTarget={8}
tilesCacheSize={800}
onObjectCatalog={(objects) => console.log(objects)}
{...props}
/>tilesErrorTargetcontrols the screen-space error target. Lower values load sharper tiles and use more bandwidth/GPU memory. Default:8.tilesCacheSizeis the maximum number of parsed tiles kept in the client cache. Default:800.onObjectCatalogreceives the bindable-object catalog fromobjects.jsonbefore all referenced tiles have loaded. SeeonObjectCatalog.
Bindings, selection, hover, material overrides, transforms, exploded view, and
scene renderer modes use the same public API in streamed and monolithic modes.
The tileset and its tile payloads must be browser-accessible, and the optional
object catalog must sit beside tileset.json as objects.json.
licenseKey
Required. License key for the library.
<ModelViewer licenseKey="your-license-key" ... />To get a license key, sign in at the React Immersive Console , choose a plan, and generate a key from your dashboard.
The browser validates the key against
https://react-immersive.liveroom.dev/api/validate-license. A successful
result is cached in memory for five minutes and may include a signed,
domain-and-key-scoped offline grant valid for up to 12 hours. If validation is
temporarily indeterminate, an unexpired signed grant is used immediately when
available. Without one, the SDK retries once after a 400 ms backoff; each
attempt has a five-second timeout. Timeouts, transport failures, 429, 5xx,
and malformed responses are retryable, while explicit licence denials are not.
A first-time visitor without a valid grant fails closed if both attempts fail.
objectBindings
A map keyed by the model node identifier, or "auto", the default (see
Generating bindings automatically). A uniquely named mesh
keeps its authored name. When a model contains duplicate node names, each
duplicate receives a stable traversal-order identifier such as Cube [1] and
Cube [2]; the original Object3D.name is not mutated, so animation tracks
continue to work. BindingBuilder generates these identifiers automatically
and the viewer logs the duplicate-name mapping to the console.
objectBindings is the single source of truth for all visual state:
| Field | Effect |
|---|---|
visible | Controls mesh visibility |
style.material.baseColor | Sets mesh color (committed by the built-in color picker on close) |
style.material.texture.path | Sets the base color (albedo) texture |
style.material.* | Per-object MeshPhysicalMaterial overrides, see below |
cameraState.position / cameraState.target | Camera view to use when this object is focused or selected from the viewer UI |
All visual overrides live under style.material (an ObjectBindingMaterial).
It covers base color/PBR, emission, opacity & blending, normal/bump,
displacement, ambient occlusion, clearcoat, sheen, anisotropy,
transmission/volume, and face sidedness, plus a texture-map slot for each.
See ObjectBindingMaterial for the
full field list and the per-slot color-space handling (color maps are decoded
as sRGB, data maps stay linear).
Generating bindings automatically
objectBindings defaults to "auto": leave it out and the viewer generates a
binding for every mesh when the model loads:
<ModelViewer modelUrl="/model.glb" licenseKey={licenseKey} />Each generated binding is keyed by the node identifier, with a readable
label, an inferred type, a unique id, and built-in actions
(change-color, change-material, toggle-visibility, or the three light
actions for meshes whose names contain “light”). For a streamed model
(tilesetUrl), bindings come from the object catalog.
The generated bindings are reported through onObjectBindingsChange. To keep
and refine them, store them and pass them back. After the first change the
viewer is controlled by your state; set it back to "auto" when you switch
models:
const [objectBindings, setObjectBindings] = useState<
Record<string, ObjectBinding> | "auto"
>("auto");
<ModelViewer
modelUrl={modelUrl}
licenseKey={licenseKey}
objectBindings={objectBindings}
onObjectBindingsChange={setObjectBindings}
/>;While the prop is "auto", a reload of the same model keeps the bindings it
already had, so runtime edits survive; a different modelUrl starts fresh.
buildBindingsFromNodes, createDefaultBinding, and inferType are exported
from @liveroom-tech/react-immersive/utils for building the same defaults
yourself. They’re the same defaults BindingBuilder generates.
const objectBindings = {
Object_2: {
id: "obj-2",
modelObjectId: "Object_2",
type: "body",
visible: true,
cameraState: {
position: [2.8, 1.6, 4.2],
target: [0, 0.8, 0],
},
style: {
material: {
baseColor: "#ff0000",
texture: { path: "/materials/body-finish.jpg" },
roughness: 0.4,
clearcoat: 1,
},
},
actions: [
{ id: "change-color", label: "Change Color", type: "command" },
{ id: "toggle-visibility", label: "Toggle Visibility", type: "command" },
],
metrics: {},
metadata: {},
},
};selectedObject
Optional currently selected object binding, or null when nothing is selected.
When provided, ModelViewer behaves as a controlled component. When omitted, it manages its own selection state internally.
When the selected object has a cameraState with both position and target, the viewer uses that saved camera view for focus/select behavior instead of falling back to fitToBox.
panelHoveredObject
Optional externally controlled binding to highlight as hovered by a custom
scene-objects panel. It takes precedence over the built-in panel and canvas
hover while highlightOnHover is enabled. Pass null when no panel row is
hovered.
onObjectSelect
Called when the user clicks a mesh or closes the side panel.
onObjectSelect?: (binding: ObjectBinding | null) => void- Clicking a mesh →
onObjectSelect(binding) - Closing the panel →
onObjectSelect(null) - Clicking the same mesh again still fires; selection is not toggled off automatically
- If the binding includes
cameraState.positionandcameraState.target, the viewer moves the camera to that saved view when focusing that object - Clicks resolve the front-most surface under the pointer; a mesh whose binding has
selectable: falsedoes not pass the click through to meshes behind it - On very large models, clicking meshes in the viewport is disabled entirely, see Large models. Selection through the scene objects panel (and this callback) still works
onObjectHover
Called when the user hovers a mesh.
onObjectHover?: (binding: ObjectBinding | null) => void- Pointer over a mesh →
onObjectHover(binding) - Pointer out →
onObjectHover(null) - On very large models, viewport hover is disabled entirely, see Large models
onObjectDoubleClick
Optional callback called when the user double-clicks an object.
onObjectDoubleClick?: (event: ObjectPointerEvent) => boolean | void
type ObjectPointerEvent = {
binding: ObjectBinding;
clientX: number; // viewport coordinates
clientY: number;
pointerType: string; // "mouse" | "pen" | "touch"
surface?: { position: Vec3; normal: Vec3 }; // where on the object, in its own space
};Return true to say you handled it: the viewer then skips its own double-click
behavior, which moves the camera’s orbit point to that spot (or starts texture
positioning, when texturePositioning is on). Return nothing to keep it.
onObjectContextMenu
Optional callback called when the user right-clicks an object, or long-presses it on a touch screen. Use it to open your own menu:
<ModelViewer
{...props}
onObjectContextMenu={({ binding, clientX, clientY }) =>
openMenu({ for: binding.id, x: clientX, y: clientY })
}
/>It receives the same ObjectPointerEvent as onObjectDoubleClick, with the
position where the gesture started, ready for a position: fixed menu. A
right-click fires when the button is released without the pointer moving, so a
right-drag still pans the camera. A long-press fires after half a second, and
not if the finger moves or a second finger touches down (a pinch); lifting the
finger afterwards doesn’t select the object. Objects with selectable: false
don’t fire it.
onObjectBindingsChange
Fired when the viewer’s built-in UI updates binding data. Wire this back into your state to keep the viewer and your app in sync.
onObjectBindingsChange?: (next: Record<string, ObjectBinding>) => voidFires for:
- Visibility toggles (
binding.visible) - Color picks (
binding.style.material.baseColor), committed when the color picker closes - Texture uploads (
binding.style.material.texture.path) - Texture removal
- Object movement and rotation (
binding.transform.positionandbinding.transform.rotation)
Passing this callback makes binding edits controlled: update the
objectBindings prop with the returned record. If it is omitted, the viewer
keeps built-in edits in internal state for the lifetime of the mounted viewer.
onHiddenObjectsChange
Fired with the derived hidden-object map whenever binding visibility state changes.
onHiddenObjectsChange?: (next: Record<string, boolean>) => voidonObjectCatalog
Lists every bindable object in the model: each named mesh, under the identifier
objectBindings is keyed by. It’s the quickest way to find out what your meshes
are called:
<ModelViewer
modelUrl="/model.glb"
licenseKey={licenseKey}
onObjectCatalog={(objects) => console.table(objects)}
/>It fires each time a model loads. For a streamed model (tilesetUrl), the list
comes from objects.json, before the tiles containing those objects have
necessarily loaded. Each entry is a
ViewerObjectInfo. To keep the list in
state, pass useViewerModel’s
handleObjectCatalog.
onModelLoaded
Called after the model has loaded, with its bounds and mesh count.
onModelLoaded?: (model: ViewerModelInfo) => void
type ViewerModelInfo = {
bounds: { min: Vec3; max: Vec3; center: Vec3; size: Vec3 }; // world space
objectCount: number; // meshes in the loaded scene
materialVariants: string[]; // glTF material variant names, if any
materialVariantGroups: MaterialVariantGroup[]; // the same, as option groups
};For the model’s object names, use onObjectCatalog.
onLoadError
Called if the model fails to load or render.
onLoadError?: (error: unknown) => voidWhether or not you pass it, ModelViewer shows a message in place of the model
that explains the likely cause: a missing file (404), refused access (401/403),
a blocked cross-origin request (CORS), a web page served instead of a model, or
a file that isn’t the expected format. It includes the URL and the underlying
error. Set showLoadingOverlay={false} to show your own instead.
onProgress
Called with the loader’s ProgressEvent while a monolithic model is being
downloaded. When event.lengthComputable is true, progress is
event.loaded / event.total; the built-in loading overlay uses the same value.
onProgress?: (event: ProgressEvent) => voidonAction
Called when a built-in action button is clicked from the side panel.
type ObjectActionEvent = {
objectId: string;
action: ObjectBindingAction;
binding?: ObjectBinding;
screenX?: number;
screenY?: number;
};
onAction?: (event: ObjectActionEvent) => voidModelViewer performs change-color, change-material, add-text,
add-image, toggle-visibility, toggle-light, change-light-color, and
set-brightness itself. add-text and add-image let the viewer’s user put
their own text or image on the object (see Decals). The light
actions turn the object’s light on and off, open a color picker, and open a
0–200% brightness slider. If scene lights are attached to the object they
control those, updating sceneConfig.lighting (through onSceneConfigChange if
you pass it, otherwise inside the viewer); otherwise they control the object’s
own glow, saved on its binding as style.material.emissive and
emissiveIntensity. Any other action id does something only when
you handle it here (pass useViewerActions().handleAction to run an action’s
declared effects). Without onAction, those buttons do nothing, and the
viewer logs a console warning naming each one. ACTION_CATALOG and
findCatalogAction, from @liveroom-tech/react-immersive/utils, list the ids
BindingBuilder offers and whether the viewer or your app handles each one.
onEngagement / debugEngagement
Optional interaction analytics. Set onEngagement to receive typed
ViewerEngagementEvent objects, or set debugEngagement to log them in the
browser’s developer console with the [react-immersive engagement] prefix.
Both are disabled by default. The viewer does not send telemetry or persist
analytics identifiers; your callback decides where events go.
import { ModelViewer, type ViewerEngagementEvent } from "@liveroom-tech/react-immersive";
function handleEngagement(event: ViewerEngagementEvent) {
if (event.type === "part-viewed") {
console.info("Part viewed:", event.objectKey, event.source);
}
if (event.type === "ar-ended") {
console.info("Time in AR:", event.durationMs);
}
// Connect this callback to your application's analytics integration.
}
<ModelViewer
modelUrl="/car.glb"
licenseKey={licenseKey}
onEngagement={handleEngagement}
debugEngagement
/>Every event includes sessionId (an ephemeral ID for this viewer/model),
timestamp (ISO format), elapsedMs, modelUrl, and tilesetUrl (or null).
Durations use a monotonic clock and are expressed in milliseconds. Session
elapsed time includes loading and background time; it is not an attention metric.
Event type | Details and trigger |
|---|---|
part-viewed | objectId, objectKey, optional label, and source ("canvas", "panel", or "keyboard"). Selecting/focusing a different part; reselecting the current part is ignored. |
variant-chosen | variant, previousVariant (names, or null for the default), and group (the option group’s name, or null). Choosing or clearing a built-in material variant; previousVariant is the earlier choice in the same group. |
ar-started | A confirmed immersive WebXR AR session begins. |
ar-ended | durationMs and reason ("session-ended" or "interrupted"). A confirmed AR session ends, or tracking stops on model change, unmount, or context loss. |
ar-launch-requested | channel ("quick-look" or "mobile-handoff"). Opening external AR or its QR handoff; this does not claim an AR session started. |
tour-started | annotationCount. Starting the guided tour. |
tour-completed | annotationCount and durationMs. Advancing past its final annotation; emitted once per completed tour. |
tour-stopped | annotationCount, durationMs, and reason ("stopped", "restarted", "annotations-changed", or "interrupted"). Leaving a tour before completion. |
Hover, camera motion, renders, and externally supplied selection/variant updates
do not create engagement events. WebXR duration measures the session interval;
Quick Look runs outside the page and has no reliable duration callback. Turning
tracking on during an existing AR session measures from attachment. Callback
changes do not restart timing. Synchronous or asynchronous callback failures
are logged without interrupting the viewer. A new model or re-enabling tracking
creates a new analytics session. WebGL recovery keeps the session ID; context
loss ends active intervals as interrupted. See
ViewerEngagementEvent for
the complete event union.
onViewerReady
Called once camera controls are available and again when the viewer publishes updated ready-state data (for example after model load, camera changes, or binding changes).
onViewerReady?: (viewer: ViewerReadyState) => void
type ViewerReadyState = {
objectBindings: Record<string, ObjectBinding>;
homeCameraState: { position: Vec3; target: Vec3 } | null;
captureImage: (options?: CaptureImageOptions) => Promise<string>;
captureVideo: (options?: CaptureVideoOptions) => Promise<Blob>;
invalidate: () => void;
rendererBackend: "webgl" | "webgpu"; // which renderer draws; see `webgpu`
raw: {
controls: CameraControls; // camera-controls
scene: Object3D | null; // three.js
nodes: Record<string, Object3D>; // model nodes by binding key and name
};
};
type CaptureImageOptions = {
width?: number;
height?: number;
transparent?: boolean;
};raw holds the live three.js scene, the camera-controls instance, and the
model’s nodes, for integrations the rest of the API doesn’t cover. You don’t
need it for anything the hooks and props already do, and its shapes are those
libraries’ own, so they can change when those libraries do.
captureImage requires the opt-in preserveDrawingBuffer prop (not needed with webgpu), forces a render, and resolves with a data:image/png URL of the canvas. Pass width/height to render at a resolution other than the canvas’s current size, and transparent to hide the configured background so the PNG has an alpha channel. It rejects if called before the viewer is ready or when framebuffer preservation is disabled.
<ModelViewer
preserveDrawingBuffer
onViewerReady={(viewer) => {
viewer
.captureImage({ width: 1920, height: 1080, transparent: true })
.then((dataUrl) => {
// upload, preview, etc.
});
}}
/>;For turning the current camera/visibility/selection into a shareable link, see useShareableViewerState.
Video export
captureVideo records one complete cinematic pass or turntable revolution and
resolves with a video Blob. It is also available on useViewerConnection and
useViewer().connection. Call it from a button handler after the model has
finished loading and framing; ready-state callbacks run repeatedly as the camera
moves and should not start recordings automatically.
import { ModelViewer, useViewerConnection } from "@liveroom-tech/react-immersive";
const connection = useViewerConnection();
const abort = new AbortController();
<ModelViewer
modelUrl="/car.glb"
licenseKey={licenseKey}
cinematic
onViewerReady={connection.handleViewerReady}
/>;
// Run in a button handler after loading and framing have finished.
const clip = await connection.captureVideo({
mode: "turntable", // or "cinematic" to record the enabled camera path
format: "auto", // prefers WebM; falls back to MP4. "gif" for an animated GIF
duration: 12, // seconds; defaults to path timing or a 24-second orbit
fps: 30,
videoBitsPerSecond: 8_000_000,
signal: abort.signal,
});
// Preview/upload the Blob; abort.abort() cancels and discards an active export.The download menu offers Record turntable video and, when cinematic is
enabled, Record cinematic video, plus Auto/WebM/MP4/GIF format selection. A
recording shows progress and a Cancel control. The filename and Blob MIME type
match the encoded format; an explicitly requested unsupported format rejects
instead of silently changing formats. With loop: true, a cinematic export
includes the return segment and stops after one cycle.
Recording runs in real time at the canvas’s current pixel resolution, includes
post-processing, and hides editor gizmos. It pauses cinematic playback, blocks
viewer gestures, then restores the previous camera pose and controls on success,
cancellation, or failure. Keep the tab visible until recording completes.
preserveDrawingBuffer is unnecessary for video. No audio, HTML overlays, or
format transcoding is included. The browser must support
canvas.captureStream, and MediaRecorder for WebM/MP4; exports reject before framing,
during XR, during another recording, or for durations outside (0, 300] seconds.
See CaptureVideoOptions for
the complete options and defaults.
Animated GIF. Pass format: "gif" (or pick GIF in the menu’s format
select) for a GIF that loops forever, for places that don’t play video, such as
email, chat, and some marketplaces:
const gif = await connection.captureVideo({
mode: "turntable",
format: "gif",
gifWidth: 480, // pixels; the height follows the canvas. Default 480
// fps defaults to 15, and a turntable to an 8-second turn
});A GIF holds 256 colours per frame and stores every frame whole, so it is much
larger than a video of the same length and shows some banding on smooth
gradients. Frames are taken from the same stream as a video and encoded as
they’re captured, so a long recording doesn’t build up memory. Each frame lasts
as long as it really showed, so a busy device drops frames rather than playing
the GIF fast. videoBitsPerSecond doesn’t apply to GIFs. The encoder
(gifenc ) loads only when a GIF is made.
onCameraChange
Called whenever the camera moves, with its plain state. It fires often while the user orbits, pans, or zooms.
onCameraChange?: (state: ViewerCameraState) => void
type ViewerCameraState = {
position: [number, number, number];
target: [number, number, number];
fov: number | null;
zoom: number;
azimuthAngle?: number; // radians, for setCameraState to restore
polarAngle?: number; // radians
};It’s the same state useViewerCamera exposes
as cameraState, and setCameraState restores it.
onTextureUpload
Optional async callback for handling texture file uploads. Use it to upload to your own storage (S3, Cloudinary, CDN, etc.) and return a durable URL.
onTextureUpload?: (file: File, objectId: string) => Promise<string>When omitted, the viewer falls back to URL.createObjectURL(file), a session-scoped blob URL that is lost on reload.
<ModelViewer
onTextureUpload={async (file, objectId) => {
const formData = new FormData();
formData.append("file", file);
formData.append("objectId", objectId);
const res = await fetch("/api/upload-texture", {
method: "POST",
body: formData,
});
const { url } = await res.json();
return url;
}}
/>texturePositioning
Turns on texture positioning for objects with an uploaded texture. Off by default. When on, the Change Material panel gains Offset X/Y and Rotation controls, and double-clicking the object puts the viewer in Move/Zoom mode: drag to move the texture, scroll to scale it, and click the canvas or Done positioning to finish.
<ModelViewer modelUrl="/sofa.glb" licenseKey="your-license-key" texturePositioning />Changes are written to the binding’s style.material.texture (offset,
rotation in radians, and scale), so they reach onObjectBindingsChange
and your saved bindings like any other edit.
To handle some of it yourself, pass an object instead of true. Any field you
leave out keeps the viewer’s own behavior:
type TexturePositioningControls = {
offset?: [number, number]; // shown in the Offset fields instead of the binding's
rotationDegrees?: number; // shown in the Rotation field, 0–360
onOffsetChange?: (objectId: string, axisIndex: 0 | 1, value: number) => void;
onRotationChange?: (objectId: string, degrees: number) => void;
isPositioning?: boolean; // shows "Done positioning" instead of the hint
onEndPositioning?: () => void; // after Done positioning ends the mode
};onOffsetChange and onRotationChange replace the viewer’s writes from the
panel fields; dragging and scrolling on the canvas still write to the binding.
onAnimationsReady
Called after the model is loaded, providing animation controls for supported
embedded GLB/GLTF, FBX, or USDZ animations. Called even if the model has no
animations (empty clips array, no-op functions).
type AnimationControls = {
clips: string[];
clipDetails?: { sourceName: string; duration: number }[];
play: (clipName: string, options?: AnimationPlayOptions) => void;
pause: (clipName?: string) => void;
stop: (clipName?: string) => void;
setSpeed: (speed: number, clipName?: string) => void;
seek?: (time: number, clipName?: string) => void; // absolute time, in seconds
getState?: () => AnimationPlaybackState; // includes every active clip
subscribe?: (listener: (state: AnimationPlaybackState) => void) => () => void;
};
onAnimationsReady?: (controls: AnimationControls) => voidSee AnimationControls for
AnimationPlayOptions and the full AnimationPlaybackState shape
(currentClip, isPlaying, speed, time, duration, and active, one entry
per active clip). Leave out clipName to act on every active clip.
Wire to useViewerAnimations().handleAnimationsReady for the simplest integration.
Lighting
Lighting lives in sceneConfig.lighting: an ambient light plus a list of
directional, point, spot, and hemisphere lights. Without a sceneConfig,
ModelViewer uses its default rig (ambient, a hemisphere fill, and a
shadow-casting key light). To use your own lights, patch the default scene
config. sceneLight fills in every setting you leave out:
import {
DEFAULT_SCENE_CONFIG,
patchSceneConfig,
sceneLight,
} from "@liveroom-tech/react-immersive/utils";
const sceneConfig = patchSceneConfig(DEFAULT_SCENE_CONFIG, {
lighting: {
ambient: { intensity: 0.5 },
lights: [
sceneLight({ id: "key", type: "directional", position: [4, 8, 4], intensity: 1.6, castShadow: true }),
sceneLight({ id: "fill", type: "point", position: [-3, 3, 2], intensity: 0.8 }),
],
},
});
<ModelViewer
modelUrl="/model.glb"
licenseKey="your-license-key"
sceneConfig={sceneConfig}
/>;To change lights while the app runs, use the lights controller from
useViewer (or useSceneConfig):
const viewer = useViewer({ sceneConfig });
viewer.scene.lights.setIntensity("key", 2.2);
viewer.scene.lights.toggle("fill");
viewer.scene.lights.add({ id: "bulb", type: "point", color: "#ffd27d", intensity: 4 });
viewer.scene.lights.attach("bulb", { objectId: "Lamp", offset: [0, 1.4, 0] });An attached light follows the object, offset from the object’s origin in its
local space (the origin is often at its base, not its center). Lighting set up
in BindingBuilder’s Scene tab exports as the same sceneConfig.
Style rules
sceneConfig.styleRules restyles objects from their own data while the app
runs: a colour for a temperature, grey for a disabled part, a pulsing glow for
an alarm. Each rule picks objects (target), checks their data (when), and
sets a look (material, colorScale, pulse):
const viewer = useViewer({
bindings,
sceneConfig: {
...DEFAULT_SCENE_CONFIG,
styleRules: [
{
id: "temperature",
target: { group: "pumps" },
colorScale: {
metric: "temperature",
stops: [[18, "#3b82f6"], [24, "#22c55e"], [32, "#ef4444"]],
},
},
{
id: "offline",
when: { status: "disabled" },
material: { baseColor: "#9ca3af", opacity: 0.4 },
},
{
id: "alarm",
when: [{ metric: "temperature", above: 32 }, { metadata: "alarm.acknowledged", equals: false }],
pulse: { color: "#ff3b30", speed: 1.5 },
},
],
},
});
<ModelViewer modelUrl="/plant.glb" licenseKey="your-license-key" {...viewer.props} />;
// Live data: update the bindings and the looks follow.
viewer.bindings.updateObjectBindings("pump-3", { metrics: { temperature: 34 } });
viewer.bindings.updateObjectBindings("valve-1", { status: "disabled" });targettakes the same targets as the binding helpers: an id, a list of ids,{ group },{ tag }, or{ type }. Left out, the rule considers every bound object.whenis a condition, or a list that must all match; left out, the rule always applies. Conditions:{ metric, above?, below? }(the value must be greater thanaboveand less thanbelow; an object without the metric doesn’t match),{ status }with one status or a list (a binding without one counts as"normal"), and{ metadata, equals }, where dots in the key reach into nested metadata.materialaccepts anystyle.materialfield.colorScalesetsbaseColor(oremissive, withchannel: "emissive") from a metric, blending between hex colour stops and taking the nearest stop outside them.pulsemakes the glow fade in and out (true, or{ color, speed }in pulses per second).- Rules apply in order over each object’s own
style.material, so a later rule wins where two set the same field. Hover and selection highlighting still show on top. - Rules only change how objects look.
objectBindings,onObjectBindingsChange, and every callback keep the object’s own values, so nothing a rule sets is saved with the bindings. - The pulse isn’t drawn on streamed 3D Tiles models (
tilesetUrl); the other looks are.
BindingBuilder’s Scene tab has a Rules sub-tab for building these rules
visually. It suggests your groups, tags, types, metric names, and metadata
keys, shows how many objects each rule matches right now, and previews the
result live. Rules export and import with the rest of sceneConfig.
applyStyleRules(bindings, rules) (from the main entry or /utils) returns
the styled bindings, for example to colour your own list the way the viewer
does, and colorScaleValue(stops, value) the colour a scale gives a value, for
a legend.
camera
Optional starting camera: where it is and its lens.
type ViewerCameraConfig = {
position?: [number, number, number];
fov?: number; // vertical field of view in degrees; default 50
near?: number;
far?: number;
zoom?: number;
};Without a position, the camera starts at [0, 0, 5]. Pass one for predictable framing.
<ModelViewer
camera={{ position: [0, 2.2, 7], fov: 40, near: 0.1, far: 1000 }}
...
/>Passing an explicit camera counts as choosing the initial view yourself, so
the mount-time auto-fit (see onAutoFit) is skipped automatically
and your position/fov stick. Provide onAutoFit as well only if you want
to run your own fit-to-scene logic instead.
backgroundColor
Optional background color for the viewer canvas.
<ModelViewer backgroundColor="#0f172a" ... />shadows
Controls whether the viewer renders shadows. Default: true.
When true, the canvas renders soft shadows: the default light rig casts a directional key-light shadow, furniture/decor meshes cast and receive shadows, and ambient occlusion is applied via post-processing. When false, shadow-map rendering is disabled on the canvas, no shadow-pass cost, and per-mesh/light castShadow flags are ignored.
<ModelViewer shadows={false} ... />Interior models (rooms, dollhouses): the large mesh that forms the
walls/ceiling enclosure is detected by its bounding-box size and automatically
excluded from casting shadows (it still receives them). Without this,
a closed shell would block a top-down light and seal the interior in darkness.
Furniture and decor then cast realistic contact shadows onto the floor. Pass a
your own lights in sceneConfig.lighting (see Lighting) to replace
the default rig.
showObjectBindingDataPanel
Controls whether the built-in left object details panel is rendered. Default: true.
customObjectBindingDataPanel
Render prop for replacing the built-in left object details panel.
type CustomObjectBindingDataPanelProps = {
isOpen: boolean;
selectedObject: ObjectBinding | null;
currentAction: ObjectActionEvent | null;
onClose: () => void;
onAction: (event: ObjectActionEvent) => void;
};showSceneObjectsPanel
Controls whether the built-in right scene objects panel is rendered. Default: true.
overlay
Your own UI over the 3D view: badges, HUDs, buttons, custom panels. It sits above the model and below the viewer’s toolbars and banners.
<ModelViewer
modelUrl="/sofa.glb"
licenseKey="your-license-key"
overlay={
<div style={{ position: "absolute", top: 16, left: 16 }}>
In stock <button onClick={addToCart}>Add to cart</button>
</div>
}
/>The layer covers the view but lets pointer events through, so the model stays
draggable around your elements. Its direct children receive pointer events, so
position them yourself (for example with position: "absolute") rather than
wrapping everything in one full-size container.
unsafe_sceneChildren
react-three-fiber elements rendered inside the scene, for anything the viewer
can’t do itself, such as custom geometry, a helper, or an effect driven by
useFrame:
import { useFrame } from "@react-three/fiber";
function Marker() {
const ref = useRef<Mesh>(null);
useFrame((_, delta) => (ref.current!.rotation.y += delta));
return (
<mesh ref={ref} position={[0, 1.5, 0]}>
<boxGeometry args={[0.3, 0.3, 0.3]} />
<meshStandardMaterial color="hotpink" />
</mesh>
);
}
<ModelViewer {...props} unsafe_sceneChildren={<Marker />} />;They’re rendered inside their own Suspense boundary, so a child that loads
assets doesn’t hold up the model. Because they work with three.js directly,
they’re outside the viewer’s own API: the name says so, and they can break when
three.js or react-three-fiber change. Prefer the props and hooks for anything
they cover.
customSceneObjectsPanel
Render prop for replacing the built-in right scene objects panel.
type CustomSceneObjectsPanelProps = {
objectBindings: Record<string, ObjectBinding>;
onAction?: (event: ObjectActionEvent) => void;
onFocus?: (binding: ObjectBinding) => void;
onHover?: (binding: ObjectBinding | null) => void;
};showDownloadButton
Enables the download split-button (export GLB, export the model’s UV layout,
and—when preserveDrawingBuffer is enabled—export a PNG screenshot).
The menu also offers Record turntable video and, when cinematic is enabled, Record cinematic video, with Auto/WebM/MP4/GIF format selection, progress, and cancellation. See Video export.
Default:
true.
UV export downloads an editable 2048×2048 SVG UV-island outline when the model
uses one material. For multi-material models it downloads a ZIP containing one
SVG texture template per material. Meshes without a uv attribute are omitted;
when the model has no UV coordinates, the viewer reports that no layout is
available.
The bottom-right action bar renders when at least one of showResetButton, showDownloadButton, showFullscreenButton (when supported), or (showAnnotationNavigation with annotations present) is true.
downloadFilename
Filename stem for the built-in model export button. Default: "model".
showResetButton
Shows the “reset camera view” button in the bottom-right action bar. Default: true.
showFullscreenButton
Optional boolean that adds an enter/exit fullscreen toggle to the bottom-right
action bar. Its grouped form is ui.fullscreen. Default: false.
<ModelViewer
modelUrl="/model.glb"
licenseKey={licenseKey}
ui={{ fullscreen: true }}
/>Fullscreen expands the whole viewer, including its panels and overlays. The
button reflects browser exits such as Escape, and is hidden when fullscreen is
unavailable. It is temporarily disabled during video recording. Embedded viewers
need an iframe with allow="fullscreen" (or allowFullScreen in React). A rejected
request displays an error beside the button.
showLoadingOverlay
Controls whether the built-in loading overlay is shown while the model is loading and the initial camera fit is settling, and the load-error message if the model fails to load (see onLoadError). Default: true.
showMouseController
Renders an on-screen joystick/zoom controller for moving the camera with a mouse or touch, useful on touch devices or kiosk layouts. Default: false.
<ModelViewer
showMouseController
mouseControllerPosition="bottom-right"
mouseControllerOpacity={0.8}
moveSensitivity={0.1}
zoomSensitivity={1.2}
...
/>cameraLimits
Constrains how far the orbit camera can zoom, tilt, turn, and pan. Angles are in degrees.
<ModelViewer
modelUrl="/car.glb"
licenseKey="your-license-key"
cameraLimits={{
minDistance: 2,
maxDistance: 12,
maxPolarAngle: 85, // stay above the floor
minAzimuthAngle: -60, // turn at most 60° either side of the front
maxAzimuthAngle: 60,
}}
/>type CameraLimits = {
minDistance?: number; // default 0
maxDistance?: number; // default Infinity
minPolarAngle?: number; // degrees, default 0
maxPolarAngle?: number; // degrees, default 180
minAzimuthAngle?: number; // degrees, default -Infinity
maxAzimuthAngle?: number; // degrees, default Infinity
panBoundary?: [
min: [number, number, number],
max: [number, number, number],
];
boundaryEnclosesCamera?: boolean; // default false
boundaryFriction?: number; // default 0
};- Polar is the vertical angle:
0looks straight down from above,90is level with the target, and180looks straight up from below. - Azimuth is the horizontal angle around the vertical axis:
0views the target from the front (+Z),90from its right (+X), and-90from its left.
panBoundary is an axis-aligned world-space box. These limits apply to the
orbit controller; pointer-lock mode is not orbiting around the same target.
mouseControllerPosition
Placement of the on-screen controller. Default: "center-bottom".
type MouseControllerPosition =
| "bottom-left"
| "bottom-right"
| "top-left"
| "top-right"
| "center"
| "center-bottom"
| "center-top";mouseControllerOpacity
Opacity of the on-screen controller. Default: 1.
moveSensitivity
Movement sensitivity for the on-screen controller. Default: 0.08.
zoomSensitivity
Zoom sensitivity for the on-screen controller. Default: 1.0.
sceneConfig
Optional scene-wide configuration object, model, camera, lighting, wireframe,
shadows, environment, background, ground shadows, post-processing, animations,
annotations, and a cinematic camera path. This is the same SceneConfig shape
authored by BindingBuilder’s Scene tab. If omitted, the viewer uses its
built-in default scene config.
Use it to define the experience around the model, rather than individual model
objects. For example, a guided tour can use annotations and saved camera
views to direct the camera through a scene without requiring
objectBindings for every tour stop. See the Guided Tour
example.
sceneConfig.animations drives autoplay and per-clip playback (see
onAnimationsReady), and sceneConfig.annotations seeds
the annotation markers rendered on the model.
sceneConfig.model.renderer accepts "pbr", "matcap", or "uv-checker".
UV Checker temporarily replaces every mesh material with an unlit numbered test
chart without changing objectBindings or uploaded textures. Switching back
restores the original material references. Meshes without UV0 coordinates appear
magenta and trigger an explanatory notice.
const { sceneConfig, updateSceneConfig } = useSceneConfig(initialSceneConfig);
const enableUvChecker = () =>
updateSceneConfig({ model: { renderer: "uv-checker" } });
<ModelViewer {...props} sceneConfig={sceneConfig} />
<button onClick={enableUvChecker}>Show UV checker</button>See useSceneConfig for nested scene updates and the
pure patchSceneConfig alternative.
onSceneConfigChange
Called with an updated SceneConfig when viewer-owned scene editing changes
the configuration. Currently this is used when a light-position or rotation
gizmo moves a scene-config light. Update controlled sceneConfig state with
the value returned by this callback.
onSceneConfigChange?: (config: SceneConfig) => voiddisableZoom
Optional boolean. When true, the viewer never zooms the camera to an object
on selection (selection still highlights and fires callbacks). Default: false.
zoomOnSelected
Optional boolean controlling whether selecting an object zooms/fits the camera
to it. Set to false to keep the current camera framing on selection. Default:
true. (Zooming is also skipped when disableZoom is true.)
highlightOnHover
Controls hover highlighting for the canvas, built-in object list, and
panelHoveredObject. Default: true. On very large models, viewport hover is
disabled for performance, while panel-row highlighting remains available.
Moving and rotating objects
Set moveModeEnabled to show a centered PivotControls gizmo for the selected
bound object. Its translation arrows, plane handles, and rotation rings are
available at the same time; scaling is disabled.
import { useState } from "react";
import {
ModelViewer,
type ObjectBinding,
} from "@liveroom-tech/react-immersive";
export function EditableViewer({
initialBindings,
}: {
initialBindings: Record<string, ObjectBinding>;
}) {
const [bindings, setBindings] = useState(initialBindings);
return (
<ModelViewer
modelUrl="/model.glb"
licenseKey="your-license-key"
objectBindings={bindings}
onObjectBindingsChange={setBindings}
moveModeEnabled
objectTransformSpace="world"
/>
);
}Click a bound mesh to select it. Drag an arrow to move on one axis, a plane handle to move on two axes, or a ring to rotate. The pivot is calculated from the center of the object’s renderable geometry, so the object rotates around itself even when its authored node origin is elsewhere.
objectTransformSpace accepts:
"world"(default): handles stay aligned with the scene axes"local": handles follow the selected object’s current orientation
Changing axis space only changes handle alignment; it does not move the
object. Changes are persisted to the binding as local-space
transform.position and transform.rotation. Positions use model units and
rotations use radians.
If you pass selectedObject, selection is controlled too. Update it from
onObjectSelect. If you omit both props, ModelViewer manages selection
internally.
enableCameraControls
Optional boolean controlling orbit, pan, and zoom input. Default: true.
Camera controls are paused whenever moveModeEnabled is on so pointer drags
manipulate the object.
cameraControllerType / onCameraControllerChange
Selects the active camera interaction mode:
"orbit"(default) uses orbit, pan, and zoom controls."pointerLock"captures the pointer for first-person look-around. Leaving pointer lock returns the viewer to orbit mode.
The viewer’s camera-controller menu can manage this internally. Pass
cameraControllerType to control it from application state and update that
state from onCameraControllerChange.
const [controller, setController] = useState<"orbit" | "pointerLock">("orbit");
<ModelViewer
{...props}
cameraControllerType={controller}
onCameraControllerChange={setController}
/>Auto-rotation pauses while pointer-lock mode is active.
autoRotate / autoRotateSpeed
Enables automatic orbiting and controls its speed. Defaults are false and
1. When these props aren’t passed, sceneConfig.model.autoRotate and
sceneConfig.model.autoRotateSpeed apply, so a scene exported from
BindingBuilder can carry the same behavior. When they are passed, they win.
<ModelViewer {...props} autoRotate autoRotateSpeed={0.75} />Auto-rotate doesn’t start for visitors who ask their device to reduce motion.
See reducedMotion.
enableKeyboardNavigation
Optional boolean enabling canvas-focused camera shortcuts. Default: false.
Click or focus the canvas first, then use W/S to move forward/back,
A/D to truck left/right, Space/C to move up/down, and the arrow keys to
orbit. Shift + Up/Down moves forward/back. The shortcuts are inactive while
object editing or camera controls are disabled. While the canvas has focus, a
screen reader reads out what its keys do (the cameraKeyboardHelp
label).
Accessibility
The viewer can be used without a mouse, and reads out what is happening:
- Moving through objects with the keyboard. Tabbing into the viewer reaches
a small object list at the top of the canvas, shown only while it has focus.
The arrow keys (or
HomeandEnd) move through the objects and highlight each one in the scene. Typing a letter jumps to an object whose name starts with it.Enteror Space selects the object, exactly as clicking it would, andEscapeclears the selection. Screen readers hear each object’s name and its position (“Wheel, 3 of 12”). The list follows the scene objects panel’s order. It skips hidden objects, and objects an isolation has set aside. - Announcing the selection. Every change of selection is announced, whether it came from a click, the keyboard, the objects panel, or your app (“Wheel selected”, “Selection cleared”).
- The scene objects panel. Each row is a button, so the panel can be used from the keyboard too. Focusing a row highlights its object, and the visibility buttons name the object they hide or show.
- Reduced motion. See
reducedMotionbelow.
All of this text can be translated with labels.
showObjectNavigator
Optional boolean for the keyboard object list described above. Default: true.
It takes no space until a keyboard user reaches it, so it’s on by default. Set it
to false when your app provides its own way to choose objects from the
keyboard. It is hidden while measuring, since clicks then place points instead
of selecting.
<ModelViewer {...props} ui={{ objectNavigator: false }} />Keyboard selections reach onEngagement as
part-viewed events with source: "keyboard".
reducedMotion
Cuts motion the viewer starts on its own, for visitors who find it hard to
watch. Default: "user", which follows the visitor’s device setting
(prefers-reduced-motion). "always" and "never" override it.
reducedMotion?: "user" | "always" | "never"; // also controls.reducedMotionWhen motion is reduced:
- Auto-rotate doesn’t start.
- A cinematic path with
autoPlaywaits for its play button. - Camera moves jump straight to where they’re going instead of gliding: the
first framing, focusing a selected object, resetting the view, saved views,
guided-tour stops, and moves made through
useViewerCamera. - Panels, menus and popups appear in place instead of sliding or fading in. Loading spinners keep turning, since they show that something is still happening.
Motion the visitor asks for still plays: the cinematic play button, video
export, and dragging the camera. The viewer doesn’t change animations your app
starts, such as model clips, effects, or
style rule pulses. To respect the setting there too, check
matchMedia("(prefers-reduced-motion: reduce)") in your app.
SimpleModelViewer takes the same prop.
showViewGizmo
Optional boolean showing a clickable camera-orientation gizmo in the top-right
of the viewer. Default: false. It displays the scene axes and can snap the
camera to an axis. This is separate from the selected object’s transform gizmo
and is hidden while moveModeEnabled is on.
onAutoFit
Optional async callback invoked once the model has loaded and the scene is
ready. When provided, it is called so consumers can run their own
fit-to-scene logic (e.g. an animated fit); if omitted, the viewer falls back to
its internal fitScene, unless an explicit camera prop was
given, in which case that position is treated as the chosen initial view and
fitScene is skipped.
onAutoFit?: () => Promise<boolean>refitOnResize
Optional boolean controlling whether the camera re-frames the model to fit
whenever the canvas is resized, a browser window resize, a device rotation, or
a side panel opening/closing (which changes how much width the canvas has). Set
to false to preserve the user’s current orbit/zoom across resizes; the camera
aspect stays correct either way, so the model never distorts, it just isn’t
re-centered. Only affects re-fits after the initial mount-time auto-fit.
Default: true.
While a selected object is framed (see zoomOnSelected), a
resize frames that object again rather than the whole model, so the object
panel opening doesn’t undo the zoom. An object with an authored cameraState
keeps that view. Once the selection is cleared, or something else moves the
camera away from the object, resizes frame the whole model again.
onAnnotationsChange
Optional callback fired when the annotation markers on the model change (added, edited, or removed through the viewer UI).
(annotations: AnnotationMarker[]) => voidAnnotationMarker is the SceneAnnotationMarker shape:
type SceneAnnotationCameraView = {
position: [number, number, number];
target: [number, number, number];
azimuthAngle?: number; // radians
polarAngle?: number; // radians
fov?: number | null;
zoom?: number;
};
type SceneAnnotationImage = { src: string; alt?: string };
type SceneAnnotationLink = { href: string; label: string };
type SceneAnnotationMarker = {
id: number;
worldPosition: [number, number, number];
localPosition: [number, number, number];
worldNormal?: [number, number, number];
anchorNodeName?: string;
anchorLocalPosition?: [number, number, number];
title: string;
description: string;
cameraView?: SceneAnnotationCameraView;
images?: SceneAnnotationImage[];
links?: SceneAnnotationLink[];
category?: string;
minDistance?: number;
maxDistance?: number;
};
type AnnotationMarker = SceneAnnotationMarker;Rich annotations
Annotations can include a saved camera view, images, links, a category, and
camera-distance limits. New annotations use automatic close-up tour framing
toward the marker, including following animated anchors. A custom cameraView
is optional: explicitly capture one in BindingBuilder or provide it in code.
Selecting a marker with a saved view or visiting it in the guided tour restores its world-space position, target, orbit angles, field of view, and zoom. Saved views stay fixed while the model animates. Clearing the saved view in BindingBuilder restores automatic framing.
import { ModelViewer, DEFAULT_SCENE_CONFIG, type SceneConfig } from "@liveroom-tech/react-immersive";
const sceneConfig: SceneConfig = {
...DEFAULT_SCENE_CONFIG,
annotations: [{
id: 1,
title: "Replaceable filter",
description: "Open this panel to access the filter.",
worldPosition: [0, 1, 0],
localPosition: [0, 1, 0],
cameraView: { position: [2, 2, 4], target: [0, 1, 0], fov: 45, zoom: 1 },
images: [{ src: "/images/filter.jpg", alt: "Filter compartment" }],
links: [{ href: "https://example.com/manual", label: "Read the manual" }],
category: "Maintenance",
minDistance: 0.5,
maxDistance: 12,
}],
};
<ModelViewer {...props} sceneConfig={sceneConfig} />Images and links accept HTTP(S) URLs and relative paths. Executable URL schemes
are ignored. Descriptions and labels render as plain text. Supply image alt
text for accessible descriptions. Image files stay at their URLs; they are not
uploaded or bundled into scene exports automatically.
minDistance and maxDistance are inclusive distances from the camera to the
marker’s live world position, in scene units. Omit either bound for no limit in
that direction. These settings hide markers, including hover interaction, but
leave explicit popups and tour steps available. Distance is unrelated to the
measurement unit label. Occlusion behavior still applies.
annotationCategories / onAnnotationCategoriesChange / showAnnotationFilters
Category chips appear when any annotation has a category. Toggle chips to select one or more categories, or choose All. Filtering applies to markers, open popups, and tour steps. Changing the filter ends the current tour. Annotations keep their IDs and are visited in ID order.
const [categories, setCategories] = useState<string[] | null>(null);
<ModelViewer
{...props}
annotationCategories={categories}
onAnnotationCategoriesChange={setCategories}
ui={{ annotationFilters: true }}
/>nullshows all categories. An empty array hides all annotations.""selects annotations without a category. Category names are trimmed and matched case-sensitively.- Omitting
annotationCategorieslets the viewer manage the filter internally. showAnnotationFilters(orui.annotationFilters) defaults totrue. Set it tofalseto supply your own filter UI; the filter props still apply.
activeAnnotation / onActiveAnnotationChange
Optional controlled state for which annotation is currently open. Pass
activeAnnotation to control it from your app, and onActiveAnnotationChange
to be notified when the viewer wants to open (AnnotationMarker) or close
(null) one. If activeAnnotation is left undefined, the viewer manages this
state internally (uncontrolled).
activeAnnotation?: AnnotationMarker | null;
onActiveAnnotationChange?: (annotation: AnnotationMarker | null) => void;renderMode
"demand" (default) only re-renders the canvas when something changes (camera move, state update, animation frame); "always" runs a continuous render loop. Leave at "demand" for lower GPU/battery cost unless you have a custom scene that mutates outside React/animation state.
maxDpr
Upper bound for the device pixel ratio used when rendering, so retina/4K displays don’t render at full 2–3x cost. Default: 2.
preserveDrawingBuffer
Retains the WebGL framebuffer so captureImage and the built-in Download PNG
action can read the rendered frame. Default: false. Enable it only when PNG
capture is needed because framebuffer retention adds GPU memory and bandwidth
cost to every rendered frame.
<ModelViewer preserveDrawingBuffer {...props} />webgpu
Optional boolean. Draws with three.js’s WebGPU renderer when the browser has
WebGPU, and with WebGL otherwise. Default: false. Also perf.webgpu.
<ModelViewer {...props} perf={{ webgpu: true }} />The viewer asks the browser for a WebGPU adapter once per page before
creating its canvas. Without one, it uses WebGL and says so once in the
console. onViewerReady reports which renderer is drawing as
rendererBackend ("webgl" or "webgpu"). The WebGPU renderer is loaded only
by viewers that use it.
With WebGPU, the model, materials, bindings, hover highlight, picking, animations, decals, annotations, measurement, video and GIF export, and tone mapping and exposure work as they do with WebGL. Some features are built on WebGL and are left out:
- The post-processing effects (outline, bloom, depth of field, and the rest)
don’t draw. Tone mapping and exposure from
sceneConfig.postProcessingstill apply, through the renderer. - Contact shadows (
sceneConfig.groundShadowsin shadow-catcher mode) don’t draw. - The section cut and the move gizmo (
moveModeEnabled) aren’t available, and the console says so if they’re asked for. - AR and VR sessions in the browser (WebXR) aren’t offered. Quick Look on iOS and the phone hand-off still work.
- Measurement lines are one pixel wide.
captureImage and the Download PNG action work with WebGPU without
preserveDrawingBuffer. A lost WebGPU device shows the same Reload viewer
message as a lost WebGL context.
Lazy loading and posters
loading, poster and reveal decide when a viewer creates its 3D view, and
what shows in its place until then. They matter most on pages with many
viewers, such as a product grid: browsers allow a page about 16 WebGL contexts
and take the oldest away past that.
<ModelViewer {...props} poster="/chair.webp" />
<ModelViewer {...props} poster="/chair.webp" reveal="interaction" />poster?: string;
loading?: "lazy" | "eager"; // default "lazy"
reveal?: "auto" | "interaction"; // default "auto"loading is "lazy" by default. A lazy viewer:
- starts its 3D view when it comes within about 200px of the screen. One that’s on the screen when the page loads starts at once.
- shares the page’s live 3D views with the other lazy viewers: at most 12, or 6 on phones and tablets. Viewers on the screen come first. One scrolled away from keeps its view while there’s room, so coming back is instant. When the room is needed, it’s put away, leaving a picture of its last frame, and is built again when it comes back near the screen.
- comes back as it was: the camera, picked variants, light and section-cut
changes, isolation, the exploded view, the camera controller, the UV checker,
the annotation filter, and, when the viewer keeps
objectBindingsitself, the changes made to them. The selection and open panels don’t come back.onModelLoadedandonViewerReadyrun again, with a new viewer. - stops drawing while it’s off the screen, and starts again when it’s back. Browsers already stop drawing in hidden tabs.
loading="eager" starts the 3D view at once and keeps it. Eager viewers don’t
count towards the 12. BindingBuilder’s
preview is eager.
poster is an image shown in the viewer’s place until its 3D view is drawn,
then fades out; the loading message shows over it. It’s fitted inside the
viewer. To fill the viewer instead, set the CSS variable
--ri-poster-fit: cover on a parent. A render of the model framed as the
viewer frames it works best: captureImage can make one.
reveal="interaction" keeps the poster, with a View in 3D button, until the
visitor presses it, so only the products someone picks use a 3D view. The
button’s text is the labels key viewIn3D.
reveal is "auto" by default: the view starts on its own.
The same three props work on
SimpleModelViewer
and CompareViewer.
performanceProfile
Optional control over how aggressively the viewer trades visual fidelity for a stable WebGL context on constrained GPUs.
performanceProfile?: "auto" | "high" | "low";"auto"(default) applies a reduced profile on handheld/mobile browsers and other low-power touch devices: the postprocessing pipeline is skipped, soft shadows are disabled, and the device pixel ratio is capped. Desktop-class touch devices may still keep the higher-quality path when they advertise plenty of memory."high"always renders at full quality."low"always applies the reduced profile.
Default: "auto".
WebGL context recovery
If the WebGL context is lost, rendering pauses and the viewer displays a
Reload viewer button. This message appears even with
ui={{ loadingOverlay: false }}. When the browser fires webglcontextrestored,
the viewer automatically rebuilds its canvas and scene; the reload button starts
the same rebuild without waiting for browser restoration.
Recovery uses the current props and cached model assets, and cancels video
recording. The rebuilt viewer keeps its camera and the visitor’s changes, as a
lazy viewer does when it comes back (see
Lazy loading and posters); the selection,
measurements and playback start over.
onModelLoaded, onAnimationsReady, and onViewerReady run again
for the rebuilt scene, so integrations receive fresh controls and object refs.
dracoDecoderPath / ktx2TranscoderPath / meshopt
Control the compressed-asset decoders used when loading the GLB/GLTF asset.
dracoDecoderPath?: string | false;
ktx2TranscoderPath?: string | false;
meshopt?: boolean;Meshopt is bundled and enabled by default. DRACO and KTX2 are also enabled by
default through immutable, versioned builds hosted on the first-party
assets.react-immersive.liveroom.dev domain. They are fetched lazily only when a model
uses the corresponding compression format.
dracoDecoderPath =
"https://assets.react-immersive.liveroom.dev/three-r184/draco/";
ktx2TranscoderPath =
"https://assets.react-immersive.liveroom.dev/three-r184/basis/";Pass false to disable either decoder. For a fully self-hosted or offline
deployment, version-matched decoder files ship in dist/decoders.
Copy them into your app’s public/static directory:
cp -R node_modules/@liveroom-tech/react-immersive/dist/decoders public/react-immersive-decodersThen pass the public URL directories, including the trailing slash:
<ModelViewer
dracoDecoderPath="/react-immersive-decoders/draco/"
ktx2TranscoderPath="/react-immersive-decoders/basis/"
{...props}
/>The same props are available on SimpleModelViewer and BindingBuilder. When
using the built-in defaults with a strict CSP, allow both the licence-validation
and asset origins, plus blob workers. A typical baseline is
connect-src 'self' https://react-immersive.liveroom.dev https://assets.react-immersive.liveroom.dev; worker-src 'self' blob:; script-src 'self' 'wasm-unsafe-eval'.
Self-hosting the decoder assets removes the asset-domain requirement, but
ModelViewer and BindingBuilder still require the licence-validation origin.
showUvCheckerButton
Shows a UV Checker toggle button over the canvas. Turning it on temporarily
replaces the model’s materials with the numbered diagnostic texture; turning it
off restores the renderer configured by sceneConfig. Meshes without UV0
coordinates appear magenta. Default: false.
<ModelViewer showUvCheckerButton ... />showMeasureTools
Optional boolean that shows measurement tools and a toggleable bounding-box dimensions overlay. Start measuring, then choose a tool:
- Distance: pick points for distances between consecutive picks.
- Angle: pick an arm, the angle’s vertex, and the other arm. The readout is in degrees, with an arc around the middle pick.
- Area: pick polygon corners in boundary order, then Finish area. The closed polygon is shaded and labeled in square units. Concave polygons are supported; nonplanar, crossing, touching, duplicate, or collinear boundaries show an error. Use Undo to correct a pick.
- Point to plane: pick a reference face, then a point. The tool highlights the picked triangle and shows the shortest distance to its infinite plane, with a perpendicular segment to the projected point.
No snapping keeps picks on the surface. Snap: vertex, Snap: edge, and Snap: auto snap within 12 CSS pixels to vertices or triangle edges on the picked face. Auto prioritizes nearby vertices. Hover markers identify the snap target; snapped picks are yellow. Snapping respects world transforms, instances, and the current pose of skinned/morph geometry. Mesh triangulation edges can also be snap targets.
Undo removes the last pick; Clear removes the current measurement. Switching tools clears picks. A new pick after a completed angle, area, or point-to-plane measurement starts another measurement. Picks are snapshots in world space; model changes, display rotation, and binding transforms clear them. Pause animated models before measuring a pose. Hidden objects, decals, and editor helpers are excluded from picking.
Default: false.
<ModelViewer
modelUrl="/assembly.glb"
licenseKey={licenseKey}
tools={{ measure: { unit: "m" } }}
/>measurementUnit
Unit suffix for distance and dimension readouts; area uses its squared form
(for example m²). Angles always use degrees. This only changes the label:
values are not converted. glTF models use meters by specification.
Default: "m".
showExplodeControls
Shows an exploded-view control over the canvas: a slider that slides each bound
part outward from the model center to reveal interior/assembly structure, then
back to reassemble it. Best suited to static product/CAD models, the offset
writes node positions each frame, so it conflicts with a playing skeletal
animation. Default: false.
<ModelViewer showExplodeControls ... />See the Exploded View example.
showMaterialVariants
Shows swatch buttons, centered at the bottom of the viewer, for switching
between the model’s glTF material variants (the KHR_materials_variants
extension): the colorways, finishes, or trims a product file ships with. Each
button shows the variant’s colour when its material is a plain colour. It
appears for a glTF/GLB with any variants. Default true.
Variants come in option groups, one row each. Choosing a variant replaces the other choice in its row and keeps the other rows, so a paint colour and a wheel style combine. Clicking the chosen variant again returns that row to the file’s default materials. The viewer works out the groups from the file:
- A name like
"Paint: Red"or"Paint / Red"puts the variant in a row labelled Paint, with a button labelled Red. - Otherwise, variants that restyle the same parts share a row, and variants
that restyle different parts get separate rows. A shoe’s
"midnight","beach"and"street"colorways, which all restyle the same parts, are one row.
The variants shown are sceneConfig.model.activeVariants, at most one from
each group, which is also how you choose them yourself:
import { chooseMaterialVariant, useViewer } from "@liveroom-tech/react-immersive";
const viewer = useViewer();
<ModelViewer modelUrl="/car.glb" licenseKey="your-license-key" {...viewer.props} />;
// Once the model has loaded
viewer.model.materialVariants; // ["Paint: Red", "Paint: Blue", "Wheels: Sport", "Wheels: Classic"]
viewer.model.materialVariantGroups;
// [
// { name: "Paint", variants: [{ name: "Paint: Red", label: "Red", swatch: "#ff0000" }, ...] },
// { name: "Wheels", variants: [{ name: "Wheels: Sport", label: "Sport" }, ...] },
// ]
viewer.scene.updateSceneConfig({ model: { activeVariants: ["Paint: Red", "Wheels: Sport"] } });
viewer.scene.updateSceneConfig({ model: { activeVariants: [] } }); // the defaults
// Your own swatches: swap a choice within its group, keeping the others.
const active = viewer.scene.sceneConfig.model.activeVariants ?? [];
viewer.scene.updateSceneConfig({
model: {
activeVariants: chooseMaterialVariant(active, "Paint: Blue", viewer.model.materialVariantGroups),
},
});- A variant sets each object’s starting material.
objectBindingsmaterial overrides (style.material) still apply on top of it, and clearing an override returns to the variant’s material rather than the file’s default. - Objects no active variant maps keep their default material, bound or not. If two active variants map the same object, the later one in the list wins.
- A variant’s materials and textures load the first time it’s chosen; the current look stays until they’re ready.
- With
onSceneConfigChange, a pick goes to your scene config (useViewerwires this up). Without it,ModelViewerkeeps the picks itself untilsceneConfig.model.activeVariantsormodelUrlchanges. - A name the model doesn’t have is skipped with a console warning listing the model’s variants.
- An action can switch variants with a scene-config effect:
{ sceneConfig: { model: { activeVariants: ["Paint: Red"] } } }. - Swatches use the colour of the material a variant puts on the most vertices. A textured material gets no swatch, just its label, so showing the buttons never loads textures.
- Each pick reaches
onEngagementas avariant-chosenevent with itsgroup. onModelLoadedreportsmaterialVariantsandmaterialVariantGroups, and BindingBuilder’s Scene tab (General, one select per group) picks the variants the exported scene config starts with. OBJ, FBX, USDZ, STL, PLY, and streamed 3D Tiles models have no variants.- Scene configs saved before option groups, with a single
model.materialVariant, still show that variant.
Decals
binding.decals prints images and text onto an object’s surface: a logo on a
shirt, a name on a mug, a label on a machine. Each decal is projected onto the
object and follows it as it moves, and it’s saved with the binding like any
other edit.
const viewer = useViewer();
<ModelViewer
modelUrl="/mug.glb"
licenseKey="your-license-key"
{...viewer.props}
// Put the customer's text where they double-click.
onObjectDoubleClick={({ binding, surface }) => {
if (!surface) return;
viewer.bindings.addDecal(binding.id, {
text: customerName,
color: "#1e293b",
...surface, // position and normal, in the object's own space
size: 0.06, // width in model units
});
return true; // skip the viewer's own double-click behavior
}}
/>;
const id = viewer.bindings.addDecal("mug", { image: "/logos/acme.png", position, normal, size: 0.05 });
viewer.bindings.updateDecal("mug", id, { size: 0.08, rotation: Math.PI / 12 });
viewer.bindings.removeDecal("mug", id);type ObjectBindingDecal = {
id: string;
image?: string; // a URL or public path
text?: string; // shown when there's no image
color?: string; // text colour, default "#ffffff"
fontFamily?: string; // CSS font family, default "sans-serif"
fontWeight?: "normal" | "bold"; // default "bold"
position: [number, number, number]; // on the object, in its own space
normal: [number, number, number]; // the way the surface faces there
size: number; // width in model units; the height follows the image or text
rotation?: number; // radians around the normal, default 0
opacity?: number; // 0–1, default 1
};-
onObjectDoubleClickandonObjectContextMenureportsurface: the point under the pointer and the way the surface faces, in the object’s own space, which is exactly what a decal’spositionandnormaltake. -
addDecalreturns the decal’s id (pass your ownidto choose it), and takes the same targets as the other binding helpers, soaddDecal({ group: "panels" }, …)prints on each. Decals are plain binding data: you can also setdecalsdirectly. -
A decal wraps onto the surface around its position, reaching about half its size in depth, so it follows gentle curves without printing on the far side of a thin part.
-
A decal fades with its object when that object is ghosted by isolation or made see-through by a style rule, and is cut by a section cut.
-
To let the people using your viewer add their own, give a binding the built-in
add-textandadd-imageactions:actions: [ { id: "add-text", label: "Add Text", type: "command" }, { id: "add-image", label: "Add Image", type: "command" }, ]Each opens a control under its button in the object panel. Add Text takes the text and a colour; Add Image takes an image file (sent through
onTextureUploadwhen you pass it, so the saved URL lasts; otherwise a blob URL for this session). The decal appears automatically at the centre of the camera-facing surface, ready to drag, resize, turn, or remove. Move enables dragging across the object’s surface; release to save, or press Escape to cancel. The Position arrows move a decal in small steps relative to the current view, without needing to grab it. Click for a fine adjustment or hold an arrow for continuous movement. Each step stays on the nearby visible surface. Every change is saved to the binding’sdecalsand reported throughonObjectBindingsChange. While a decal is in Move mode, a hint shows over the viewer and Escape cancels the current drag without removing the centred decal. -
On a rigged (skinned) mesh a decal bends with the animation: it takes on the bone weights of the surface under it and is driven by the same skeleton. A click on a posed mesh is stored at the matching point of its rest shape, so a decal lands where you clicked whatever pose the model is in. Over a joint that bends sharply across few polygons, the decal’s outer edge can sit a hair off the surface. Decals don’t follow morph targets (blend shapes).
-
Decals need the binding’s object to be a mesh, and aren’t drawn on streamed 3D Tiles models.
Isolating objects
isolation focuses the view on some objects: everything else is ghosted
(drawn faintly) or hidden, and clicks and hovers pass through to what’s
isolated. Nothing about the bindings changes, and clearing the isolation puts
everything back. It’s view state, like the selection, so it isn’t part of
sceneConfig.
const viewer = useViewer();
<ModelViewer
modelUrl="/engine.glb"
licenseKey="your-license-key"
{...viewer.props}
tools={{ isolate: true }}
/>;
viewer.isolation.isolate({ group: "gearbox" }); // ghost everything else
viewer.isolation.isolate(["piston-1", "piston-2"], { mode: "hide" });
viewer.isolation.clearIsolation();type ViewerIsolation = {
target: ObjectBindingTarget; // an id, a list of ids, { group }, { tag }, or { type }
mode?: "ghost" | "hide"; // default "ghost"
ghostOpacity?: number; // 0–1, default 0.12
};
isolation?: ViewerIsolation | null;
onIsolationChange?: (isolation: ViewerIsolation | null) => void;
showIsolateControls?: boolean; // tools.isolate- Pass
isolation(includingnull) to control it, and update it fromonIsolationChange;useViewerwires both, asviewer.isolation. Leaveisolationout andModelViewerkeeps it itself, clearing it whenmodelUrlchanges.useViewerIsolationis the same state on its own. showIsolateControls(tools.isolate, defaultfalse) adds an isolate button to each row of the scene objects panel (click again to show all) and a “Show all” pill at the bottom of the viewer while anything is isolated.- An action can isolate with an
isolationeffect:{ isolation: { target: { group: "engine" } } }, or{ isolation: null }to show everything again. PassisolationtouseViewerActionsfor it (useViewerdoes). - A target that matches no object isolates nothing, rather than fading out the whole model. Ghosting never raises an object’s own lower opacity, and style rules still apply underneath.
applyIsolation(main entry and/utils) applies an isolation to bindings the same way, for rendering your own lists.
showSectionTools
Shows a section-cut button over the canvas (tools.section). It cuts the model
open along an axis, hiding everything on one side of a plane, to look inside
assemblies, equipment, and buildings. Default false. Not available with
webgpu.
With the cut on, the toolbar picks the axis (X, Y, or Z), the position along
it, and which side stays, and a plane appears across the model that you can
drag. The cut is stored in sceneConfig.section, so you can also set it
yourself, author it in BindingBuilder, or switch it from an action:
viewer.scene.updateSceneConfig({
section: { enabled: true, axis: "y", position: 0.4, flip: false }, // a floor-plan cut
});type SceneSectionConfig = {
enabled: boolean;
axis: "x" | "y" | "z"; // world axis the cut runs across
position: number; // 0 at the model's minimum along the axis, 1 at its maximum
flip: boolean; // keep the higher side instead of the lower one
};- The cut applies to the model only; lights, helpers, and gizmos stay whole.
It also shows without the toolbar whenever
sceneConfig.section.enabledis set; the draggable plane appears only withshowSectionTools. - While the cut is on, the model’s materials render both sides of each surface, so a cut part shows its inside surfaces instead of disappearing. There are no solid caps over the cut.
- Clicking and hovering ignore whatever is cut away, so the parts you can see behind the cut are the ones you select.
- Toolbar changes go to
onSceneConfigChangewhen you pass it (useViewerdoes); otherwiseModelViewerkeeps them untilsceneConfig.sectionormodelUrlchanges. - The measuring tool can still pick points on the hidden side of a cut.
Default:
false;cinematic
Enables the cinematic auto-camera: the camera glides along a path on its own, like a film, ideal for showing off a gallery, apartment, or product with no user interaction. Renders a play/pause button over the canvas; grabbing the camera (or selecting a part) pauses it and hands control back.
cinematic?: boolean | CinematicConfig;
type CinematicConfig = {
waypoints?: CinematicWaypoint[]; // camera keyframes to glide through
duration?: number; // seconds for one full pass (auto-scales when omitted)
loop?: boolean; // default true
autoPlay?: boolean; // start once the model has loaded and framed (default false); waits for the play button under reduced motion
};
type CinematicWaypoint = {
position: [number, number, number];
target: [number, number, number]; // world-space look-at point
azimuthAngle?: number; // radians, captured by BindingBuilder; omit by hand
polarAngle?: number; // radians
};- Pass
cinematicas booleantrue(or an object with fewer than two waypoints) to get a zero-config showcase orbit around the model’s bounding sphere. - Pass two or more
waypointsto author a walkthrough: the camera follows a smooth Catmull-Rom spline through them at a constant on-screen speed, looping seamlessly. - Waypoints are easiest to capture visually in
BindingBuilder’s Cinematic tab (frame a view, click Add waypoint), which stores them onsceneConfig.cinematic.
Precedence. A path can also come from
sceneConfig.cinematic (authored in BindingBuilder). The
cinematic prop overwrites it: a boolean toggles the feature (true keeps any
authored waypoints, false forces it off), and an object overrides
field-by-field, so you can keep the authored waypoints while overriding, say,
autoPlay or loop.
// Zero-config showcase orbit
<ModelViewer cinematic ... />
// Authored walkthrough
<ModelViewer
cinematic={{
waypoints: [
{ position: [4, 2, 6], target: [0, 1, 0] },
{ position: [-3, 2, 4], target: [0, 1, 0] },
{ position: [-5, 2, -2], target: [0, 1, 0] },
],
loop: true,
autoPlay: true,
}}
...
/>
// Keep the BindingBuilder-authored path, just autoplay it
<ModelViewer sceneConfig={sceneConfig} cinematic={{ autoPlay: true }} ... />Default: disabled.
enableXR
Optional boolean that enables XR entry points. On WebXR-capable browsers, the
viewer shows an AR and/or VR button once the relevant immersive session support
is confirmed. On iPhone/iPad, WebXR AR is not available, but if you also pass
usdzUrl, the viewer shows a “View in AR”
button that launches Apple Quick Look instead.
Default: false.
usdzUrl
Optional USDZ URL used as an Apple-device AR fallback when enableXR is
true.
usdzUrl?: string;- Point this to a
.usdzfile Safari can fetch directly. - iPhone/iPad will show a “View in AR” button that launches Apple Quick Look when WebXR AR is unavailable.
- Android / WebXR-capable devices ignore this prop and continue using the normal WebXR AR flow.
arScaleMode
Controls the initial model size in WebXR AR and VR sessions.
arScaleMode?: "normalized" | "real-world";"normalized"(default) fits the longest model dimension to approximately 1 metre. Use this for arbitrary uploads and room-scale scenes."real-world"preserves the GLB’s authored scale. glTF units are metres, so use this for product models exported at their real dimensions.
Users can still pinch to resize after placement in either mode.
mobileHandoffUrl
Optional public URL used for the desktop-to-phone AR handoff. When enableXR
is true, desktop viewers show an “Open on phone” QR-code control. Scan it
to open the experience on a phone, then use its AR control (or iOS Quick Look).
mobileHandoffUrl?: string;- Omit it to encode the current page URL, including its hash. This works with
useShareableViewerStateso the phone opens the same camera/visibility/selection state. - Pass a dedicated HTTPS mobile-viewer URL when desktop and mobile use different routes.
- The QR handoff is hidden on phones and tablets because they can enter AR directly.
- The target must be publicly reachable from the phone;
localhostURLs will not work.
showAnnotationNavigation
Shows the “Guided Tour” control cluster (left side of the bottom action bar) when the selected categories contain at least one annotation: a single “Guided Tour” button that, once started, becomes Previous/Stop/Next controls for stepping through annotations in order. Default: true.
showAnnotationOnHover
Temporarily opens an annotation’s detail popup when the pointer hovers over its
marker. Moving the pointer away closes the hover popup after a short grace
period. Clicking a marker still locks its popup open until it is closed.
Default: false.
<ModelViewer {...props} showAnnotationOnHover />Large models
The viewer accelerates viewport picking by building a BVH (bounding volume hierarchy) index for the loaded model in a background worker. Models above roughly 12 million triangles are too large to index without a significant memory cost, so the viewer degrades gracefully instead:
- Viewport pointer interaction is disabled: clicking or hovering meshes in the
canvas does nothing, and
onObjectSelect/onObjectHoverdo not fire from the viewport. Selection through the scene objects panel still works and still firesonObjectSelect. - A dismissible banner appears over the canvas telling the user the model is very large, that it may render slowly on their device, and that selection is available through the panel.
- The loading overlay narrates the extra work (“Preparing large model…”), uploading that much geometry to the GPU is a single synchronous operation that can block the page for tens of seconds, on any WebGL viewer.
Orbit, zoom, pan, animations, annotations, and the panels all keep working.
There is no way to make a model of this size feel fast in a browser, the
per-frame cost of tens of millions of triangles is inherent, and on mobile
devices the decoded geometry alone can exceed the tab’s memory budget. For
assets like this, decimate at ingestion (for example to ~2–3M triangles) rather
than shipping the raw export; a decimated model loads in seconds and keeps full
interactivity. Note that Blender or native tooling is required for very large
Draco-compressed files, the WASM builds of gltf-transform and gltfpack
cannot decode them within their 4GB address space.
Theming
The scene objects panel and object binding data panel read their colors,
borders, shadows, and fonts from CSS custom properties instead of hardcoded
values. Set them on a .ri-viewer ancestor (or any element wrapping the
viewer) to re-skin those panels without overriding individual classes,
every variable already has the current default baked in as a fallback, so
you only need to set the ones you actually want to change.
.my-app .ri-viewer {
--ri-sidepanel-bg: #1a0033;
--ri-sidepanel-accent: #ff2d75;
--ri-sidepanel-accent-soft: rgba(255, 45, 117, 0.25);
--ri-sidepanel-text: #ffe8f5;
}<div className="my-app">
<ModelViewer ... />
</div>Scene objects panel, --ri-sidepanel-*
| Variable | Default |
|---|---|
--ri-sidepanel-font-family | Inter, "Segoe UI", Helvetica, Arial, sans-serif |
--ri-sidepanel-bg | #141824 |
--ri-sidepanel-open-btn-bg | rgba(20, 20, 20, 0.82) |
--ri-sidepanel-open-btn-bg-hover | #27282b7e |
--ri-sidepanel-border | rgba(255, 255, 255, 0.06) |
--ri-sidepanel-divider | rgba(255, 255, 255, 0.04) |
--ri-sidepanel-border-strong | rgba(255, 255, 255, 0.08) |
--ri-sidepanel-border-active | rgba(255, 255, 255, 0.18) |
--ri-sidepanel-shadow-left | 4px 0 24px rgba(0, 0, 0, 0.35) |
--ri-sidepanel-shadow-right | -4px 0 24px rgba(0, 0, 0, 0.35) |
--ri-sidepanel-text | #f1f5f9 |
--ri-sidepanel-text-emphasis | #ffffff |
--ri-sidepanel-text-muted | #94a3b8 |
--ri-sidepanel-text-subtle | #64748b |
--ri-sidepanel-placeholder | #475569 |
--ri-sidepanel-surface | rgba(255, 255, 255, 0.05) |
--ri-sidepanel-surface-hover | rgba(255, 255, 255, 0.1) |
--ri-sidepanel-accent | #3b82f6 |
--ri-sidepanel-accent-soft | rgba(59, 130, 246, 0.12) |
Object binding data panel, --ri-databinding-*
| Variable | Default |
|---|---|
--ri-databinding-bg | #111318 |
--ri-databinding-border | rgba(255, 255, 255, 0.06) |
--ri-databinding-border-strong | rgba(255, 255, 255, 0.08) |
--ri-databinding-border-hover | rgba(255, 255, 255, 0.15) |
--ri-databinding-shadow | 4px 0 24px rgba(0, 0, 0, 0.35) |
--ri-databinding-font-family | "Inter", "Segoe UI", sans-serif |
--ri-databinding-mono-font-family | "JetBrains Mono", "Fira Code", "Menlo", monospace |
--ri-databinding-text | #f1f5f9 |
--ri-databinding-text-muted | #94a3b8 |
--ri-databinding-text-subtle | #64748b |
--ri-databinding-text-faint | #475569 |
--ri-databinding-text-hover | #cbd5e1 |
--ri-databinding-surface | rgba(255, 255, 255, 0.03) |
--ri-databinding-surface-hover | rgba(255, 255, 255, 0.06) |
--ri-databinding-accent | #93c5fd |
--ri-databinding-accent-glow | rgba(147, 197, 253, 0.8) |
--ri-databinding-accent-border | rgba(96, 165, 250, 0.55) |
--ri-databinding-accent-gradient | linear-gradient(135deg, #1e3a8a 0%, #1d4ed8 60%, #2563eb 100%) |
--ri-databinding-accent-gradient-active | linear-gradient(135deg, #1e3a8a 0%, #1e40af 100%) |
ModelViewer.css also declares the original --ri-panel-* and
--ri-license-overlay-* variables for its other surfaces, the two sets above
are specifically for the scene objects panel and the object binding data
panel.
Built-In Actions
| Action ID | Behavior |
|---|---|
toggle-visibility | Hides or shows the selected mesh via binding.visible + onObjectBindingsChange |
change-color | Opens the color picker inline under the action; hex committed to binding.style.material.baseColor on close |
change-material | Opens the texture upload panel inline under the action; URL stored in binding.style.material.texture.path |