Sensors
Browser and device state, exposed as signals you can read in a template or a computed. Online status, media queries, element size, scroll, mouse position, and more.
@mmstack/primitivesnpm
#Why sensors
Reading browser state normally means wiring up an event listener, copying the value into component state, and remembering to remove the listener on destroy. Do that for online/offline, a media query, and a ResizeObserver and you have three listeners, three fields, and three teardown paths to keep straight.
A sensor collapses that to one call. It returns a plain signal that already tracks the underlying browser API, so you read it directly in a template or a computed and it updates when the state changes. The listener is attached for you and torn down through DestroyRef when the creating context is destroyed. On the server each sensor returns a stable fallback instead of touching an API that isn't there, so the same code renders under SSR.
Reach for one whenever a piece of UI should react to the environment: a layout that changes at a breakpoint, a control that dims when the connection drops, a panel that measures itself.
Demo loads on scroll.
#One call, one signal
sensor(type, options?) creates a browser-state signal by name. Create it as a component field and read it like any other signal. Nothing else is required.
import { Component } from '@angular/core';
import { DatePipe } from '@angular/common';
import { sensor } from '@mmstack/primitives';
@Component({
selector: 'app-network-badge',
imports: [DatePipe],
template: `
@if (network()) {
<span>Online since {{ '{{' }} network.since() | date: 'short' {{ '}}' }}</span>
} @else {
<span>Offline since {{ '{{' }} network.since() | date: 'short' {{ '}}' }}</span>
}
`,
})
export class NetworkBadge {
protected readonly network = sensor('networkStatus');
} Each sensor is also available as a standalone function (networkStatus(), mediaQuery(q), and so on) if you would rather import it directly. The sensor() facade is the convenient entry point when you want one call and a consistent options bag.
#Three worth knowing
A few sensors carry their weight in most apps. Each returns a signal, and some hang extra signals or methods off it.
mediaQuery tracks a CSS media query and returns a Signal<boolean>, so you can drive a responsive layout from a computed instead of duplicating the breakpoint in CSS and TypeScript. prefersDarkMode (also reachable as 'dark-mode') and prefersReducedMotion are named shorthands for the common queries.
import { computed } from '@angular/core';
import { sensor } from '@mmstack/primitives';
const desktop = sensor('mediaQuery', { query: '(min-width: 1024px)' });
const dark = sensor('dark-mode'); // Signal<boolean>
const columns = computed(() => (desktop() ? 3 : 1));elementSize wraps a ResizeObserver and defaults its target to the host element, so with no arguments it measures the component it lives in. It returns { width, height } | undefined (undefined until the first measurement). Pair it with a computed to switch a component between compact and full layouts based on its own width, not the viewport's.
import { computed } from '@angular/core';
import { sensor } from '@mmstack/primitives';
// no argument: measures the host element via ResizeObserver
const box = sensor('elementSize');
const layout = computed(() => {
const width = box()?.width ?? 0; // undefined until first measurement
return width < 480 ? 'compact' : 'full';
});networkStatus returns a Signal<boolean> for online/offline, plus a .since signal holding the Date of the last transition. Use it to disable actions that need the network, or to show how long you have been offline.
import { sensor } from '@mmstack/primitives';
const online = sensor('networkStatus');
online(); // boolean
online.since(); // Date of the last online/offline flip#Throttling and unthrottled reads
Sensors that fire rapidly are throttled to 100ms by default: windowSize, scrollPosition, mousePosition, and pointerDrag. Pass throttle to change the window. When you need the exact, un-throttled value (a drop position, a precise scroll offset), read the .unthrottled view they expose alongside the throttled one.
import { sensor } from '@mmstack/primitives';
const mouse = sensor('mousePosition', { coordinateSpace: 'page', throttle: 50 });
mouse(); // throttled { x, y }
mouse.unthrottled(); // exact current coordinates
const size = sensor('windowSize', { throttle: 200 }); // widen the window#The full set
Every sensor takes an options bag and, where it targets an element (elementSize, elementVisibility, focusWithin, pointerDrag), defaults target to the host so it is drop-in inside a component.
| Type | Returns | Notes |
|---|---|---|
networkStatus | Signal<boolean> + .since | Online/offline. .since is the last transition. |
pageVisibility | Signal<DocumentVisibilityState> | 'visible' | 'hidden' | 'prerender'. |
mediaQuery | Signal<boolean> | Generic CSS media query. query is required. |
dark-mode | Signal<boolean> | Shorthand for (prefers-color-scheme: dark). |
reduced-motion | Signal<boolean> | Shorthand for (prefers-reduced-motion: reduce). |
windowSize | Signal<{ width, height }> + .unthrottled | Throttled 100ms. |
scrollPosition | Signal<{ x, y }> + .unthrottled | Window or element scroll, throttled 100ms. |
mousePosition | Signal<{ x, y }> + .unthrottled | Throttled 100ms. coordinateSpace: 'client' | 'page'. |
pointerDrag | Signal<PointerDragState> + .unthrottled + .cancel() | Pointer gesture with activationThreshold and delta. |
elementSize | Signal<{ width, height } | undefined> | ResizeObserver-based. Defaults to the host. |
elementVisibility | Signal<IntersectionObserverEntry?> + .visible | IntersectionObserver-based; .visible is a boolean. |
focusWithin | Signal<boolean> | Mirrors :focus-within. |
geolocation | Signal<GeolocationPosition?> + .error + .loading | One-shot by default; watch: true for continuous. |
clipboard | Signal<string> + .copy(v) + .isSupported | Mirrors clipboard contents; .copy writes through. |
orientation | Signal<{ angle, type }> | Tracks screen.orientation. |
batteryStatus | Signal<BatteryStatus | null> | null until (or unless) the API resolves. |
idle | Signal<boolean> + .since | Flips true after ms of inactivity. |
#Several at once
When one consumer needs a handful of them, sensors([...]) creates several in one call and returns them keyed by type, with optional per-sensor options.
import { sensors } from '@mmstack/primitives';
const { networkStatus, windowSize } = sensors(
['networkStatus', 'windowSize'],
{ windowSize: { throttle: 200 } },
);#signalFromEvent
Most sensors are shaped from signalFromEvent, a generic EventTarget to signal helper. It is not on the sensor() facade because it takes positional arguments rather than an options bag, but it is there when you need to track an event the built-in sensors do not cover. The optional projector plucks just the data you want, and the target can itself be a signal, so the listener moves when it flips.
import { signalFromEvent } from '@mmstack/primitives';
// project just the coordinates off each mousemove
const point = signalFromEvent<MouseEvent, { x: number; y: number }>(
document,
'mousemove',
{ x: 0, y: 0 },
(e) => ({ x: e.clientX, y: e.clientY }),
);