Place clickable hotspots in a panorama that fire Dash callbacks with the hotspot name

Callback Hotspots

Place clickable hotspots in a panorama that fire Dash callbacks with the hotspot name


Overview

Scene hotspots navigate a tour — callback hotspots talk to your Dash app. Pass them in the callbackHotspots prop (keyed by scene ID, separate from the scene's own hotSpots) and every click updates the lastClickedHotspot prop with the hotspot's name, ready to be used as a callback Input.

Use them for info panels, product tags in showrooms, inspection checklists, "shop the room" experiences — anything where a position in the panorama should trigger Python.


Live example

Click either ⓘ hotspot and watch the alert update from a Dash callback. The code line under the alert streams the current camera position — aim the red center dot at a feature and copy the printed pitch/yaw into your own hotspot config:

# File: docs/hotspots/hotspots_example.py

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

from dash_pannellum import DashPannellum

HOTSPOT_INFO = {
    "telescope-array": "The ALMA antennas sit at 5,000 m on the Chajnantor plateau.",
    "snowy-peaks": "The Andes ridge line, looking east toward Bolivia.",
}

component = html.Div(
    [
        DashPannellum(
            id="hotspot-demo",
            tour={
                "default": {"firstScene": "alma"},
                "scenes": {
                    "alma": {
                        "title": "ALMA Observatory",
                        "type": "equirectangular",
                        "panorama": "https://pannellum.org/images/alma.jpg",
                        "autoLoad": True,
                        "yaw": 117,
                        "pitch": -3,
                        "hfov": 110,
                    }
                },
            },
            callbackHotspots={
                "alma": [
                    {
                        "pitch": -1.2,
                        "yaw": 122.0,
                        "type": "info",
                        "text": "Telescope array",
                        "name": "telescope-array",
                    },
                    {
                        "pitch": 1.5,
                        "yaw": 60.0,
                        "type": "info",
                        "text": "Snowy peaks",
                        "name": "snowy-peaks",
                    },
                ]
            },
            showCenterDot=True,
            autoLoad=True,
            width="100%",
            height="450px",
        ),
        dmc.Alert(
            "Click a hotspot in the panorama…",
            id="hotspot-demo-alert",
            title="Nothing clicked yet",
            color="gray",
            mt="md",
        ),
        dmc.Code("aim the center dot to author coordinates", id="hotspot-demo-aim", mt="sm"),
    ]
)


@callback(
    Output("hotspot-demo-alert", "children"),
    Output("hotspot-demo-alert", "title"),
    Output("hotspot-demo-alert", "color"),
    Input("hotspot-demo", "lastClickedHotspot"),
    prevent_initial_call=True,
)
def on_hotspot_click(name):
    detail = HOTSPOT_INFO.get(name, "No description on file.")
    return detail, f"You clicked: {name}", "teal"


@callback(
    Output("hotspot-demo-aim", "children"),
    Input("hotspot-demo", "pitch"),
    Input("hotspot-demo", "yaw"),
)
def show_aim(pitch, yaw):
    return f'"pitch": {pitch or 0:.1f}, "yaw": {yaw or 0:.1f}  ← center dot position'

:defaultExpanded: false :withExpandedButton: true


Configuration

callbackHotspots = {
    "scene-id": [
        {
            "pitch": -1.2,            # position in the panorama
            "yaw": 122.0,
            "type": "info",           # rendered with Pannellum's info styling
            "text": "Telescope array",  # tooltip on hover
            "name": "telescope-array",  # value written to lastClickedHotspot
        },
    ],
}

Then wire the callback:

@callback(
    Output("panel", "children"),
    Input("my-panorama", "lastClickedHotspot"),
    prevent_initial_call=True,
)
def on_click(name):
    return f"You clicked {name}"

Scene hotSpots live inside the tour dictionary and are plain Pannellum config. callbackHotspots are merged in by the component, which attaches a real Pannellum clickHandlerFunc to each one — something JSON sent from Python cannot express directly.


Drifting hotspots — live position updates

Since 0.3.1 the live callbackHotspots diff is per-name: a update that only changes a hotspot's pitch/yaw moves the existing DOM node in place instead of destroying and recreating it. Contacts can drift every tick and stay clickable the whole way — try to catch the paramecium:

# File: docs/hotspots/drifting_example.py

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

from dash_pannellum import DashPannellum

component = html.Div(
    [
        DashPannellum(
            id="drift-pano",
            tour={
                "default": {"firstScene": "alma"},
                "scenes": {
                    "alma": {
                        "title": "ALMA Observatory",
                        "type": "equirectangular",
                        "panorama": "https://pannellum.org/images/alma.jpg",
                        "autoLoad": True,
                        "yaw": 117,
                        "pitch": -3,
                        "hfov": 100,
                    }
                },
            },
            callbackHotspots={
                "alma": [
                    {
                        "pitch": -3.0,
                        "yaw": 120.0,
                        "type": "info",
                        "text": "Paramecium — click to catch",
                        "name": "paramecium",
                    }
                ]
            },
            autoLoad=True,
            width="100%",
            height="380px",
        ),
        dmc.Group(
            [
                dmc.Switch(id="drift-on", label="Drift", checked=True, size="sm"),
                dmc.Code("waiting for drift…", id="drift-readout"),
            ],
            mt="sm",
            gap="md",
        ),
        html.Div(id="drift-log"),
        dcc.Interval(id="drift-tick", interval=400),
    ]
)

# Swim the contact along a lazy loop — pure callbackHotspots prop updates.
# Since 0.3.1 a position-only change moves the EXISTING hotspot DOM node
# in place (per-name diff), so the marker drifts instead of teleporting
# through destroy/recreate.
clientside_callback(
    """function(n, on) {
        if (!on) { return window.dash_clientside.no_update; }
        const t = (n || 0) * 0.35;
        const yaw = 120 + 22 * Math.sin(t);
        const pitch = -3 + 5 * Math.sin(t * 0.7 + 1);
        window.dash_clientside.set_props('drift-pano', {callbackHotspots: {
            alma: [{
                pitch: Math.round(pitch * 10) / 10,
                yaw: Math.round(yaw * 10) / 10,
                type: 'info',
                text: 'Paramecium — click to catch',
                name: 'paramecium',
            }],
        }});
        return 'contact at pitch ' + pitch.toFixed(1) + '\\u00b0 \\u00b7 yaw '
            + yaw.toFixed(1) + '\\u00b0';
    }""",
    Output("drift-readout", "children"),
    Input("drift-tick", "n_intervals"),
    State("drift-on", "checked"),
    prevent_initial_call=True,
)


@callback(
    Output("drift-log", "children"),
    Input("drift-pano", "lastClickedHotspot"),
    prevent_initial_call=True,
)
def caught(name):
    return dmc.Alert(
        f"Caught it mid-drift! ({name}) — the moving node is still a live "
        "click target.",
        color="teal",
        mt="sm",
    )

:defaultExpanded: false :withExpandedButton: true

The diff rules: unchanged hotspots are untouched; position-only changes mutate in place (the node survives — hover state and all); changing anything else (text, type, cssClass) recreates that one hotspot; names that vanish are removed. On an idle viewer the component nudges one render so moves show immediately; continuously-rendering viewers (dynamicUpdate, autoRotate) pick them up anyway.

Authoring workflow

  1. Set showCenterDot=True on the component.
  2. Stream pitch/yaw into a readout callback (as in the example above).
  3. Aim the dot at the feature you want to tag.
  4. Copy the printed values into your callbackHotspots entry.
  5. Remove the center dot when you ship.

Source: /components/hotspots

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: