Attribute Tour
The model-viewer attributes with no named prop — loading, reveal, scale, skybox, pan and tap locks, and the AR-only ones — each shown or honestly marked as unshowable.
Why this page exists
Attributes and parity argues that the attributes dict and mv_* wildcards reach every attribute <model-viewer> has. This page is the evidence: it drives eleven attributes that have no named prop on ModelViewer, and says plainly which of them you cannot see from a desktop browser.
It was written from an audit against modelviewer.dev's own example pages. Those eleven were reachable from this package on the day it shipped and demonstrated nowhere — which is a documentation gap rather than a capability one, and the kind the 1.0.0 review kept finding.
# File: docs/attribute-tour/attribute_tour.py
from dash import Input, Output, State, callback, html
import dash_mantine_components as dmc
import dash_model_viewer as dmv
from lib.demo_models import ASTRONAUT, MOON_HDR
#: Always on, so the model is lit and the ground reads as ground.
BASE = {"environment-image": "neutral", "shadow-softness": "0.6"}
SCALES = {"1 1 1": "actual size", "0.5 0.5 0.5": "half", "2 2 2": "double"}
def build_attrs(no_pan, no_tap, no_prompt, scale, skybox, skybox_height,
loading, reveal):
"""The attribute dict, from the controls. Pure, so it can be tested."""
attrs = dict(BASE)
attrs["scale"] = scale
attrs["loading"] = loading
attrs["reveal"] = reveal
# Boolean attributes: present means on. OMITTED when off, never "false" —
# "false" is a present value and would leave them on. See the page.
if no_pan:
attrs["disable-pan"] = ""
if no_tap:
attrs["disable-tap"] = ""
if no_prompt:
attrs["interaction-prompt"] = "none"
if skybox:
attrs["skybox-image"] = MOON_HDR
# Only meaningful with a skybox; sending it alone does nothing, which
# is why it is nested rather than listed beside the others.
if skybox_height:
attrs["skybox-height"] = f"{skybox_height}m"
return attrs
def viewer(attrs):
return dmv.ModelViewer(
id="at-viewer",
src=ASTRONAUT,
alt="An astronaut used to demonstrate model-viewer attributes that have no named prop",
camera_controls=True,
shadow_intensity=1,
attributes=attrs,
style={"width": "100%", "height": "460px"},
)
CONTROLS = dmc.Stack(
gap="sm",
children=[
dmc.Text("What the user may do", size="xs", fw=700),
dmc.Switch(id="at-pan", label="disable-pan", checked=False),
dmc.Switch(id="at-tap", label="disable-tap", checked=False),
dmc.Switch(id="at-prompt", label="interaction-prompt: none", checked=False),
dmc.Divider(),
dmc.Text("The scene", size="xs", fw=700),
dmc.Select(
id="at-scale", label="scale",
data=[{"value": k, "label": f"{k} — {v}"} for k, v in SCALES.items()],
value="1 1 1", allowDeselect=False,
),
dmc.Switch(id="at-skybox", label="skybox-image", checked=False),
dmc.NumberInput(
id="at-skybox-height", label="skybox-height (m)",
value=0, min=0, max=10, step=1,
),
dmc.Divider(),
dmc.Text("When it loads", size="xs", fw=700),
dmc.Select(
id="at-loading", label="loading",
data=["auto", "lazy", "eager"], value="auto", allowDeselect=False,
),
dmc.Select(
id="at-reveal", label="reveal",
data=["auto", "interaction"], value="auto", allowDeselect=False,
),
dmc.Button(
"Reload the model",
id="at-reload",
variant="light",
mt="xs",
),
dmc.Text(
"loading and reveal only act while a model is loading. "
"Press this to see them.",
size="xs", c="dimmed",
),
],
)
component = html.Div(
[
dmc.Grid(
gutter="md",
children=[
dmc.GridCol(CONTROLS, span={"base": 12, "md": 4}),
dmc.GridCol(
# The viewer is HERE, in the initial layout, and updates in
# place. An earlier version rebuilt it from a callback on
# every control, which reloaded the model and reset the
# camera each time — so `disable-pan` and `disable-tap` did
# work and were impossible to SEE, and the remount reset the
# idle timer so `interaction-prompt: none` made the prompt
# reappear. The reload is now an explicit button.
html.Div(id="at-mount", children=viewer(build_attrs(
False, False, False, "1 1 1", False, 0, "auto", "auto"
))),
span={"base": 12, "md": 8},
),
],
),
dmc.Code(id="at-readout", block=True, mt="sm"),
]
)
_CONTROL_STATE = (
Input("at-pan", "checked"),
Input("at-tap", "checked"),
Input("at-prompt", "checked"),
Input("at-scale", "value"),
Input("at-skybox", "checked"),
Input("at-skybox-height", "value"),
Input("at-loading", "value"),
Input("at-reveal", "value"),
)
@callback(
Output("at-viewer", "attributes"),
Output("at-readout", "children"),
*_CONTROL_STATE,
)
def apply_attributes(*values):
"""Update the live element IN PLACE — no reload, no camera reset.
That is what makes `disable-pan`, `disable-tap` and `interaction-prompt`
observable: you can toggle one and immediately try the gesture against the
same view. The shim diffs the dict and removes whatever disappeared.
"""
attrs = build_attrs(*values)
rendered = "\n".join(f'{k}="{v}"' if v else k for k, v in sorted(attrs.items()))
return attrs, rendered
@callback(
Output("at-mount", "children"),
Input("at-reload", "n_clicks"),
*[State(dep.component_id, dep.component_property) for dep in _CONTROL_STATE],
prevent_initial_call=True,
)
def reload_model(_clicks, *values):
"""Remount, and ONLY on request.
`loading` and `reveal` do nothing to a model that has already loaded, so
they need a fresh mount to be observable at all. Doing that on every
control change — as this page first did — obscured every other attribute
on it. A button makes the reload deliberate and leaves the rest alone.
"""
return viewer(build_attrs(*values))
The block under the viewer is the attribute dict as it reaches the element, so you can read what each control actually sent.
Reload is a button, not a side effect
Most controls here update the live element in place — toggle disable-pan and you can immediately try a two-finger drag against the same view, with the same camera, and see the difference.
loading and reveal are the exception: they only act while a model is loading, so they do nothing to a viewer that has already loaded. That is what the Reload the model button is for.
The first version rebuilt the viewer on every control change, so that loading and reveal would always be observable. It made everything else worse: toggling disable-pan reloaded the model and reset the camera, so the attribute worked and there was no way to see that it had — and the remount reset the idle timer, so interaction-prompt: none caused the prompt to reappear rather than stop.
Reported from testing, and the fix is the split above. It is a good example of a demo page being wrong in a way the code is not: every attribute reached the element correctly the whole time.
What you can see from a laptop
| Attribute | Values | What to look for |
|---|---|---|
scale | "1 1 1", "2 2 2" | The model gets bigger. Note the shadow scales with it. |
disable-pan | present | Two-finger drag (or right-drag) no longer slides the model sideways. |
disable-tap | present | A single tap no longer recentres the camera on the tapped point. |
interaction-prompt | none | The animated hand stops appearing after a few idle seconds. |
skybox-image | an .hdr URL | The grey background becomes the environment, and the model is lit by it. |
skybox-height | e.g. 2m | Only with a skybox: lifts the horizon so the model sits in the scene rather than floating in a sphere. Alone it does nothing, which is why the control is nested under the skybox toggle. |
loading | auto, lazy, eager | lazy defers until the viewer is near the viewport; eager fetches immediately. Watch the network panel, not the picture. |
reveal | auto, interaction | interaction holds the poster until you click. |
reveal="manual" is deliberately absent from the control. It holds the model until dismissPoster() is called — an imperative method, and 1.0.0 ships no imperative surface, so there is no way to dismiss it from Python. Offering the value would produce a viewer that never reveals, which is a trap rather than a demonstration.
What you cannot see from a laptop
These three are real, reachable and not demonstrable here. Listing them with their values beats pretending the page covers them:
| Attribute | Values | Needs |
|---|---|---|
ar-placement | floor, wall | A phone in AR. wall anchors the model to a vertical surface — the right choice for a picture frame or a television. |
xr-environment | present | A WebXR session on Android. Lights the model with the room's estimated lighting instead of the environment-image. |
ios-src | a .usdz URL | iOS Safari. Quick Look cannot read .glb, so an iOS AR path needs a second file. There is no named prop; use mv_ios_src or attributes. |
dmv.ModelViewer(
id="viewer", src="/assets/chair.glb", alt="A chair",
mv_ios_src="/assets/chair.usdz",
attributes={"ar-placement": "floor", "xr-environment": ""},
)
Walk those on Augmented reality with a real device. A desktop browser will report AR as unavailable and tell you nothing about whether the attributes landed.
Boolean attributes, once more
disable-pan, disable-tap and xr-environment are true by presence. So:
attributes={"disable-pan": ""} # panning is off
attributes={} # panning is on
attributes={"disable-pan": "false"} # STILL OFF — "false" is a present value
The shim removes any attribute that disappears between renders, so building a fresh dict each time — as the callback on this page does — is the pattern that works. False and None also remove, if you would rather be explicit than rely on absence.
The same trap as paused on Animation, and it is worth meeting twice: it is the single most common way an attributes dict does the opposite of what it reads like.
Source: /attribute-tour
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:
- /attribute-tour/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt