Skip to Content
API ReferenceModelViewer

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:

PresetForWhat it sets
minimalJust the modelHides both panels, the download and reset buttons, tour controls, and the material variant buttons
showcaseA product on displayEverything minimal does, plus auto-rotation
configuratorCustomers changing partsShows the selected object’s panel, the reset button, and the material variant buttons; hides the scene objects panel and download
inspectorChecking a modelEvery 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" }} />
GroupFields, and the flat prop each one sets
uiobjectPanel (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)
toolsmeasure (showMeasureTools; { unit } also sets measurementUnit), uvChecker (showUvCheckerButton), explode (showExplodeControls), section (showSectionTools), isolate (showIsolateControls), texturePositioning (texturePositioning)
controlsenabled (enableCameraControls), keyboard (enableKeyboardNavigation), zoom (the opposite of disableZoom), zoomOnSelect (zoomOnSelected), autoRotate (a number also sets autoRotateSpeed), refitOnResize, limits (cameraLimits), moveSensitivity, zoomSensitivity, reducedMotion
perfrenderMode, maxDpr, profile (performanceProfile), preserveDrawingBuffer, webgpu
tilesurl (tilesetUrl), errorTarget (tilesErrorTarget), cacheSize (tilesCacheSize)
xrenabled (enableXR), usdzUrl, scaleMode (arScaleMode), mobileHandoffUrl
decodersdraco (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.stl loads as one object keyed bracket, which its bindings use. A text STL with several named solids loads one object per solid. A .zip holding 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 set measurementUnit to "mm". In AR, the default "normalized" arScaleMode fits 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} />
  • tilesErrorTarget controls the screen-space error target. Lower values load sharper tiles and use more bandwidth/GPU memory. Default: 8.
  • tilesCacheSize is the maximum number of parsed tiles kept in the client cache. Default: 800.
  • onObjectCatalog receives the bindable-object catalog from objects.json before all referenced tiles have loaded. See onObjectCatalog.

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:

FieldEffect
visibleControls mesh visibility
style.material.baseColorSets mesh color (committed by the built-in color picker on close)
style.material.texture.pathSets the base color (albedo) texture
style.material.*Per-object MeshPhysicalMaterial overrides, see below
cameraState.position / cameraState.targetCamera 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.position and cameraState.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: false does 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>) => void

Fires 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.position and binding.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>) => void

onObjectCatalog

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) => void

Whether 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) => void

onAction

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) => void

ModelViewer 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 typeDetails and trigger
part-viewedobjectId, objectKey, optional label, and source ("canvas", "panel", or "keyboard"). Selecting/focusing a different part; reselecting the current part is ignored.
variant-chosenvariant, 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-startedA confirmed immersive WebXR AR session begins.
ar-endeddurationMs and reason ("session-ended" or "interrupted"). A confirmed AR session ends, or tracking stops on model change, unmount, or context loss.
ar-launch-requestedchannel ("quick-look" or "mobile-handoff"). Opening external AR or its QR handoff; this does not claim an AR session started.
tour-startedannotationCount. Starting the guided tour.
tour-completedannotationCount and durationMs. Advancing past its final annotation; emitted once per completed tour.
tour-stoppedannotationCount, 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) => void

See 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" });
  • target takes 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.
  • when is a condition, or a list that must all match; left out, the rule always applies. Conditions: { metric, above?, below? } (the value must be greater than above and less than below; 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.
  • material accepts any style.material field. colorScale sets baseColor (or emissive, with channel: "emissive") from a metric, blending between hex colour stops and taking the nearest stop outside them. pulse makes 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: 0 looks straight down from above, 90 is level with the target, and 180 looks straight up from below.
  • Azimuth is the horizontal angle around the vertical axis: 0 views the target from the front (+Z), 90 from its right (+X), and -90 from 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) => void

disableZoom

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 Home and End) move through the objects and highlight each one in the scene. Typing a letter jumps to an object whose name starts with it. Enter or Space selects the object, exactly as clicking it would, and Escape clears 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 reducedMotion below.

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

When motion is reduced:

  • Auto-rotate doesn’t start.
  • A cinematic path with autoPlay waits 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[]) => void

AnnotationMarker 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 }} />
  • null shows all categories. An empty array hides all annotations.
  • "" selects annotations without a category. Category names are trimmed and matched case-sensitively.
  • Omitting annotationCategories lets the viewer manage the filter internally.
  • showAnnotationFilters (or ui.annotationFilters) defaults to true. Set it to false to 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.postProcessing still apply, through the renderer.
  • Contact shadows (sceneConfig.groundShadows in 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 objectBindings itself, the changes made to them. The selection and open panels don’t come back. onModelLoaded and onViewerReady run 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-decoders

Then 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. objectBindings material 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 (useViewer wires this up). Without it, ModelViewer keeps the picks itself until sceneConfig.model.activeVariants or modelUrl changes.
  • 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 onEngagement as a variant-chosen event with its group.
  • onModelLoaded reports materialVariants and materialVariantGroups, 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 };
  • onObjectDoubleClick and onObjectContextMenu report surface: the point under the pointer and the way the surface faces, in the object’s own space, which is exactly what a decal’s position and normal take.

  • addDecal returns the decal’s id (pass your own id to choose it), and takes the same targets as the other binding helpers, so addDecal({ group: "panels" }, …) prints on each. Decals are plain binding data: you can also set decals directly.

  • 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-text and add-image actions:

    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 onTextureUpload when 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’s decals and reported through onObjectBindingsChange. 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 (including null) to control it, and update it from onIsolationChange; useViewer wires both, as viewer.isolation. Leave isolation out and ModelViewer keeps it itself, clearing it when modelUrl changes. useViewerIsolation is the same state on its own.
  • showIsolateControls (tools.isolate, default false) 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 isolation effect: { isolation: { target: { group: "engine" } } }, or { isolation: null } to show everything again. Pass isolation to useViewerActions for it (useViewer does).
  • 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.enabled is set; the draggable plane appears only with showSectionTools.
  • 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 onSceneConfigChange when you pass it (useViewer does); otherwise ModelViewer keeps them until sceneConfig.section or modelUrl changes.
  • 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 cinematic as boolean true (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 waypoints to 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 on sceneConfig.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 .usdz file 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 useShareableViewerState so 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; localhost URLs 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 / onObjectHover do not fire from the viewport. Selection through the scene objects panel still works and still fires onObjectSelect.
  • 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-*

VariableDefault
--ri-sidepanel-font-familyInter, "Segoe UI", Helvetica, Arial, sans-serif
--ri-sidepanel-bg#141824
--ri-sidepanel-open-btn-bgrgba(20, 20, 20, 0.82)
--ri-sidepanel-open-btn-bg-hover#27282b7e
--ri-sidepanel-borderrgba(255, 255, 255, 0.06)
--ri-sidepanel-dividerrgba(255, 255, 255, 0.04)
--ri-sidepanel-border-strongrgba(255, 255, 255, 0.08)
--ri-sidepanel-border-activergba(255, 255, 255, 0.18)
--ri-sidepanel-shadow-left4px 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-surfacergba(255, 255, 255, 0.05)
--ri-sidepanel-surface-hoverrgba(255, 255, 255, 0.1)
--ri-sidepanel-accent#3b82f6
--ri-sidepanel-accent-softrgba(59, 130, 246, 0.12)

Object binding data panel, --ri-databinding-*

VariableDefault
--ri-databinding-bg#111318
--ri-databinding-borderrgba(255, 255, 255, 0.06)
--ri-databinding-border-strongrgba(255, 255, 255, 0.08)
--ri-databinding-border-hoverrgba(255, 255, 255, 0.15)
--ri-databinding-shadow4px 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-surfacergba(255, 255, 255, 0.03)
--ri-databinding-surface-hoverrgba(255, 255, 255, 0.06)
--ri-databinding-accent#93c5fd
--ri-databinding-accent-glowrgba(147, 197, 253, 0.8)
--ri-databinding-accent-borderrgba(96, 165, 250, 0.55)
--ri-databinding-accent-gradientlinear-gradient(135deg, #1e3a8a 0%, #1d4ed8 60%, #2563eb 100%)
--ri-databinding-accent-gradient-activelinear-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 IDBehavior
toggle-visibilityHides or shows the selected mesh via binding.visible + onObjectBindingsChange
change-colorOpens the color picker inline under the action; hex committed to binding.style.material.baseColor on close
change-materialOpens the texture upload panel inline under the action; URL stored in binding.style.material.texture.path