# Gyro Look-Around

> Steer the 360° camera with the device's gyroscope — orientation props, the iOS permission dance, and a desktop tilt simulator

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


---



### What this adds

Hold the phone up and the panorama looks where the phone looks. Gyro
look-around is the most literal form of embodiment the component offers —
no dragging, no joystick: the device *is* the camera. Pannellum ships the
sensor pipeline natively (device-orientation events → quaternion → view);
0.4.0 wires it into the Dash prop system as one request prop and two
truth props:

| Prop | Direction | Meaning |
|------|-----------|---------|
| `orientation` | in (imperative) | Request gyro steering on/off — no rebuild |
| `orientationSupported` | **out** | This browser/device can do it (sensors + mobile browser) |
| `orientationActive` | **out** | The gyro is steering right now |

Request and truth are separate on purpose: the user can deny the iOS
permission prompt, the device may have no sensors, and Pannellum pauses
gyro steering when the user grabs the panorama — `orientationActive` is
the only honest answer.

### Live example

On a phone or tablet, flip the switch and move the device. On a desktop,
the badge will tell you there's no sensor — use the drag pad, which feeds
the camera the same continuous `lookAt` stream a gyroscope would:



```python
# File: docs/gyro/gyro_example.py

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

from dash_pannellum import DashPannellum

component = html.Div(
    [
        DashPannellum(
            id="gyro-pano",
            tour={
                "default": {"firstScene": "toco"},
                "scenes": {
                    "toco": {
                        "title": "Cerro Toco",
                        "type": "equirectangular",
                        "panorama": "https://pannellum.org/images/cerro-toco-0.jpg",
                        "autoLoad": True,
                        "yaw": 5,
                        "pitch": 0,
                        "hfov": 100,
                    }
                },
            },
            autoLoad=True,
            compass=True,
            width="100%",
            height="420px",
        ),
        dmc.Group(
            [
                dmc.Switch(
                    id="gyro-switch",
                    label="📱 Gyro look-around",
                    checked=False,
                    size="md",
                ),
                dmc.Badge("sensor: checking…", id="gyro-supported", variant="light",
                          color="gray"),
                dmc.Badge("inactive", id="gyro-active", variant="light", color="gray"),
                dmc.Code("camera idle", id="gyro-readout"),
            ],
            mt="md",
            gap="md",
            wrap="wrap",
        ),
        dmc.Group(
            [
                html.Div(
                    html.Div(
                        id="gyro-pad-dot",
                        style={
                            "position": "absolute",
                            "left": "50%",
                            "top": "50%",
                            "width": "16px",
                            "height": "16px",
                            "borderRadius": "50%",
                            "background": "#12B886",
                            "transform": "translate(-50%, -50%)",
                            "pointerEvents": "none",
                            "boxShadow": "0 0 12px rgba(18, 184, 134, 0.8)",
                        },
                    ),
                    id="gyro-pad",
                    style={
                        "position": "relative",
                        "width": "140px",
                        "height": "140px",
                        "borderRadius": "12px",
                        "border": "2px dashed var(--mantine-color-gray-5)",
                        "cursor": "grab",
                        "touchAction": "none",
                        "flexShrink": 0,
                    },
                ),
                dmc.Text(
                    "No motion sensors on this device? Drag the pad — it feeds "
                    "the camera the same continuous lookAt stream a phone's "
                    "gyroscope would. On a phone, flip the switch instead and "
                    "move the device itself.",
                    size="xs",
                    c="dimmed",
                    style={"flex": 1, "minWidth": 220},
                ),
            ],
            mt="md",
            gap="lg",
            align="center",
        ),
    ]
)

# The gyro request MUST be clientside: on iOS, startOrientation triggers the
# DeviceOrientationEvent permission prompt, which Safari only allows inside
# a user-gesture window — a server round-trip would fall outside it.
clientside_callback(
    """function(checked) { return Boolean(checked); }""",
    Output("gyro-pano", "orientation"),
    Input("gyro-switch", "checked"),
    prevent_initial_call=True,
)


@callback(
    Output("gyro-supported", "children"),
    Output("gyro-supported", "color"),
    Output("gyro-active", "children"),
    Output("gyro-active", "color"),
    Input("gyro-pano", "orientationSupported"),
    Input("gyro-pano", "orientationActive"),
)
def gyro_status(supported, active):
    sup = ("sensor: available", "teal") if supported else \
        ("sensor: not on this device", "gray")
    act = ("gyro steering", "teal") if active else ("inactive", "gray")
    return sup[0], sup[1], act[0], act[1]


clientside_callback(
    """function(pitch, yaw) {
        const f = (v) => (typeof v === 'number' ? v.toFixed(1) : '0');
        return 'pitch ' + f(pitch) + '\\u00b0 \\u00b7 yaw ' + f(yaw) + '\\u00b0';
    }""",
    Output("gyro-readout", "children"),
    Input("gyro-pano", "pitch"),
    Input("gyro-pano", "yaw"),
)
```

    :defaultExpanded: false
    :withExpandedButton: true

### The platform matrix

| Platform | Behavior |
|----------|----------|
| iOS 13+ (Safari/WebKit) | `startOrientation` triggers the system permission prompt — see the gesture rule below |
| Android (Chrome/Firefox) | Works directly, no prompt |
| Desktop browsers | `orientationSupported` stays `False` — Pannellum gates on a mobile user agent, since desktop "orientation" sensors are noise |
| HTTPS | **Strictly required** — Pannellum checks `location.protocol === "https:"` literally, so plain-HTTP localhost reports unsupported too (use a TLS dev cert or a tunnel to test on a phone) |


    Safari only shows the motion-sensor permission prompt inside a
    user-gesture window. That's why the example wires the switch through a
    **clientside callback** straight to the `orientation` prop — a server
    round-trip would land outside the gesture and the prompt would be
    silently blocked. Rule of thumb: the write that sets
    `orientation=True` should be the direct result of a tap, with no
    server hop in between.

### How the pieces interact

- **Gyro + drag**: while `orientation` is on, grabbing the panorama pauses
  gyro steering (Pannellum's behavior); `orientationActive` reflects it.
- **Gyro + `lookAt`**: an imperative `lookAt` (a [fly-to](/components/scenes)
  or an [emote](/components/emotes)) competes with the sensor stream —
  turn `orientation` off for directed sequences, back on after. A
  knockdown emote that fights the gyroscope feels broken, not dramatic.
- **Gyro + dynamic canvas**: fully compatible — the sensor steers the
  camera while the [arena](/components/arena) redraws the world under it.
  Phone-in-hand petri dish, no extra code.

### Where this is headed

Gyro turns the camera yaw into a *physical* fact, which makes two future
layers click into place: **spatial audio** (pan sources by the angle
between `yaw` and each contact's bearing — the readouts this page streams
are exactly the inputs an `AudioContext` panner needs) and **world-anchored
zones** (agar hazards that you *hear and face* before you see). The
component-side groundwork for both is already here: `yaw`/`pitch`/`hfov`
out, `orientation` in.


---

*Source: /components/gyro*
