ModelViewer
The primary exported component. Renders a GLB, GLTF, OBJ, FBX, or USDZ model with orbit/pan/zoom controls, built-in side panels, and a full callback API.
Props
type Props = {
modelUrl: string;
modelFormat?: "glb" | "gltf" | "obj" | "fbx" | "usdz";
licenseKey: string;
objectBindings: Record<string, ObjectBinding>;
selectedObject?: ObjectBinding | null;
onObjectBindingsChange?: (next: Record<string, ObjectBinding>) => void;
onObjectSelect?: (binding: ObjectBinding | null) => void;
onObjectHover?: (binding: ObjectBinding | null) => void;
onModelLoaded?: (scene: Object3D) => void;
onLoadError?: (error: unknown) => void;
onAction?: (event: ObjectActionEvent) => void;
onHiddenObjectsChange?: (next: Record<string, boolean>) => void;
onCameraChange?: (camera: Camera, controls: CameraControls) => void;
onViewerReady?: (viewer: ViewerReadyState) => void;
onTextureUpload?: (file: File, objectId: string) => Promise<string>;
onAnimationsReady?: (controls: AnimationControls) => void;
onAnnotationsChange?: (annotations: AnnotationMarker[]) => void;
activeAnnotation?: AnnotationMarker | null;
onActiveAnnotationChange?: (annotation: AnnotationMarker | null) => void;
lights?: React.ReactNode;
camera?: React.ComponentProps<typeof Canvas>["camera"];
backgroundColor?: string;
shadows?: boolean;
showObjectBindingDataPanel?: boolean;
customObjectBindingDataPanel?: (
props: CustomObjectBindingDataPanelProps,
) => React.ReactNode;
customSceneObjectsPanel?: (
props: CustomSceneObjectsPanelProps,
) => React.ReactNode;
showSceneObjectsPanel?: boolean;
showDownloadButton?: boolean;
downloadFilename?: string;
showResetButton?: 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;
enableCameraControls?: boolean;
moveModeEnabled?: boolean;
objectTransformSpace?: "local" | "world";
enableKeyboardNavigation?: boolean;
onAutoFit?: () => Promise<boolean>;
refitOnResize?: boolean;
renderMode?: "always" | "demand";
maxDpr?: number;
performanceProfile?: "auto" | "high" | "low";
dracoDecoderPath?: string | false;
ktx2TranscoderPath?: string | false;
meshopt?: boolean;
showMeasureTools?: boolean;
showUvCheckerButton?: boolean;
showExplodeControls?: boolean;
cinematic?: boolean | CinematicConfig;
measurementUnit?: string;
enableXR?: boolean;
usdzUrl?: string;
showAnnotationNavigation?: boolean;
showAnnotationOnHover?: boolean;
showViewGizmo?: boolean;
};modelUrl
Required. URL or public path to a .glb, .gltf, .obj, .fbx, or .usdz 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.
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.
objectBindings
Required. A map keyed by the model node name. Each key should match a mesh name from the GLB/GLTF file.
objectBindings is the single source of truth for all visual state:
| Field | Effect |
|---|---|
visible | Controls mesh visibility |
style.material.baseColor | Sets mesh color (committed by the built-in color picker on close) |
style.material.texture.path | Sets the base color (albedo) texture |
style.material.* | Per-object MeshPhysicalMaterial overrides, see below |
cameraState.position / cameraState.target | Camera view to use when this object is focused or selected from the viewer UI |
All visual overrides live under style.material (an ObjectBindingMaterial).
It covers base color/PBR, emission, opacity & blending, normal/bump,
displacement, ambient occlusion, clearcoat, sheen, anisotropy,
transmission/volume, and face sidedness, plus a texture-map slot for each.
See ObjectBindingMaterial for the
full field list and the per-slot color-space handling (color maps are decoded
as sRGB, data maps stay linear).
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.
onObjectSelect
Called when the user clicks a mesh or closes the side panel.
onObjectSelect?: (binding: ObjectBinding | null) => void- Clicking a mesh →
onObjectSelect(binding) - Closing the panel →
onObjectSelect(null) - Clicking the same mesh again still fires; selection is not toggled off automatically
- If the binding includes
cameraState.positionandcameraState.target, the viewer moves the camera to that saved view when focusing that object - Clicks resolve the front-most surface under the pointer; a mesh whose binding has
selectable: falsedoes not pass the click through to meshes behind it - On very large models, clicking meshes in the viewport is disabled entirely, see Large models. Selection through the scene objects panel (and this callback) still works
onObjectHover
Called when the user hovers a mesh.
onObjectHover?: (binding: ObjectBinding | null) => void- Pointer over a mesh →
onObjectHover(binding) - Pointer out →
onObjectHover(null) - On very large models, viewport hover is disabled entirely, see Large models
onObjectBindingsChange
Fired when the viewer’s built-in UI updates binding data. Wire this back into your state to keep the viewer and your app in sync.
onObjectBindingsChange?: (next: Record<string, ObjectBinding>) => voidFires for:
- Visibility toggles (
binding.visible) - Color picks (
binding.style.material.baseColor), committed when the color picker closes - Texture uploads (
binding.style.material.texture.path) - Texture removal
- Object movement and rotation (
binding.transform.positionandbinding.transform.rotation)
Passing this callback makes binding edits controlled: update the
objectBindings prop with the returned record. If it is omitted, the viewer
keeps built-in edits in internal state for the lifetime of the mounted viewer.
onHiddenObjectsChange
Fired with the derived hidden-object map whenever binding visibility state changes.
onHiddenObjectsChange?: (next: Record<string, boolean>) => voidonModelLoaded
Called after the GLB/GLTF scene has been loaded.
onModelLoaded?: (scene: Object3D) => voidonLoadError
Called if the model fails to load or render.
onLoadError?: (error: unknown) => voidonAction
Called when a built-in action button is clicked from the side panel.
type ObjectActionEvent = {
objectId: string;
action: ObjectBindingAction;
binding?: ObjectBinding;
screenX?: number;
screenY?: number;
};
onAction?: (event: ObjectActionEvent) => voidonViewerReady
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 = {
controls: CameraControls;
scene: Object3D | null;
objectBindings: Record<string, ObjectBinding>;
nodeRefs: Record<string, Object3D>;
captureImage: (options?: CaptureImageOptions) => Promise<string>;
};
type CaptureImageOptions = {
width?: number;
height?: number;
transparent?: boolean;
};captureImage 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.
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.
onCameraChange
Called whenever the camera controls update.
onCameraChange?: (camera: Camera, controls: CameraControls) => voidonTextureUpload
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;
}}
/>onAnimationsReady
Called after the GLB/GLTF model is loaded, providing animation controls. 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) => void;
pause: () => void;
stop: () => void;
setSpeed: (speed: number) => void;
seek?: (time: number) => void; // scrub the current clip to an absolute time (seconds)
getState?: () => AnimationPlaybackState; // includes live time + duration
subscribe?: (listener: (state: AnimationPlaybackState) => void) => () => void;
};
onAnimationsReady?: (controls: AnimationControls) => voidSee AnimationControls for the full
AnimationPlaybackState shape (currentClip, isPlaying, speed, time,
duration).
Wire to useViewerAnimations().handleAnimationsReady for the simplest integration.
lights
Optional custom lighting rendered inside the scene. When omitted, ModelViewer uses its default light rig.
function CustomLights() {
return (
<>
<ambientLight intensity={0.5} />
<directionalLight position={[4, 8, 4]} intensity={1.6} castShadow />
<pointLight position={[-3, 3, 2]} intensity={0.8} />
</>
);
}
<ModelViewer lights={<CustomLights />} ... />camera
Optional camera configuration passed through to the underlying React Three Fiber Canvas.
ModelViewer only sets a default fov: 50, it does not set a default position, so an unset position falls back to React Three Fiber’s own Canvas default ([0, 0, 5]). Pass an explicit position 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
custom lights rig to fully override the default lighting.
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.
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 PNG screenshot, or export
the model’s UV layout). 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, 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.
showLoadingOverlay
Controls whether the built-in loading overlay is shown while the model is loading and the initial camera fit is settling. 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}
...
/>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 world
positions 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.
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.)
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.
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.
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.
onAnnotationsChange
Optional callback fired when the annotation markers on the model change (added, edited, or removed through the viewer UI).
(annotations: AnnotationMarker[]) => voidAnnotationMarker is the SceneAnnotationMarker shape:
type AnnotationMarker = {
id: number;
worldPosition: [number, number, number];
localPosition: [number, number, number];
title: string;
description: string;
};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.
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".
dracoDecoderPath / ktx2TranscoderPath / meshopt
Control the compressed-asset decoders used when loading the GLB/GLTF asset.
dracoDecoderPath?: string | false;
ktx2TranscoderPath?: string | false;
meshopt?: boolean;DRACO, Meshopt, and KTX2 decoding are enabled by default via hosted decoder/transcoder bundles, fetched lazily only when the model actually needs them. Pass a path to self-host the decoder/transcoder files, or false to disable that decoder entirely.
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
Shows a click-to-measure toolbar over the canvas: click two points on the model to get a distance readout, plus a toggleable bounding-box dimensions overlay (width/height/depth). Default: false.
<ModelViewer showMeasureTools measurementUnit="m" ... />measurementUnit
Unit suffix appended to measurement readouts. glTF models are authored in meters by spec, so values are not converted, this only changes the displayed label. 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.
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)
};
type CinematicWaypoint = {
position: [number, number, number];
target: [number, number, number]; // world-space look-at point
azimuthAngle?: number; // preserves orbit rotation at vertical poles
polarAngle?: number;
};- Pass
cinematicas booleantrue(or an object with fewer than two waypoints) to get a zero-config showcase orbit around the model’s bounding sphere. - Pass two or more
waypointsto author a walkthrough: the camera follows a smooth Catmull-Rom spline through them at a constant on-screen speed, looping seamlessly. - Waypoints are easiest to capture visually in
BindingBuilder’s Cinematic tab (frame a view, click Add waypoint), which stores them onsceneConfig.cinematic.
Precedence. A path can also come from
sceneConfig.cinematic (authored in BindingBuilder). The
cinematic prop overwrites it: a boolean toggles the feature (true keeps any
authored waypoints, false forces it off), and an object overrides
field-by-field, so you can keep the authored waypoints while overriding, say,
autoPlay or loop.
// Zero-config showcase orbit
<ModelViewer cinematic ... />
// Authored walkthrough
<ModelViewer
cinematic={{
waypoints: [
{ position: [4, 2, 6], target: [0, 1, 0] },
{ position: [-3, 2, 4], target: [0, 1, 0] },
{ position: [-5, 2, -2], target: [0, 1, 0] },
],
loop: true,
autoPlay: true,
}}
...
/>
// Keep the BindingBuilder-authored path, just autoplay it
<ModelViewer sceneConfig={sceneConfig} cinematic={{ autoPlay: true }} ... />Default: disabled.
enableXR
Optional boolean that enables XR entry points. On WebXR-capable browsers, the
viewer shows an AR and/or VR button once the relevant immersive session support
is confirmed. On iPhone/iPad, WebXR AR is not available, but if you also pass
usdzUrl, the viewer shows a “View in AR”
button that launches Apple Quick Look instead.
Default: false.
usdzUrl
Optional USDZ URL used as an Apple-device AR fallback when enableXR is
true.
usdzUrl?: string;- Point this to a
.usdzfile Safari can fetch directly. - iPhone/iPad will show a “View in AR” button that launches Apple Quick Look when WebXR AR is unavailable.
- Android / WebXR-capable devices ignore this prop and continue using the normal WebXR AR flow.
arScaleMode
Controls the initial model size in WebXR AR and VR sessions.
arScaleMode?: "normalized" | "real-world";"normalized"(default) fits the longest model dimension to approximately 1 metre. Use this for arbitrary uploads and room-scale scenes."real-world"preserves the GLB’s authored scale. glTF units are metres, so use this for product models exported at their real dimensions.
Users can still pinch to resize after placement in either mode.
mobileHandoffUrl
Optional public URL used for the desktop-to-phone AR handoff. When enableXR
is true, desktop viewers show an “Open on phone” QR-code control. Scan it
to open the experience on a phone, then use its AR control (or iOS Quick Look).
mobileHandoffUrl?: string;- Omit it to encode the current page URL, including its hash. This works with
useShareableViewerStateso the phone opens the same camera/visibility/selection state. - Pass a dedicated HTTPS mobile-viewer URL when desktop and mobile use different routes.
- The QR handoff is hidden on phones and tablets because they can enter AR directly.
- The target must be publicly reachable from the phone;
localhostURLs will not work.
showAnnotationNavigation
Shows the “Guided Tour” control cluster (left side of the bottom action bar) when the model has at least one annotation: a single “Guided Tour” button that, once started, becomes Previous/Stop/Next controls for stepping through annotations in order. Default: true.
showAnnotationOnHover
Temporarily opens an annotation’s detail popup when the pointer hovers over its
marker. Moving the pointer away closes the hover popup after a short grace
period. Clicking a marker still locks its popup open until it is closed.
Default: false.
<ModelViewer {...props} showAnnotationOnHover />Large models
The viewer accelerates viewport picking by building a BVH (bounding volume hierarchy) index for the loaded model in a background worker. Models above roughly 12 million triangles are too large to index without a significant memory cost, so the viewer degrades gracefully instead:
- Viewport pointer interaction is disabled: clicking or hovering meshes in the
canvas does nothing, and
onObjectSelect/onObjectHoverdo not fire from the viewport. Selection through the scene objects panel still works and still firesonObjectSelect. - A dismissible banner appears over the canvas telling the user the model is very large, that it may render slowly on their device, and that selection is available through the panel.
- The loading overlay narrates the extra work (“Preparing large model…”), uploading that much geometry to the GPU is a single synchronous operation that can block the page for tens of seconds, on any WebGL viewer.
Orbit, zoom, pan, animations, annotations, and the panels all keep working.
There is no way to make a model of this size feel fast in a browser, the
per-frame cost of tens of millions of triangles is inherent, and on mobile
devices the decoded geometry alone can exceed the tab’s memory budget. For
assets like this, decimate at ingestion (for example to ~2–3M triangles) rather
than shipping the raw export; a decimated model loads in seconds and keeps full
interactivity. Note that Blender or native tooling is required for very large
Draco-compressed files, the WASM builds of gltf-transform and gltfpack
cannot decode them within their 4GB address space.
Theming
The scene objects panel and object binding data panel read their colors,
borders, shadows, and fonts from CSS custom properties instead of hardcoded
values. Set them on a .ri-viewer ancestor (or any element wrapping the
viewer) to re-skin those panels without overriding individual classes,
every variable already has the current default baked in as a fallback, so
you only need to set the ones you actually want to change.
.my-app .ri-viewer {
--ri-sidepanel-bg: #1a0033;
--ri-sidepanel-accent: #ff2d75;
--ri-sidepanel-accent-soft: rgba(255, 45, 117, 0.25);
--ri-sidepanel-text: #ffe8f5;
}<div className="my-app">
<ModelViewer ... />
</div>Scene objects panel, --ri-sidepanel-*
| Variable | Default |
|---|---|
--ri-sidepanel-font-family | Inter, "Segoe UI", Helvetica, Arial, sans-serif |
--ri-sidepanel-bg | #141824 |
--ri-sidepanel-open-btn-bg | rgba(20, 20, 20, 0.82) |
--ri-sidepanel-open-btn-bg-hover | #27282b7e |
--ri-sidepanel-border | rgba(255, 255, 255, 0.06) |
--ri-sidepanel-divider | rgba(255, 255, 255, 0.04) |
--ri-sidepanel-border-strong | rgba(255, 255, 255, 0.08) |
--ri-sidepanel-border-active | rgba(255, 255, 255, 0.18) |
--ri-sidepanel-shadow-left | 4px 0 24px rgba(0, 0, 0, 0.35) |
--ri-sidepanel-shadow-right | -4px 0 24px rgba(0, 0, 0, 0.35) |
--ri-sidepanel-text | #f1f5f9 |
--ri-sidepanel-text-emphasis | #ffffff |
--ri-sidepanel-text-muted | #94a3b8 |
--ri-sidepanel-text-subtle | #64748b |
--ri-sidepanel-placeholder | #475569 |
--ri-sidepanel-surface | rgba(255, 255, 255, 0.05) |
--ri-sidepanel-surface-hover | rgba(255, 255, 255, 0.1) |
--ri-sidepanel-accent | #3b82f6 |
--ri-sidepanel-accent-soft | rgba(59, 130, 246, 0.12) |
Object binding data panel, --ri-databinding-*
| Variable | Default |
|---|---|
--ri-databinding-bg | #111318 |
--ri-databinding-border | rgba(255, 255, 255, 0.06) |
--ri-databinding-border-strong | rgba(255, 255, 255, 0.08) |
--ri-databinding-border-hover | rgba(255, 255, 255, 0.15) |
--ri-databinding-shadow | 4px 0 24px rgba(0, 0, 0, 0.35) |
--ri-databinding-font-family | "Inter", "Segoe UI", sans-serif |
--ri-databinding-mono-font-family | "JetBrains Mono", "Fira Code", "Menlo", monospace |
--ri-databinding-text | #f1f5f9 |
--ri-databinding-text-muted | #94a3b8 |
--ri-databinding-text-subtle | #64748b |
--ri-databinding-text-faint | #475569 |
--ri-databinding-text-hover | #cbd5e1 |
--ri-databinding-surface | rgba(255, 255, 255, 0.03) |
--ri-databinding-surface-hover | rgba(255, 255, 255, 0.06) |
--ri-databinding-accent | #93c5fd |
--ri-databinding-accent-glow | rgba(147, 197, 253, 0.8) |
--ri-databinding-accent-border | rgba(96, 165, 250, 0.55) |
--ri-databinding-accent-gradient | linear-gradient(135deg, #1e3a8a 0%, #1d4ed8 60%, #2563eb 100%) |
--ri-databinding-accent-gradient-active | linear-gradient(135deg, #1e3a8a 0%, #1e40af 100%) |
ModelViewer.css also declares the original --ri-panel-* and
--ri-license-overlay-* variables for its other surfaces, the two sets above
are specifically for the scene objects panel and the object binding data
panel.
Built-In Actions
| Action ID | Behavior |
|---|---|
toggle-visibility | Hides or shows the selected mesh via binding.visible + onObjectBindingsChange |
change-color | Opens the color picker inline under the action; hex committed to binding.style.material.baseColor on close |
change-material | Opens the texture upload panel inline under the action; URL stored in binding.style.material.texture.path |