# Animation

> Play a model's built-in animation clips, switch between them with a crossfade, and pause — entirely through attributes, with the clip list reported by the model itself.

**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  


---



### Clips come from the model, not from the page

A `.glb` can carry animation clips, and `model_info["animations"]` lists the
ones it has. Robot Expressive carries fourteen — `Dance`, `Death`, `Idle`,
`Jump`, `No`, `Punch`, `Running`, `Sitting`, `Standing`, `ThumbsUp`, `Walking`,
`WalkJump`, `Wave`, `Yes`.

The dropdown is filled from that prop, so this page would work unchanged if you
pointed `src` at a different animated model. A hardcoded list of those fourteen
names would have looked identical today and broken silently the first time
somebody swapped the model.



```python
# File: docs/animation/animation.py

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

import dash_model_viewer as dmv
from lib.demo_models import ROBOT

#: Lighting that makes a moving figure readable. A matte grey character against
#: a flat background reads as a bug report; this reads as a character.
BASE_ATTRS = {
    "environment-image": "neutral",
    "shadow-softness": "0.6",
}

#: Milliseconds of blend when switching clips. 0 is a hard cut and is the
#: default most people expect to be wrong — see the page.
DEFAULT_CROSSFADE = 300

component = html.Div(
    [
        dmc.Group(
            [
                dmc.Select(
                    id="an-name",
                    label="Animation",
                    # Filled from the model itself, not hardcoded — see below.
                    data=[],
                    w=220,
                    allowDeselect=False,
                ),
                dmc.NumberInput(
                    id="an-crossfade",
                    label="Crossfade (ms)",
                    value=DEFAULT_CROSSFADE,
                    min=0,
                    max=2000,
                    step=100,
                    w=150,
                ),
                dmc.Switch(
                    id="an-playing",
                    label="Playing",
                    checked=True,
                    mt="xl",
                ),
            ],
            mb="sm",
            align="flex-end",
        ),
        dmv.ModelViewer(
            id="an-viewer",
            src=ROBOT,
            alt="An expressive cartoon robot playing one of its built-in animation clips",
            camera_controls=True,
            shadow_intensity=1,
            # `autoplay` starts the first clip. Without it the model loads in
            # its bind pose and looks broken rather than idle.
            attributes={**BASE_ATTRS, "autoplay": ""},
            style={"width": "100%", "height": "460px"},
        ),
        dmc.Text(id="an-status", size="sm", c="dimmed", mt="xs"),
    ]
)


@callback(
    Output("an-name", "data"),
    Output("an-name", "value"),
    Output("an-status", "children"),
    Input("an-viewer", "model_info"),
    State("an-name", "value"),
)
def list_animations(info, current):
    """The viewer reports the clips; the page never hardcodes them.

    `model_info["animations"]` is the same prop /model-switching uses for
    material variants, and it arrives with the `load` event. So this picker
    works for ANY model the src is pointed at — which is the point, and is
    what a hardcoded list of Robot Expressive's fourteen clips would have
    quietly broken the moment somebody changed the model.
    """
    if not info:
        return [], None, "Loading the model…"
    clips = info.get("animations") or []
    if not clips:
        return [], None, "This model carries no animation clips."
    chosen = current if current in clips else clips[0]
    return clips, chosen, f"{len(clips)} clips: {', '.join(clips)}"


@callback(
    Output("an-viewer", "attributes"),
    Input("an-name", "value"),
    Input("an-crossfade", "value"),
    Input("an-playing", "checked"),
)
def drive_animation(name, crossfade, playing):
    """Everything here is an ATTRIBUTE, not a method call.

    1.0.0 ships no imperative surface — no `play()`, no `pause()` — so the
    whole of this page runs through the `attributes` escape hatch. That is the
    honest demonstration of what the hatch is for.

    `paused` is a boolean attribute: its PRESENCE pauses. So resuming means
    leaving the key out of the dict entirely rather than setting it false, and
    the shim removes any attribute that disappears between renders.
    """
    if not name:
        return no_update
    attrs = {
        **BASE_ATTRS,
        "autoplay": "",
        "animation-name": name,
        "animation-crossfade-duration": str(crossfade or 0),
    }
    if not playing:
        attrs["paused"] = ""
    return attrs
```


It is the same prop that drives the variant dropdown on
[Model Switching](/model-switching) — one read-only payload, two features.

---

### Everything here is an attribute

There is no `animation_name` prop on `ModelViewer`, and that is deliberate:
1.0.0 ships **no imperative surface** — no `play()`, no `pause()`, no
`activateAR()`. So this entire page runs through the `attributes` escape hatch,
which makes it the most honest demonstration on the site of what that hatch is
for.

| Attribute | Effect |
| :-- | :-- |
| `autoplay` | Start playing on load. Without it the model loads in its bind pose, which reads as broken rather than as idle. |
| `animation-name` | Which clip. Changing it switches clip. |
| `animation-crossfade-duration` | Milliseconds to blend between clips. |
| `paused` | **Presence** pauses. |

```python
dmv.ModelViewer(
    id="viewer",
    src=ROBOT,
    alt="A robot, mid-wave",
    attributes={"autoplay": "", "animation-name": "Wave"},
)
```


    Resuming means **leaving the key out of the dict**, not setting it to
    `False` or `"false"`. An HTML boolean attribute is true by presence, so
    `paused="false"` is still paused.

    The shim removes any attribute that disappears between renders, so building
    a fresh dict each time — as the callback above does — is the pattern that
    works. `False` and `None` also remove, if you prefer to be explicit.

---

### Crossfade is the setting worth playing with

Set it to `0` and switching clips is a hard cut: the robot teleports from one
pose into the next. At `300` ms the poses blend and it reads as a character
changing its mind.

The default in `<model-viewer>` is `300`, and it is one of the few upstream
defaults this page leaves alone — it is already the right answer, which is
worth knowing before you reach for it.

---

### What is not here

Honest about the edges, because the gap is in the package rather than in this
page:

- **No scrubbing and no speed control.** `currentTime` and `timeScale` are
  JavaScript properties with no attribute equivalent, so they are unreachable
  from Python in 1.0.0. A clientside callback can drive them — see the one on
  [Texture Upload](/texture-upload) for the shape — but there is no prop.
- **No "animation finished" event.** `<model-viewer>` fires no such event, so
  there is nothing for the shim to forward.
- **No `appendAnimation`.** Composing clips from several files is an imperative
  API.

If you need any of those today, the escape hatch is JavaScript. If enough
people need them, they are props — which is the better answer and is not in
this release.

---

### Models that actually carry clips

Of the demo models this site uses, **only Robot Expressive has animations** —
measured by reading each `.glb`'s JSON chunk, not assumed. The astronaut, the
shoe, the sofa, the chair, the skull and the glass all have none, which is why
this page does not offer them: a dropdown that is empty for six of seven
choices is how [Model Switching](/model-switching) used to read before its
models were chosen for the feature it documents.


---

*Source: /animation*
