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.

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.


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.

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

AttributeEffect
autoplayStart playing on load. Without it the model loads in its bind pose, which reads as broken rather than as idle.
animation-nameWhich clip. Changing it switches clip.
animation-crossfade-durationMilliseconds to blend between clips.
pausedPresence pauses.
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:

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 for the shape — but there is no prop.

there is nothing for the shim to forward.

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 used to read before its models were chosen for the feature it documents.


Source: /animation

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: