Media
What's new in 1.14.0
Media graduated to a batteries-included tier in 1.14.0 with 25+ new reactive composables: preference signals (usePreferredColorScheme, usePreferredContrast, usePreferredReducedTransparency, usePreferredLanguage, usePreferredLanguages), page state (useOnlineStatus, usePageVisibility, useDocumentFocus, useWindowFocus, useIdle), element observers (useElementSize, useElementBounding, useElementVisibility, useHover, useFocus, useFocusWithin, useActiveElement), pointer/scroll (usePointer, useScroll), platform integrations (usePermission, useWakeLock, useShare, useShareSupported, useBroadcastChannel, useEventListener, useMediaDevices, useStorage), and clipboard upgrades (isSupported, isImageSupported, readImage, writeImage, clipboardText). Every composable accepts an optional { signal: AbortSignal } for auto-teardown. See the 1.14.0 release notes.
The media module exposes reactive wrappers and helpers around browser and device APIs. Many composables return reactive signals that update automatically when the underlying browser state changes, typically as a MediaSignalHandle with a destroy() method for cleanup. Some utilities return helper objects with their own APIs instead.
import {
breakpoints,
clipboard,
mediaQuery,
useBattery,
useDeviceMotion,
useDeviceOrientation,
useGeolocation,
useIntersectionObserver,
useMutationObserver,
useNetworkStatus,
useResizeObserver,
useViewport,
} from '@bquery/bquery/media';What's new in 1.14
Media graduates into a batteries-included tier. Every new composable accepts an optional { signal: AbortSignal } for auto-teardown.
Preference signals
import {
usePreferredColorScheme,
usePreferredContrast,
usePreferredReducedTransparency,
usePreferredLanguage,
usePreferredLanguages,
} from '@bquery/bquery/media';
const theme = usePreferredColorScheme(); // 'light' | 'dark' | 'no-preference'
const lang = usePreferredLanguage();Page-state signals
import {
useOnlineStatus,
usePageVisibility,
useDocumentFocus,
useWindowFocus,
useIdle,
} from '@bquery/bquery/media';
const online = useOnlineStatus(); // Signal<boolean>
const visibility = usePageVisibility(); // 'visible' | 'hidden'
const idle = useIdle(60_000); // true after 60s without inputElement observers
useElementSize, useElementBounding, useElementVisibility, useHover, useFocus, useFocusWithin, useActiveElement. Targets accept plain Element | null | undefined values.
import { useElementSize, useElementVisibility } from '@bquery/bquery/media';
const size = useElementSize(myEl); // Signal<{ width, height }>
const visible = useElementVisibility(myEl);Pointer / scroll
import { usePointer, useScroll } from '@bquery/bquery/media';
const pointer = usePointer();
const scroll = useScroll(window); // { x, y, directionX, directionY, isScrolling, arrived }Platform integrations
import {
usePermission,
useWakeLock,
useShare,
useBroadcastChannel,
useEventListener,
useMediaDevices,
useStorage,
} from '@bquery/bquery/media';
const perm = usePermission('clipboard-read');
const wake = useWakeLock();
await wake.request();
const channel = useBroadcastChannel<{ ping: number }>('app');
channel.post({ ping: 1 });
const prefs = useStorage('prefs', { theme: 'dark' });Clipboard upgrades
import { clipboard, clipboardText } from '@bquery/bquery/media';
if (clipboard.isImageSupported) {
const img = await clipboard.readImage();
}
const text = clipboardText({ onFocus: true });Media Queries
mediaQuery()
Creates a reactive boolean signal that tracks whether a CSS media query matches.
function mediaQuery(query: string): MediaSignalHandle<boolean>;| Parameter | Type | Description |
|---|---|---|
query | string | A valid CSS media query string |
const isDark = mediaQuery('(prefers-color-scheme: dark)');
const isWide = mediaQuery('(min-width: 1024px)');
const isPortrait = mediaQuery('(orientation: portrait)');
import { effect } from '@bquery/bquery/reactive';
effect(() => {
console.log('Dark mode:', isDark.value);
console.log('Wide screen:', isWide.value);
});
// Clean up listeners
isDark.destroy();
isWide.destroy();
isPortrait.destroy();Breakpoints
breakpoints()
Defines named breakpoints and returns reactive boolean signals for each. Each signal is true when the viewport width is at least the specified value.
function breakpoints<T extends BreakpointMap>(bp: T): BreakpointSignals<T>;type BreakpointMap = Record<string, number>;The return object has a signal for each key, plus destroyAll() for bulk cleanup and a destroy() alias.
const bp = breakpoints({ sm: 640, md: 768, lg: 1024, xl: 1280 });
import { effect } from '@bquery/bquery/reactive';
effect(() => {
if (bp.xl.value) {
console.log('Extra-large viewport');
} else if (bp.lg.value) {
console.log('Large viewport');
} else if (bp.md.value) {
console.log('Medium viewport');
} else {
console.log('Small viewport');
}
});
// Clean up all breakpoint listeners
bp.destroyAll();
// Or destroy individually
bp.sm.destroy();Viewport
useViewport()
Returns a reactive signal tracking the current viewport dimensions and orientation.
function useViewport(): ViewportSignal;ViewportState
interface ViewportState {
/** Current viewport width in pixels. */
width: number;
/** Current viewport height in pixels. */
height: number;
/** Current orientation. */
orientation: 'portrait' | 'landscape';
}const viewport = useViewport();
import { effect } from '@bquery/bquery/reactive';
effect(() => {
console.log(`Viewport: ${viewport.value.width}×${viewport.value.height}`);
console.log(`Orientation: ${viewport.value.orientation}`);
});
viewport.destroy();Network Status
useNetworkStatus()
Returns a reactive signal tracking network connectivity and quality.
function useNetworkStatus(): NetworkSignal;NetworkState
interface NetworkState {
/** Whether the browser is online. */
online: boolean;
/** Connection type (e.g., `'4g'`, `'3g'`, `'2g'`, `'slow-2g'`). */
effectiveType: string;
/** Estimated downlink speed in Mbps. */
downlink: number;
/** Estimated round-trip time in milliseconds. */
rtt: number;
}const network = useNetworkStatus();
import { effect } from '@bquery/bquery/reactive';
effect(() => {
if (!network.value.online) {
console.warn('No internet connection');
} else {
console.log(`Connection: ${network.value.effectiveType}, RTT: ${network.value.rtt}ms`);
}
});
network.destroy();Battery
useBattery()
Returns a reactive signal tracking the device's battery status via the Battery Status API.
function useBattery(): BatterySignal;BatteryState
interface BatteryState {
/** Whether the Battery API is supported. */
supported: boolean;
/** Whether the device is currently charging. */
charging: boolean;
/** Seconds until fully charged, or `Infinity`. */
chargingTime: number;
/** Seconds until fully discharged, or `Infinity`. */
dischargingTime: number;
/** Battery level from 0 to 1. */
level: number;
}const battery = useBattery();
import { effect } from '@bquery/bquery/reactive';
effect(() => {
if (battery.value.supported) {
console.log(`Battery: ${Math.round(battery.value.level * 100)}%`);
console.log(`Charging: ${battery.value.charging}`);
} else {
console.log('Battery API not supported');
}
});
battery.destroy();Geolocation
useGeolocation()
Returns a reactive signal tracking the device's geographic position.
function useGeolocation(options?: GeolocationOptions): GeolocationSignal;GeolocationOptions
interface GeolocationOptions {
/** Enable high-accuracy mode (GPS). Default: `false` */
enableHighAccuracy?: boolean;
/** Maximum age of cached position in milliseconds. Default: `0` */
maximumAge?: number;
/** Timeout for position request in milliseconds. Default: `Infinity` */
timeout?: number;
/** Watch for continuous position updates. Default: `false` */
watch?: boolean;
}GeolocationState
interface GeolocationState {
/** Whether the Geolocation API is supported. */
supported: boolean;
/** Whether position data is being loaded. */
loading: boolean;
/** Current latitude, or `null` if unavailable. */
latitude: number | null;
/** Current longitude, or `null` if unavailable. */
longitude: number | null;
/** Position accuracy in meters. */
accuracy: number | null;
/** Altitude in meters, or `null`. */
altitude: number | null;
/** Altitude accuracy in meters, or `null`. */
altitudeAccuracy: number | null;
/** Heading in degrees (0–360), or `null`. */
heading: number | null;
/** Speed in meters per second, or `null`. */
speed: number | null;
/** Position timestamp. */
timestamp: number | null;
/** Error message, or `null`. */
error: string | null;
}Examples
Single position request:
const geo = useGeolocation();
import { effect } from '@bquery/bquery/reactive';
effect(() => {
if (geo.value.loading) {
console.log('Getting position...');
} else if (geo.value.error) {
console.error('Geolocation error:', geo.value.error);
} else {
console.log(`Position: ${geo.value.latitude}, ${geo.value.longitude}`);
console.log(`Accuracy: ${geo.value.accuracy}m`);
}
});
geo.destroy();Continuous tracking (watch mode):
const geo = useGeolocation({
watch: true,
enableHighAccuracy: true,
timeout: 10000,
});
effect(() => {
if (geo.value.latitude !== null) {
updateMapMarker(geo.value.latitude, geo.value.longitude!);
}
});
geo.destroy();Device Sensors
useDeviceMotion()
Returns a reactive signal tracking device motion data from the accelerometer and gyroscope.
function useDeviceMotion(): DeviceMotionSignal;DeviceMotionState
interface DeviceMotionState {
/** Acceleration in m/s² without gravity. */
acceleration: { x: number | null; y: number | null; z: number | null };
/** Acceleration in m/s² including gravity. */
accelerationIncludingGravity: { x: number | null; y: number | null; z: number | null };
/** Rotation rate in degrees/second. */
rotationRate: { alpha: number | null; beta: number | null; gamma: number | null };
/** Interval between updates in milliseconds. */
interval: number;
}const motion = useDeviceMotion();
effect(() => {
const { x, y, z } = motion.value.acceleration;
console.log(`Acceleration: x=${x}, y=${y}, z=${z}`);
});
motion.destroy();useDeviceOrientation()
Returns a reactive signal tracking device orientation data from the compass and gyroscope.
function useDeviceOrientation(): DeviceOrientationSignal;DeviceOrientationState
interface DeviceOrientationState {
/** Rotation around the z-axis (0–360°). Compass heading. */
alpha: number | null;
/** Rotation around the x-axis (−180° to 180°). Front-back tilt. */
beta: number | null;
/** Rotation around the y-axis (−90° to 90°). Left-right tilt. */
gamma: number | null;
/** Whether the orientation is absolute (relative to Earth). */
absolute: boolean;
}const orientation = useDeviceOrientation();
effect(() => {
console.log(`Compass heading: ${orientation.value.alpha}°`);
console.log(`Tilt: ${orientation.value.beta}° / ${orientation.value.gamma}°`);
});
orientation.destroy();Clipboard
clipboard
A singleton object wrapping the Clipboard API for simple async read/write access.
const clipboard: ClipboardAPI;ClipboardAPI
interface ClipboardAPI {
/** Read text from the clipboard. */
read: () => Promise<string>;
/** Write text to the clipboard. */
write: (text: string) => Promise<void>;
}// Write to clipboard
await clipboard.write('Hello from bQuery!');
// Read from clipboard
const text = await clipboard.read();
console.log(text); // 'Hello from bQuery!'Observer Composables
Reactive wrappers for the browser's IntersectionObserver, ResizeObserver, and MutationObserver APIs. Each returns a signal that follows the MediaSignalHandle pattern with additional observer-specific methods.
Intersection Observer
useIntersectionObserver()
Tracks whether elements are visible inside a scrollable ancestor or the viewport.
function useIntersectionObserver(
target?: Element | Element[] | null,
options?: IntersectionObserverOptions
): IntersectionObserverSignal;IntersectionObserverOptions
interface IntersectionObserverOptions {
/** Root element for intersection testing. Default: browser viewport (`null`) */
root?: Element | Document | null;
/** Margin around the root. Default: `undefined` (CSS margin syntax, e.g., `'10px 20px'`) */
rootMargin?: string;
/** Visibility thresholds (0–1). Default: `undefined` (fires at 0) */
threshold?: number | number[];
}IntersectionObserverState
interface IntersectionObserverState {
/** Whether the target is currently intersecting the root. */
isIntersecting: boolean;
/** Intersection ratio (0–1). */
intersectionRatio: number;
/** The most recent `IntersectionObserverEntry`, or `null`. */
entry: IntersectionObserverEntry | null;
}IntersectionObserverSignal
interface IntersectionObserverSignal extends MediaSignalHandle<IntersectionObserverState> {
/** Start observing an additional target. */
observe(target: Element): void;
/** Stop observing a target. */
unobserve(target: Element): void;
}Examples
Lazy-load an image:
const img = document.querySelector('#lazy-image')!;
const io = useIntersectionObserver(img, { threshold: 0.1 });
effect(() => {
if (io.value.isIntersecting) {
img.setAttribute('src', img.dataset.src!);
io.destroy();
}
});Track visibility ratio:
const banner = document.querySelector('#banner')!;
const io = useIntersectionObserver(banner, {
threshold: [0, 0.25, 0.5, 0.75, 1.0],
});
effect(() => {
console.log(`Banner ${Math.round(io.value.intersectionRatio * 100)}% visible`);
});
io.destroy();Observe multiple elements dynamically:
const io = useIntersectionObserver(null, { threshold: 0.5 });
document.querySelectorAll('.card').forEach((card) => {
io.observe(card);
});
effect(() => {
if (io.value.isIntersecting) {
console.log('A card entered the viewport');
}
});
io.destroy();Resize Observer
useResizeObserver()
Tracks the size of one or more elements.
function useResizeObserver(
target?: Element | Element[] | null,
options?: ResizeObserverOptions
): ResizeObserverSignal;ResizeObserverOptions
interface ResizeObserverOptions {
/** Box model to observe. Default: `'content-box'` */
box?: ResizeObserverBoxOptions;
}ResizeObserverState
interface ResizeObserverState {
/** Width in pixels (based on the configured box model). */
width: number;
/** Height in pixels (based on the configured box model). */
height: number;
/** The most recent `ResizeObserverEntry`, or `null`. */
entry: ResizeObserverEntry | null;
}ResizeObserverSignal
interface ResizeObserverSignal extends MediaSignalHandle<ResizeObserverState> {
/** Start observing an additional target. */
observe(target: Element): void;
/** Stop observing a target. */
unobserve(target: Element): void;
}Examples
Track panel dimensions:
const panel = document.querySelector('#panel')!;
const size = useResizeObserver(panel);
effect(() => {
console.log(`Panel: ${size.value.width}×${size.value.height}`);
});
size.destroy();Border-box measurement:
const box = document.querySelector('#box')!;
const size = useResizeObserver(box, { box: 'border-box' });
effect(() => {
console.log(`Border-box: ${size.value.width}×${size.value.height}`);
});
size.destroy();Responsive layout logic:
const container = document.querySelector('#container')!;
const size = useResizeObserver(container);
effect(() => {
const cols = size.value.width > 800 ? 3 : size.value.width > 500 ? 2 : 1;
container.style.setProperty('--columns', String(cols));
});
size.destroy();Mutation Observer
useMutationObserver()
Tracks DOM mutations (attribute changes, child-list edits, character data) on observed nodes.
function useMutationObserver(
target?: Node | null,
options?: MutationObserverOptions
): MutationObserverSignal;MutationObserverOptions
interface MutationObserverOptions {
/** Watch for attribute changes. Default: `true` */
attributes?: boolean;
/** Watch for child additions/removals. Default: `false` */
childList?: boolean;
/** Watch for text content changes. Default: `false` */
characterData?: boolean;
/** Watch the entire subtree. Default: `false` */
subtree?: boolean;
/** Record old attribute values. Default: `false` */
attributeOldValue?: boolean;
/** Record old character data values. Default: `false` */
characterDataOldValue?: boolean;
/** Only watch specific attributes. */
attributeFilter?: string[];
}MutationObserverState
interface MutationObserverState {
/** Mutations from the most recent callback. */
mutations: MutationRecord[];
/** Total number of mutation callback batches received. */
count: number;
}MutationObserverSignal
interface MutationObserverSignal extends MediaSignalHandle<MutationObserverState> {
/** Start observing an additional target. */
observe(target: Node): void;
/** Manually flush pending mutation records. */
takeRecords(): MutationRecord[];
}Examples
Watch for child changes:
const list = document.querySelector('#todo-list')!;
const mo = useMutationObserver(list, { childList: true, subtree: true });
effect(() => {
console.log(`${mo.value.count} mutation batches`);
for (const m of mo.value.mutations) {
console.log(m.type, m.addedNodes.length, 'added');
}
});
mo.destroy();Watch specific attributes:
const el = document.querySelector('#widget')!;
const mo = useMutationObserver(el, {
attributes: true,
attributeFilter: ['data-state', 'aria-expanded'],
attributeOldValue: true,
});
effect(() => {
for (const m of mo.value.mutations) {
console.log(
`${m.attributeName}: ${m.oldValue} → ${(m.target as HTMLElement).getAttribute(m.attributeName!)}`
);
}
});
mo.destroy();Flush pending records manually:
const mo = useMutationObserver(document.body, { childList: true });
// Make some DOM changes
document.body.appendChild(document.createElement('div'));
// Flush before the next microtask
const pending = mo.takeRecords();
console.log(pending.length, 'pending mutations');
mo.destroy();Common Patterns
Cleanup on unmount
All media signals expose destroy() so you can release listeners when a view or component goes away:
import { effect } from '@bquery/bquery/reactive';
const viewport = useViewport();
const network = useNetworkStatus();
// Use in effects...
effect(() => {
console.log(viewport.value.width, network.value.online);
});
// Clean up when done
viewport.destroy();
network.destroy();Combining signals
const bp = breakpoints({ sm: 640, md: 768, lg: 1024 });
const network = useNetworkStatus();
effect(() => {
if (!network.value.online) {
showOfflineBanner();
} else if (bp.sm.value && !bp.md.value) {
loadMobileLayout();
} else {
loadDesktopLayout();
}
});Notes
- All media signals return readonly signal handles — you cannot write to
.value. - Breakpoint collections expose
destroyAll()for bulk cleanup, with adestroy()alias when none of your breakpoint names use that key. - Observer composables follow the
MediaSignalHandlepattern with non-enumerableobserve/unobserve/takeRecords/destroymethods. useResizeObserverreadsborderBoxSizeordevicePixelContentBoxSizewhen the corresponding box option is configured, with acontentRectfallback when box-specific sizes are unavailable.- When no mutation observer options are given,
useMutationObserverdefaults to{ attributes: true }.
Pitfalls and gotchas
- All composables accept
{ signal: AbortSignal }(1.14.0) for auto-teardown — prefer it over manualdestroy(). usePermission()returnsnullwhen the permission API is unavailable; do not assume it always resolves.useStorage()swallows JSON parse errors and falls back to the default value — setonErrorif you need to log them.useShare()requires a secure context and user gesture; checkuseShareSupported()first.usePointer()updates at pointer-event frequency — wrap subscribers withwatchThrottle.
Performance notes
- Use
useElementVisibility()(IntersectionObserver-based) overuseScroll()polling. - Bulk-cleanup breakpoint collections with
destroyAll(). - Prefer
useResizeObserverwith explicitboxoption instead of pollinggetBoundingClientRect.
Testing this module
happy-domstubs most observers; for fine-grained tests assert reactivity by dispatching events towindow/document.- Combine with
@bquery/bquery/testing'stick()to flush observer callbacks.
Related modules
- A11y —
prefersReducedMotion,prefersReducedTransparency, etc. share the media-query infrastructure. - Platform — storage, notifications, and cookies that complement these signals.
- Reactive — underlying signal layer.
Version history
- 1.14.0 — 25+ new composables: preference signals, page state, element observers, pointer/scroll, platform integrations, clipboard upgrades. All accept
{ signal: AbortSignal }.