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:
| 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:
# 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
orientationis on, grabbing the panorama pauses
gyro steering (Pannellum's behavior); orientationActive reflects it.
- Gyro +
lookAt: an imperativelookAt(a fly-to
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.
- Gyro + dynamic canvas: fully compatible — the sensor steers the
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:
- /components/gyro/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt