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

**Site index:** [https://modelviewer.2plot.dev/llms.txt](https://modelviewer.2plot.dev/llms.txt) — every page on this site, as Markdown.  
**Network index:** [https://2plot.dev/llms.txt](https://2plot.dev/llms.txt) — The 2plot network; start here to discover sibling sites.  
**Sibling sites:** 13 more in The 2plot network — listed in the site index above.  
**Sitemap:** https://modelviewer.2plot.dev/sitemap.xml  


---



### Why this page exists

[Attributes and parity](/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.



```python
# 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`. |

```python
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](/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:

```python
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](/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*
