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

Gyro Look-Around

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


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:

PropDirectionMeaning
orientationin (imperative)Request gyro steering on/off — no rebuild
orientationSupportedoutThis browser/device can do it (sensors + mobile browser)
orientationActiveoutThe 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:

# 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

PlatformBehavior
iOS 13+ (Safari/WebKit)startOrientation triggers the system permission prompt — see the gesture rule below
Android (Chrome/Firefox)Works directly, no prompt
Desktop browsersorientationSupported stays False — Pannellum gates on a mobile user agent, since desktop "orientation" sensors are noise
HTTPSStrictly 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 steering (Pannellum's behavior); orientationActive reflects it.

or an emote) 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.

camera while the 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

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: