Model Switching and Variants
Swap the model at runtime, and drive GLTF material variants from a dropdown the viewer populates itself.
Swapping models
src is an ordinary prop. Change it from a callback and the viewer loads the new file, keeping the camera where the user left it.
# File: docs/model-switching/switching.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 GLAM_SOFA, MODELS_WITH_VARIANTS
MODELS = MODELS_WITH_VARIANTS
component = html.Div(
[
dmc.Group(
[
dmc.SegmentedControl(id="ms-model", data=list(MODELS), value="Sofa"),
dmc.Select(
id="ms-variant",
placeholder="Variant",
data=[],
w=220,
clearable=True,
),
],
mb="sm",
),
dmv.ModelViewer(
id="ms-viewer",
src=GLAM_SOFA,
alt="A velvet sofa whose material variants can be switched at runtime",
camera_controls=True,
shadow_intensity=1,
style={"width": "100%", "height": "380px"},
),
dmc.Text(id="ms-status", size="sm", c="dimmed", mt="xs"),
]
)
@callback(Output("ms-viewer", "src"), Input("ms-model", "value"))
def swap_model(name):
return MODELS[name]
@callback(
Output("ms-variant", "data"),
Output("ms-variant", "value"),
Output("ms-variant", "disabled"),
Output("ms-status", "children"),
Input("ms-viewer", "model_info"),
)
def list_variants(info):
"""The viewer tells us which variants the file actually contains.
The empty case is rendered as a visible, disabled state rather than an
enabled dropdown with nothing in it — a control that looks operable and
does nothing reads as a broken page, which is exactly how this one read
when two of its three models carried no variants.
"""
if not info:
# Not loaded yet. Saying "no variants" here would be a lie for the
# first second of every page view, and an alarming one on a page whose
# whole subject is variants.
return [], None, True, "Loading the model…"
variants = info.get("variants") or []
if not variants:
return [], None, True, "This model carries no material variants."
plural = "s" if len(variants) != 1 else ""
return (
variants,
None,
False,
f"{len(variants)} variant{plural}: {', '.join(variants)}",
)
@callback(
Output("ms-viewer", "variant_name"),
Input("ms-variant", "value"),
)
def choose_variant(value):
"""Clearing the dropdown must CLEAR the prop, not leave the old value.
This callback used to `return no_update` when nothing was selected, which
looked harmless and was not: switching models leaves `ms-variant.value`
None, so the viewer kept the variant chosen on the *previous* model and
applied a name the new file does not contain. `"default"` is the GLTF
default material — the shim drops the attribute entirely for that value,
which is how model-viewer expresses "no variant".
"""
return value or "default"
Variants populate themselves
The interesting part is the second callback. The variant dropdown is not hard-coded — it is filled from model_info["variants"], which the viewer reports after the file loads.
@callback(
Output("ms-variant", "data"),
Input("ms-viewer", "model_info"),
)
def list_variants(info):
return (info or {}).get("variants") or []
Switch between the three and the dropdown refills itself each time — five names for the sofa, two for the chair, three for the shoe. Nothing on the server knows anything about any of those files.
This is the shape of every "user uploads their own model" feature, and it was not possible in 0.0.1 — the variant list lived in the browser and there was no way to get it out.
The third callback returns value or "default" rather than no_update. That looks like a detail and is not: switching models leaves the dropdown empty, and a callback that declines to update leaves variant_name holding the variant you picked on the previous model — a name the new file does not contain. The viewer then renders the new model with a stale variant request.
All three models here carry variants, which is deliberate. Only five of the fifteen Khronos sample models do, and a page named for the feature should not offer models that cannot show it.
variant_name
| Value | Effect |
|---|---|
None | The GLTF's default variant. |
"default" | Also the default — accepted for readability. |
"Midnight" | That named variant, if present. |
An unknown name is ignored by <model-viewer> rather than raising, so validate against model_info["variants"] if it matters.
Loading state during a swap
A large model swap is not instant, and an unstyled viewer shows the old model until the new one is ready. Use model_state to say so:
@callback(Output("overlay", "style"), Input("viewer", "model_state"))
def spinner(state):
loading = (state or {}).get("status") == "loading"
return {"display": "flex" if loading else "none"}
A poster image is the cheaper version of the same idea — it covers the first load, though not subsequent swaps:
dmv.ModelViewer(..., poster="/assets/preview.webp")
src is a URL, so the browser caches it. If your models are user-uploaded and can change at the same URL, append a version or hash — otherwise a re-upload shows the previous mesh and looks like the upload failed.
Animations
model_info["animations"] lists the clips in the file. Playback control is not a named prop in 1.0.0; drive it through attributes meanwhile:
@callback(Output("viewer", "attributes"), Input("clip", "value"))
def play(clip):
return {"autoplay": "", "animation-name": clip}
Source: /model-switching
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:
- /model-switching/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt