@robonen/platform

Platform-dependent utilities for browser and multi-runtime JavaScript — focus management, ARIA isolation, animation lifecycle tracking, and environment-safe globals.

Most utility libraries stop at the platform boundary: the moment you need to reach for the DOM, shadow roots, aria-hidden, or globalThis, you are on your own. @robonen/platform fills that gap. It packages the gritty, well-tested primitives that overlays, dialogs, and editors depend on — focus guards, tabbable-edge detection, sibling hiding for screen readers, and CSS animation settling — and ships them SSR-aware and dependency-free. It is the low-level layer that powers @robonen/primitives and Writekit.

Focus, done right

Shadow-DOM-aware active-element lookup, scroll-free focusing, and first/last tabbable-edge detection via a fast TreeWalker — the bones of any focus trap.

Accessible isolation

hideOthers marks every sibling aria-hidden, ref-counted across layers, preserving aria-live regions. A dependency-free port of aria-hidden.

Animation lifecycle

Detect running animations and transitions, then settle exit animations cleanly with fill-mode flash prevention — so unmounts wait for the CSS to finish.

Multi-runtime safe

A resolved _global and an isClient flag that work across Node, Bun, Deno, and the browser — guards baked in so SSR never throws.

Install

sh
pnpm add @robonen/platform

Subpath exports

The package splits along the platform boundary. Browser-only helpers live under /browsers; runtime-agnostic helpers live under /multi.

EntryScopeWhat you get
@robonen/platform/browsersDOMFocus, tabbable edges, hideOthers, animation lifecycle
@robonen/platform/multiAny runtime_global, isClient

Usage

A typical overlay flow: capture the focused element, hide siblings from assistive tech, drop focus onto the first tabbable target, and tear it all down on close.

ts
import {
  getActiveElement,
  getTabbableEdges,
  focus,
  hideOthers,
} from '@robonen/platform/browsers';

function openDialog(dialog: HTMLElement) {
  // Remember where focus was, so we can restore it on close.
  const previouslyFocused = getActiveElement();

  // Hide everything outside the dialog from screen readers (ref-counted).
  const undoHide = hideOthers(dialog);

  // Move focus to the first tabbable element inside the dialog.
  const { first } = getTabbableEdges(dialog);
  focus(first, { select: true });

  return function close() {
    undoHide();
    focus(previouslyFocused);
  };
}

On the cross-runtime side, reach for a safe global and a reliable client check without sprinkling "undefined" guards through your code:

ts
import { _global, isClient } from '@robonen/platform/multi';

// Works in Node, Bun, Deno and the browser — never throws in SSR.
if (isClient) {
  _global.addEventListener('resize', onResize);
}
SSR note: browser helpers touch the DOM, so call them inside event handlers or after mount. hideOthers already no-ops when document is undefined, and /multi is import-safe everywhere.

Where to next

Browse the full API reference below, or jump straight to the building blocks:

  • focusGuard — add boundary guards for predictable focus wrapping
  • getTabbableEdges — find the first and last focusable elements in a container
  • hideOthers — isolate a subtree for assistive technology
  • onAnimationSettle — run a callback once an animation or transition finishes

browsers · 39

fn
assignStyle

Merges a style patch onto an element's inline `style` and returns a cleanup function that restores the element's entire previous `cssText`. Unlike {@link setStyle}, the snapshot is the full `cssText`, so the cleanup is an all-or-nothing revert — handy for scoped effects.

fn
createGuardAttrs
fn
decodeCookieValue

Decodes a cookie value: unwraps an RFC 6265 DQUOTE-wrapped value (the quotes are transport dressing, not payload), then decodes percent-escapes. Malformed escapes (e.g. third-party cookies that never used percent-encoding) are returned as-is instead of throwing.

fn
dispatchAnimationEvent

Dispatches a non-bubbling custom event on an element for animation lifecycle tracking

fn
encodeCookieName

Percent-encodes the characters a cookie name cannot contain (cookie names are RFC 2616 tokens). Typical names — letters, digits, `-`, `_`, `.` — pass through unchanged. `(` and `)` are escaped as `%28`/`%29`.

fn
encodeCookieValue

Percent-encodes only the characters a cookie value cannot contain per RFC 6265 (controls, whitespace, `"` `,` `;` `\` and `%` itself), leaving everything else readable. Compatible with js-cookie's default write converter.

fn
findFirstVisible

Returns the first visible element from a list. Checks visibility up the DOM to `container` (exclusive).

fn
findLastVisible

Returns the last visible element from a list. Checks visibility up the DOM to `container` (exclusive).

fn
focus

Focuses an element without scrolling. Optionally calls select on input elements.

fn
focusFirst

Attempts to focus the first element from a list of candidates. Stops when focus actually moves.

fn
focusGuard

Adds a pair of focus guards at the boundaries of the DOM tree to ensure consistent focus behavior

fn
getActiveElement

Returns the active element of the document (or shadow root)

fn
getAnimationName

Returns the current CSS animation name(s) of an element

fn
getCookieValue

Looks up a single cookie in a `document.cookie`-style string without building a full map — same first-occurrence and verbatim-raw-value semantics as {@link parseCookieString}, but allocation-free for misses and cheap for hot paths (reactive reads, polling).

fn
getTabbableCandidates

Collects all tabbable candidates via TreeWalker (faster than querySelectorAll). This is an approximate check — does not account for computed styles. Visibility is checked separately in `findFirstVisible`.

fn
getTabbableEdges

Returns the first and last tabbable elements inside a container

fn
getTranslate

Reads the current translation of an element along one axis from its computed `transform`, parsing both `matrix(...)` (2D) and `matrix3d(...)` (3D) forms. Returns `null` when the element has no matrix transform.

fn
hideOthers

Marks every sibling of `target` (within `parentNode`, defaulting to `document.body`) as `aria-hidden="true"` so assistive technologies skip them. `aria-live` regions and `<script>` elements are preserved. Returns an undo function that restores the previous state; calls stack (ref-counted) across multiple layers. Port of the `aria-hidden` npm package, kept dependency-free.

fn
isAnimatable

Checks whether an element has a running CSS animation or transition

fn
isEventTarget

Type guard for a value that is itself an {@link EventTarget}(e.g. `window`, `document`, or an element) — i.e. it can be attached to directly rather than unwrapped from a ref/getter first.

fn
isHidden

Checks if an element is hidden via `visibility: hidden` or `display: none` up the DOM tree

fn
isInView

Reports whether an element is fully within the visual viewport, accounting for on-screen keyboards via `window.visualViewport`. A 40px slack is allowed at the bottom to tolerate Safari's viewport quirks. Returns `false` when `visualViewport` is unavailable.

fn
isIOS

Whether the current device runs iOS/iPadOS (iPhone or iPad).

fn
isIPad

Whether the current device is an iPad. iPadOS 13+ masquerades as a Mac, so this also treats a touch-capable Mac (`maxTouchPoints > 1`) as an iPad.

fn
isIPhone

Whether the current platform is an iPhone (per `navigator.platform`).

fn
isMac

Whether the current platform is macOS (per `navigator.platform`). Note iPadOS reports as a Mac — combine with {@link isIPad} to disambiguate.

fn
isMobileFirefox

Whether the current browser is Firefox on a mobile device (Android Firefox or iOS Firefox / `FxiOS`).

fn
isSafari

Whether the current browser is Safari (desktop or iOS), excluding Chrome and Android browsers that also include "Safari" in their UA string.

fn
isSelectableInput

Checks if an element is an input element with a select method

fn
onAnimationSettle

Attaches animation/transition end listeners to an element with fill-mode flash prevention. Returns a cleanup function.

fn
parseCookieString

Parses a `document.cookie`-style string (`'a=1; b=2'`) into a `Map` of decoded names to values. Keeps the **first** occurrence per name — browsers order cookies most-specific-path first, so the first one is the one a server would use. The raw value is passed to `decode` verbatim (including any wrapping quotes) so it matches what the Cookie Store API would report; the default decoder unwraps quotes and percent-escapes.

fn
pxValue

Parse a CSS length token (`"1024px"`, `"48em"`, `"30rem"`, `"50%"`) into a pixel number. `em`/`rem` use the conventional 16px root size. Returns `NaN` for non-numeric input.

fn
readInputState

Reads the value and current selection of an `<input>`/`<textarea>` into a plain {@link InputState}. A `null` selection (some input types report it) falls back to a collapsed caret at the end of the value.

fn
resetStyle

Restores the inline styles an element had before the most recent cached {@link setStyle}. With `prop` it restores a single property; otherwise it restores every property that was remembered. A no-op if nothing was cached.

fn
serializeCookie

Builds a `document.cookie` assignment string from an **already-encoded** name and value (see {@link encodeCookieName} / {@link encodeCookieValue}) plus {@link CookieAttributes}. `Path` and `SameSite` are always emitted explicitly. Fails loudly on combinations browsers silently drop: `SameSite=None` or `Partitioned` without `Secure`, and `name=value` over 4096 UTF-8 bytes.

fn
setStyle

Applies a batch of inline styles to an element, remembering the values it overwrote so {@link resetStyle} can restore them later. `--custom` properties are written through `setProperty`. Pass `ignoreCache` to apply the styles without recording the originals (e.g. for transient, per-frame writes during a drag that you intend to clear wholesale).

fn
shouldSuspendUnmount

Determines whether unmounting should be delayed due to a running animation/transition change

fn
testUserAgentPlatform

Tests `navigator.platform` against a regular expression, guarding for non-browser environments. Returns `undefined` when there is no `navigator` (e.g. during SSR) so callers can distinguish "no" from "unknown".

fn
writeInputState

Writes value and selection back to an `<input>`/`<textarea>`. The value is only assigned when it actually changed (avoids spurious cursor jumps), and the caret is moved **only while the element is focused** so programmatic updates never steal or reposition focus. `setSelectionRange` is guarded because some input types (`number`, `email`, `date`) forbid it.

multi · 7