@valdres/browser-device-motion
v1.0.0-beta.9
Published
Reactive device acceleration and rotation from DeviceMotionEvent
Readme
browser-device-motion
Wraps the devicemotion event as read-only state: linear acceleration, acceleration including gravity and rotation rate, plus derived selectors. Readings are usually null on desktops, which have no motion sensors.
Permission
Subscribing never prompts. Where the browser gates motion behind a prompt (iOS Safari), call
requestMotionPermission()directly from a click handler, with noawaitbefore it. The specification checks the gesture's transient activation when the request starts; how a given browser presents or remembers the prompt has not been verified on iOS Safari.
Install
bun add @valdres/browser-device-motionLive example
▶ Live example: https://valdres.dev/react/plugins/browser-device-motion
Usage
Every read works through a store, with no adapter at all:
import { store } from "valdres"
import {
accelerationSelector,
motionStatusAtom,
permissionAtom,
requestMotionPermission,
} from "@valdres/browser-device-motion"
const app = store()
app.get(permissionAtom) // "prompt" on iOS until answered
button.addEventListener("click", () => {
// Called inside the click: no `await` before it.
void requestMotionPermission()
})
const stop = app.sub(accelerationSelector, () => {
draw(app.get(accelerationSelector)) // Vector3 | null
})
app.get(motionStatusAtom) // "active" while a store listens
stop()import { useValue } from "valdres-react"
import {
accelerationSelector,
permissionAtom,
requestMotionPermission,
rotationRateSelector,
} from "@valdres/browser-device-motion"
function Motion() {
const permission = useValue(permissionAtom)
const accel = useValue(accelerationSelector) // Vector3 | null
const rot = useValue(rotationRateSelector) // RotationRateSnapshot | null
return (
<div>
{permission === "prompt" && (
<button onClick={() => void requestMotionPermission()}>Enable motion</button>
)}
<pre>{JSON.stringify({ accel, rot }, null, 2)}</pre>
</div>
)
}Adapter support in this beta
valdres-reactis the only adapter migrated to the v1 core. The Vue, Svelte, Solid and Angular adapters cannot read an external atom yet. Until they ship, read this package withstore.get/store.subas above.
Exports
| Export | Kind | Type |
| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| motionAtom | selector (read-only) | MotionSnapshot \| null |
| motionStatusAtom | selector (read-only) | MotionStatus |
| permissionAtom | external atom (read-only) | PermissionValue |
| accelerationSelector | selector | Vector3 \| null |
| accelerationIncludingGravitySelector | selector | Vector3 \| null |
| accelerationMagnitudeSelector | selector | number \| null |
| rotationRateSelector | selector | RotationRateSnapshot \| null |
| intervalSelector | selector | number \| null |
| requestMotionPermission | util fn | () => Promise<PermissionValue> |
| MotionSnapshot | type | { acceleration, accelerationIncludingGravity, rotationRate: ... \| null; interval: number; timeStamp: number } |
| Vector3 | type | { x, y, z: number \| null } |
| RotationRateSnapshot | type | { alpha, beta, gamma: number \| null } |
| MotionStatus | type | "unsupported" \| "insecure" \| "idle" \| "active" |
| PermissionValue | type | "granted" \| "denied" \| "prompt" \| "unsupported" |
Ownership
Motion belongs to the device, so every export is read-only: stores cannot
set, reset or update them, at compile time or at runtime. motionAtom and
motionStatusAtom project one source, so a reading and the status it was
published with are never observed apart.
motionStatusAtom is:
"unsupported"without awindowor aDeviceMotionEvent(servers, workers, browsers without the API);"insecure"whenwindow.isSecureContextisfalse— the API is restricted to secure contexts, so nothing is attached;"idle"while no store tree listens;"active"while at least one store tree listens. Readings can still benull: no sensor, permission not granted yet, or a hidden page (browsers only firedevicemotionfor visible documents).
Permission
permissionAtom never prompts. Its value is:
- the newest answer this page has seen, shared by every store: a completed
requestMotionPermission(), a Permissions API answer or a laterchangeevent — an answer whose operation started earlier never overwrites a newer one; "granted"observed from adevicemotionreading with values, as evidence of access rather than an authoritative answer. Browsers only deliver readings once access is granted, so a reading may fill an unknown or"prompt"state — this is how the package learns a grant given through@valdres/browser-device-orientation, since iOS asks once for both. It never overrides a recorded denial; only a newer request, query answer orchangeevent can recover from one. All-null readings, which Chromium sends without access, prove nothing;- while a store subscribes, the Permissions API's combined answer for
accelerometerandgyroscopewhere the engine knows those names (Chromium), followed through itschangeevents while a store subscribes; - otherwise
"prompt"whereDeviceMotionEvent.requestPermissionexists, and"granted"where it does not (those browsers fire events without asking); "unsupported"without the API or in an insecure context — nothing can be asked there. The status keeps the two apart ("unsupported"vs"insecure"), and a denial is"denied", never"unsupported".
requestMotionPermission() is the only call that can show a prompt. It calls
DeviceMotionEvent.requestPermission() synchronously, inside the caller's
task, so a call made directly from a click handler — with no await before it
— reaches the browser while that click's transient activation is still
current, which is when the specification checks it. That is all the package
guarantees: whether a browser then prompts, and how long it remembers the
answer, is the browser's behaviour. Verified in Chromium 151 and 154 (headless,
with emulated permission states); the iOS Safari prompt flow is not yet
verified:
| Outcome | Resolves | Recorded |
| ------------------------------------------------------------- | ------------------------ | ------------------------------ |
| The user grants or denies | "granted" / "denied" | yes, for every store |
| No transient activation (NotAllowedError) or a host failure | the current value | no — a later gesture can retry |
| The browser has no requestPermission | the current value | no |
| No API, or an insecure context | "unsupported" | no |
Concurrent calls share one request. A browser prompt cannot be cancelled; a request that completes after every store was disposed still records its answer, which later permission updates replace.
Errors. The promise never rejects and the call never throws: it settles
with the browser's answer (or the current value, as above). If a store
subscriber throws while that answer is published, every store is still
notified, the answer is still recorded and returned, and the subscriber failure
is reported separately through globalThis.reportError — or thrown in a later
task where there is none — the way core reports onCommit failures. The
request is never retried. A subscriber that throws on a Permissions API answer
is reported the same way; one that throws on a change event is rethrown from
that event's listener, which the browser reports.
Unsubscribed reads may be stale. While no store subscribes, nothing observes the permission: a read returns the last answer this page saw, which can be out of date if the user changed the setting meanwhile. Observation resumes with the next subscription, which queries again and corrects it.
Server rendering
The server snapshots are fixed seeds: motionStatusAtom is "idle",
motionAtom is null and permissionAtom is "prompt". useValue renders
them first and switches to the live values after hydration. Nothing attaches,
queries or prompts during a server render.
Lifetime
One devicemotion listener per window is shared by every store tree that
retains the source and removed with the last one — when the last retaining
subscriber leaves or its store is disposed. Child scopes share their root's
registration. The window's latest reading is cached while attached and
discarded on detach, so a later subscription never reports a stale reading.
Importing the package and reading without a subscription attach nothing.
Every store tree is invalidated for each reading even when another tree's subscriber throws; the failures are rethrown afterwards from the native listener, where the browser reports them.
Full documentation: https://valdres.dev/react/plugins/browser-device-motion
