# Callback Hotspots

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

**Site index:** [https://pannellum.2plot.dev/llms.txt](https://pannellum.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://pannellum.2plot.dev/sitemap.xml  


---



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



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

```python
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:

```python
@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:



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