The camera, load state, model dimensions, AR status and hotspot clicks arrive as ordinary Dash props — no clientside callbacks.

Events and Callbacks

The camera, load state, model dimensions, AR status and hotspot clicks arrive as ordinary Dash props — no clientside callbacks.


The headline change in 1.0.0

Version 0.0.1 had no output props at all. The single setProps call in the component was commented out, so nothing the model did could reach Python. Every interaction — reading the camera, reacting to a load, measuring the model — required hand-written JavaScript in assets/ and a clientside_callback to reach it.

That is why the previous camera-views example was 231 lines.

Everything below is now an ordinary Input.

PropUpdates whenShape
camerathe user moves the camera{"orbit", "target", "field_of_view"}
model_stateloading, loaded, or failed{"status", "progress"}
model_infoon load{"dimensions", "variants", "animations"}
ar_statusan AR session changes statestr
ar_trackingAR gains or loses trackingstr
scene_pointthe model is clicked, with pick_on_click=True{"position", "normal", "uv"}
Slot.n_clicksa slot is clickedint

Reading the camera

# File: docs/events-and-callbacks/camera_readout.py

from dash import Input, Output, callback, html
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib.demo_models import ROBOT

component = html.Div(
    [
        dmv.ModelViewer(
            id="ev-camera-viewer",
            src=ROBOT,
            alt="An expressive cartoon robot",
            camera_change_debounce=120,
            style={"width": "100%", "height": "360px"},
        ),
        dmc.Code(
            "Drag the model.",
            id="ev-camera-readout",
            block=True,
            mt="sm",
        ),
    ]
)


@callback(
    Output("ev-camera-readout", "children"),
    Input("ev-camera-viewer", "camera"),
)
def show_camera(camera):
    if not camera:
        return "Drag the model."
    return (
        f"orbit  {camera['orbit']}\n"
        f"target {camera['target']}\n"
        f"fov    {camera['field_of_view']}"
    )

Eleven lines of callback for something that used to need a JavaScript file, a dcc.Store, and a ClientsideFunction.


camera_change_debounce — the prop you must not set to 0

camera-change fires at frame rate. Unthrottled, a single viewer in a single browser tab is 60 server round-trips per second.

The default is 100 ms. Setting it to 0 is permitted, documented, and means exactly what it sounds like.

dmv.ModelViewer(..., camera_change_debounce=120)   # coalesce for 120 ms
dmv.ModelViewer(..., camera_change_debounce=0)     # you are asking for the storm

camera_orbit is two-way. Naively, a callback that writes camera_orbit causes camera-change to fire, which updates camera, which re-triggers the callback — forever, as fast as the browser can manage.

The shim suppresses this by checking event.detail.source and reporting only user-interaction events. Programmatic camera moves — from a callback, from a hotspot, from the generative demo — never echo back. You can safely make camera an Input and camera_orbit an Output of the same callback.


Load progress and real dimensions

model_info carries the model's measured bounding box in metres, plus its GLTF material variants and animation clips. This is the prop that deletes the most user code: getting a model's size previously meant reaching into the element's JavaScript API and marshalling the result back through a store.

# File: docs/events-and-callbacks/load_state.py

from dash import Input, Output, callback, html
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib.demo_models import ODD_SHAPE

component = html.Div(
    [
        dmv.ModelViewer(
            id="ev-load-viewer",
            src=ODD_SHAPE,
            alt="A labelled irregular solid used to show measured dimensions",
            camera_controls=True,
            style={"width": "100%", "height": "320px"},
        ),
        dmc.Progress(id="ev-load-progress", value=0, mt="sm", animated=True),
        dmc.Text(id="ev-load-status", size="sm", mt="xs"),
        dmc.Code(id="ev-load-dims", block=True, mt="xs"),
    ]
)


@callback(
    Output("ev-load-progress", "value"),
    Output("ev-load-status", "children"),
    Input("ev-load-viewer", "model_state"),
)
def show_progress(state):
    if not state:
        return 0, "Waiting for the model…"
    pct = round((state.get("progress") or 0) * 100)
    return pct, f"{state['status']} — {pct}%"


@callback(
    Output("ev-load-dims", "children"),
    Input("ev-load-viewer", "model_info"),
)
def show_dimensions(info):
    if not info or not info.get("dimensions"):
        return "Dimensions arrive with the `load` event."
    d = info["dimensions"]
    return (
        f"width  {d['x']:.3f} m\n"
        f"height {d['y']:.3f} m\n"
        f"depth  {d['z']:.3f} m\n"
        f"variants   {info['variants'] or '(none)'}\n"
        f"animations {info['animations'] or '(none)'}"
    )

model_state["status"] is one of loading, loaded or error. Note that progress events stop at 1.0 — completion is reported once, by load, so a callback keyed on model_state does not fire twice at the end of every load.


Picking a point on the surface

With pick_on_click=True, clicking the model reports the 3D position and surface normal under the cursor — the raw material for placing a hotspot where the user pointed.

# File: docs/events-and-callbacks/picking.py

from dash import Input, Output, callback, html
import dash_mantine_components as dmc

import dash_model_viewer as dmv
from lib.demo_models import ASTRONAUT

component = html.Div(
    [
        dmv.ModelViewer(
            id="ev-pick-viewer",
            src=ASTRONAUT,
            alt="An astronaut model; clicking its surface reports the point under the cursor",
            camera_controls=True,
            # Without this the click listener returns immediately and
            # `scene_point` never updates.
            pick_on_click=True,
            style={"width": "100%", "height": "320px"},
        ),
        dmc.Text("Click the astronaut.", id="ev-pick-status", size="sm", mt="sm"),
        dmc.Code(id="ev-pick-readout", block=True, mt="xs"),
    ]
)


@callback(
    Output("ev-pick-status", "children"),
    Output("ev-pick-readout", "children"),
    Input("ev-pick-viewer", "scene_point"),
)
def show_point(point):
    # `scene_point` is None both before the first click and whenever a click
    # misses the mesh — which is the common case near the silhouette, so the
    # miss is a real state to render rather than an error to hide.
    if not point:
        return "Click the astronaut.", "No point yet — a click that misses the mesh reports None."
    uv = point.get("uv")
    return (
        "Picked.",
        f"position {point['position']}\n"
        f"normal   {point['normal']}\n"
        f"uv       {f'{uv[0]:.3f}, {uv[1]:.3f}' if uv else '(none — model has no UVs)'}",
    )

scene_point is None both before the first click and whenever a click misses the mesh — the common case near the silhouette — so check it before use. uv is None for a model with no texture coordinates; position and normal are always present on a hit.


What is deliberately not here

There is no imperative command surface — no play(), no pause(), no animation_name yet. Animation control is the obvious next addition and it is not in 1.0.0. Use attributes={"autoplay": "", "animation-name": "Wave"} in the meantime; see Attributes and parity.


Source: /events-and-callbacks

Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs: