Motion Sensors
The gyroscope, accelerometer and device orientation — how the phone is being held and moved.
All three ride the standard web DeviceMotionEvent / DeviceOrientationEvent, which fire in mobile Safari, Chrome, and both WebView shells — so this is a thin client helper, not a native bridge. What it adds over a raw addEventListener is the part hand-written code reliably forgets:
- the iOS 13+ permission gesture —
DeviceMotionEvent.requestPermission()must be called from a user gesture, and rejects otherwise; - stopping the listener — instant navigation swaps the body without a page unload, so a subscription from a page that has been replaced keeps the sensor (and the battery drain) running;
- one shared listener per event fanned out to every subscriber;
- a normalized reading in each sensor's conventional unit.
Enabling it
Referencing soli.sensors in an inline <script> is enough — the server sees it and injects the client. When your sensor code lives in an external .js file the server can't see, call motion_sensors() once (it emits the marker that turns injection on):
<%- motion_sensors() %> <%# only needed for external-JS sensor code %>
A page that references neither downloads nothing.
The API
window.soli.sensors exposes three subscriptions. Each returns a Promise<{ stop() }> — a Promise because permission may have to be asked first.
// Angular velocity — x/y/z in rad/s
const gyro = await soli.sensors.gyroscope(r => {
console.log(r.x, r.y, r.z, r.interval)
})
// Linear acceleration — x/y/z in m/s² (gravity removed by default)
const accel = await soli.sensors.accelerometer(r => {
console.log(r.x, r.y, r.z)
}, { includeGravity: false })
// Device attitude — degrees
const tilt = await soli.sensors.orientation(r => {
console.log(r.alpha, r.beta, r.gamma, r.absolute)
})
// Later — always stop what you started
gyro.stop()
accel.stop()
tilt.stop()
| Method | Reading | Unit |
|---|---|---|
gyroscope(cb, opts) |
{ x, y, z, interval } — angular velocity |
rad/s |
accelerometer(cb, opts) |
{ x, y, z, interval } — linear acceleration |
m/s² |
orientation(cb, opts) |
{ alpha, beta, gamma, absolute } — attitude |
degrees |
supported(kind) |
whether the event exists | — |
requestPermission() |
ask motion + orientation in one gesture | — |
stopAll() |
stop every active subscription | — |
Orientation axes follow the web convention: alpha is the compass rotation (0–360, absolute: true when it is true north), beta is front-to-back tilt (−180 to 180), gamma is left-to-right tilt (−90 to 90).
Options
frequency(Hz) orinterval(ms) — throttle the callback. Sensors can fire 60+ times a second; a level indicator or a compass needs far less, and throttling saves the battery.{ frequency: 10 }caps it at ten readings a second.includeGravity(accelerometer only) — by default the reading has gravity removed (DeviceMotionEvent.acceleration). Settruefor the raw with-gravity value. On a device that only reports the with-gravity reading, the gravity-free request falls back to it.
iOS needs a gesture
On iOS 13+, motion and orientation are gated behind a permission prompt that only appears if you ask from a user gesture. Call from a click, not on load:
<button id="enable">Enable motion</button>
<script>
document.getElementById("enable").addEventListener("click", async () => {
// one gesture grants both
const state = await soli.sensors.requestPermission() // {motion, orientation}
if (state.motion === "granted") {
soli.sensors.gyroscope(r => level(r))
}
})
</script>
Calling gyroscope() directly works too — it requests permission itself — but only if that call is inside the gesture. Outside one, the promise rejects with a NotAllowedError. Android and desktop browsers have no such prompt.
Per-platform reality
| Platform | Behaviour |
|---|---|
| Mobile Safari / iOS shell | Works, after the per-gesture requestPermission() prompt. |
| Chrome Android / Android shell | Works with no prompt. |
| Desktop browsers, macOS / Linux shells | The events exist, so supported() is true, but a machine with no gyroscope never emits — your callback simply never fires. Design for readings that may not come. |
supported(kind) reports whether the API exists, which is not the same as whether the hardware is present — the honest signal is that a callback never arrives, so don't block your UI waiting for the first reading.
Cleanup across instant navigation
You must call .stop() when a subscription is no longer needed. The one case the helper handles for you is instant navigation — on the soli:visit event (and on pagehide) every active subscription is stopped, because the body swap that instant-nav performs would otherwise leave a listener from the previous page running against a gyroscope nobody is watching. Re-subscribe in soli:load if the next page needs the sensor.