Skip to content

Utilities

Utilities are pre-built factories that wrap common browser APIs in signals β€” similar in spirit to react-use hooks or Svelte runes, but framework-agnostic. They return a Computed<T> or Signal<T> you can read anywhere signals work.


Quick Reference

UtilityImportReturns
createMediaQueryutilities/media-queryComputed<boolean>
createTimeoututilities/timeout{ pending, start, stop, reset } & Disposable
createIntervalutilities/interval{ pending, start, stop, reset } & Disposable
createDebouncedutilities/debouncedComputed<T>
createThrottledutilities/throttledComputed<T>
onutilities/event-listener() => void
createHoverutilities/hoverComputed<boolean>
createFocusWithinutilities/focus-withinComputed<boolean>
onClickOutsideutilities/on-click-outside() => void
createLongPressutilities/long-press() => void
activeElementutilities/active-elementComputed<Element | null>
createElementRectutilities/element-rect{ x, y, width, height, … } & Disposable
createElementScrollutilities/element-scroll{ x: Signal, y: Signal } & Disposable
createResizeObserverutilities/resize-observerDisposable
createIntersectionObserverutilities/intersection-observerDisposable
createMutationObserverutilities/mutation-observerDisposable
createMediaDevicesutilities/media-devicesComputed<MediaDeviceInfo[]>
windowSizeutilities/window-size{ width, height } & Disposable
orientationutilities/orientation{ angle, type } & Disposable
onlineutilities/networkComputed<boolean>
windowFocusedutilities/window-focusComputed<boolean>
retryutilities/retry() => Promise<T>
createLocalStorageutilities/storageSignal<T>
createSessionStorageutilities/storageSignal<T>
createMediaPlayerutilities/media-player{ playing, muted, volume, … } & Disposable
currentLocationutilities/location{ hash, href, pathname, search }
createSearchParamutilities/search-paramsComputed<string | null>
navigateutilities/routingvoid
isLocalNavigationEventutilities/routingboolean
matchesutilities/routingComputed<boolean>
matchutilities/routingComputed<URLPatternResult | null>
createPreviousutilities/previousComputed<T | undefined>
fromEventutilities/event-drivenSubscribe
syncutilities/event-driven[Computed<T> | Signal<T>, () => void]
setContextutilities/contextvoid
getContextutilities/contextT | undefined
<dom-lifecycle>utilities/dom-lifecycleCustom element
DomLifecycleElementutilities/dom-lifecycleclass

Using utilities with React

Utilities return plain signals and computeds β€” useSignal from elements-kit/integrations/react connects them to React components with no special glue.

Singleton utilities are module-level values shared across the whole app. Pass them directly to useSignal:

import { useSignal } from "elements-kit/integrations/react";
import { windowSize } from "elements-kit/utilities/window-size";
import { currentLocation } from "elements-kit/utilities/location";
function Layout() {
const width = useSignal(windowSize.width);
const path = useSignal(currentLocation.pathname);
return <p>{path} β€” {width}px wide</p>;
}

Factory utilities with per-component lifetime go inside useScope so they are created and cleaned up with the component:

import { useSignal, useScope } from "elements-kit/integrations/react";
import { createDebounced } from "elements-kit/utilities/debounced";
import { signal } from "elements-kit/signals";
const query = signal("");
function Search() {
const debounced = useScope(() => createDebounced(query, 300));
return (
<>
<input onInput={(e) => query(e.currentTarget.value)} />
<p>Searching for: {debounced}</p>
</>
);
}

Writable signals from storage or scroll work as both getter (via useSignal) and setter (call directly):

import { useSignal } from "elements-kit/integrations/react";
import { createLocalStorage } from "elements-kit/utilities/storage";
const theme = createLocalStorage("theme", "light");
function ThemeToggle() {
const current = useSignal(theme);
return (
<button onClick={() => theme(current === "light" ? "dark" : "light")}>
{current}
</button>
);
}

createMediaQuery

Creates a Computed<boolean> that tracks a CSS media query. Returns true when the query matches, false otherwise.

import {
function createMediaQuery(query: string, defaultState?: boolean): Computed<boolean>

Creates a signal that tracks a CSS media query.

@param ― query The media query string (e.g. '(max-width: 600px)')

@param ― defaultState The default value (for SSR/hydration)

@returns ― Computed that is true if the query matches

createMediaQuery
} from "elements-kit/utilities/media-query";
const
const isDark: Computed<boolean>
isDark
=
function createMediaQuery(query: string, defaultState?: boolean): Computed<boolean>

Creates a signal that tracks a CSS media query.

@param ― query The media query string (e.g. '(max-width: 600px)')

@param ― defaultState The default value (for SSR/hydration)

@returns ― Computed that is true if the query matches

createMediaQuery
("(prefers-color-scheme: dark)");
const isDark: () => boolean
isDark
(); // true or false β€” live, updates on OS change

Signature

function createMediaQuery(
query: string,
defaultState?: boolean,
): Computed<boolean>
ParameterDescription
queryAny valid CSS media query string
defaultStateValue returned during SSR (where window is unavailable). Defaults to false.

Examples

Dark mode toggle

import { effect } from "elements-kit/signals";
import { createMediaQuery } from "elements-kit/utilities/media-query";
const isDark = createMediaQuery("(prefers-color-scheme: dark)");
effect(() => {
document.documentElement.classList.toggle("dark", isDark());
});

Responsive layout

const isMobile = createMediaQuery("(max-width: 640px)");
const isTablet = createMediaQuery("(max-width: 1024px)");
const prefersReducedMotion = createMediaQuery("(prefers-reduced-motion: reduce)");
effect(() => {
if (isMobile()) {
// render compact layout
} else if (isTablet()) {
// render medium layout
}
});

SSR / hydration

Pass defaultState to control the value returned on the server before the browser environment is available:

// Server renders as if dark mode is off
const isDark = createMediaQuery("(prefers-color-scheme: dark)", false);

Cleanup

The underlying MediaQueryList event listener is automatically removed when the signal goes out of scope β€” via onCleanup if created inside an effect, or Symbol.dispose for explicit resource management:

// Inside an effect or effectScope β€” cleanup is automatic
effectScope(() => {
const isDark = createMediaQuery("(prefers-color-scheme: dark)");
effect(() => document.body.classList.toggle("dark", isDark()));
});
// ↑ stop() cleans up the MediaQueryList listener too
// Explicit disposal
using isDark = createMediaQuery("(prefers-color-scheme: dark)");
// ↑ listener removed when `isDark` goes out of scope (TC39 `using` keyword)

Timing

createTimeout

Reactive setTimeout wrapper. Fires callback once after delay ms. Starts immediately unless immediate is false.

import { createTimeout } from "elements-kit/utilities/timeout";
const { pending, stop, reset } = createTimeout(() => {
console.log("fired");
}, 1000);
function createTimeout(
callback: () => void,
delay: number | (() => number),
immediate?: boolean,
): { pending: Computed<boolean>; start(): void; stop(): void; reset(): void } & Disposable

createInterval

Pausable setInterval wrapper. Starts running immediately on creation.

import { createInterval } from "elements-kit/utilities/interval";
const { pending, stop } = createInterval(() => {
console.log("tick");
}, 1000);
stop(); // pause
function createInterval(
callback: () => void,
delay: number | (() => number),
): { pending: Computed<boolean>; start(): void; stop(): void; reset(): void } & Disposable

createDebounced

Returns a Computed<T> that mirrors getter but only updates after delay ms of silence.

import {
function createDebounced<T>(getter: () => T, delay: number | (() => number)): Computed<T>

Returns a Computed that mirrors getter but only updates after delay milliseconds of silence (i.e. no new values from getter).

The initial value is read synchronously, so the computed is never undefined.

@example

import { signal } from "elements-kit/signals";
import { createDebounced } from "elements-kit/utilities/debounced";
const query = signal("");
const debounced = createDebounced(query, 300);
effect(() => fetch(`/search?q=${debounced()}`));

createDebounced
} from "elements-kit/utilities/debounced";
import {
function signal<T>(): Updater<T> & Computed<T> (+1 overload)

Creates a mutable reactive signal.

  • Read: call with no arguments β†’ returns the current value and subscribes the active tracking context.
  • Write: call with a value β†’ updates the signal and schedules downstream effects if the value changed.

@example

const count = signal(0);
count(); // β†’ 0 (read)
count(1); // write – effects depending on count will re-run
count(); // β†’ 1

signal
} from "elements-kit/signals";
const
const input: Updater<string> & Computed<string>
input
=
signal<string>(initialValue: string): Updater<string> & Computed<string> (+1 overload)

Creates a mutable reactive signal.

  • Read: call with no arguments β†’ returns the current value and subscribes the active tracking context.
  • Write: call with a value β†’ updates the signal and schedules downstream effects if the value changed.

@example

const count = signal(0);
count(); // β†’ 0 (read)
count(1); // write – effects depending on count will re-run
count(); // β†’ 1

signal
("");
const
const debounced: Computed<string>
debounced
=
createDebounced<string>(getter: () => string, delay: number | (() => number)): Computed<string>

Returns a Computed that mirrors getter but only updates after delay milliseconds of silence (i.e. no new values from getter).

The initial value is read synchronously, so the computed is never undefined.

@example

import { signal } from "elements-kit/signals";
import { createDebounced } from "elements-kit/utilities/debounced";
const query = signal("");
const debounced = createDebounced(query, 300);
effect(() => fetch(`/search?q=${debounced()}`));

createDebounced
(
const input: Updater<string> & Computed<string>
input
, 300);
any
effect
(() =>
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
(
const debounced: () => string
debounced
())); // fires 300ms after input stops changing
function createDebounced<T>(getter: () => T, delay: number | (() => number)): Computed<T>

createThrottled

Returns a Computed<T> that mirrors getter but updates at most once per interval ms. A trailing-edge update ensures the final value is never dropped.

import { createThrottled } from "elements-kit/utilities/throttled";
const throttledScroll = createThrottled(() => window.scrollY, 100);
function createThrottled<T>(getter: () => T, interval: number): Computed<T>

DOM Events

on

Attaches a type-safe event listener with automatic cleanup. When the target is a reactive getter, the listener re-registers whenever the target changes.

import { on } from "elements-kit/utilities/event-listener";
const cleanup = on(document, "keydown", (e) => console.log(e.key));
// cleanup() to remove manually, or call inside an effectScope for auto-cleanup
function on(
target: EventTarget | Computed<EventTarget | null>,
type: string,
handler: EventListener,
options?: AddEventListenerOptions,
): () => void

createHover

Returns Computed<boolean> β€” true while pointer is over target.

import { createHover } from "elements-kit/utilities/hover";
const hovered = createHover(document.querySelector("#btn")!);
effect(() => console.log("hovered:", hovered()));
function createHover(target: Element): Computed<boolean>

createFocusWithin

Returns Computed<boolean> β€” true while focus is anywhere inside target (including target itself).

import { createFocusWithin } from "elements-kit/utilities/focus-within";
const focused = createFocusWithin(document.querySelector("form")!);
function createFocusWithin(target: Element): Computed<boolean>

onClickOutside

Fires handler whenever a pointer-down event occurs outside target. Returns a cleanup function.

import { onClickOutside } from "elements-kit/utilities/on-click-outside";
const cleanup = onClickOutside(menuEl, () => closeMenu());
function onClickOutside(target: Element, handler: (e: PointerEvent) => void): () => void

createLongPress

Fires handler when a pointer is held over target for at least delay ms (default 500 ms).

import { createLongPress } from "elements-kit/utilities/long-press";
const cleanup = createLongPress(el, (e) => openContextMenu(e), { delay: 600 });
function createLongPress(
target: Element,
handler: (e: PointerEvent) => void,
options?: { delay?: number },
): () => void

activeElement

Module-level singleton. Computed<Element | null> bound to document.activeElement.

import { activeElement } from "elements-kit/utilities/active-element";
effect(() => console.log("focused:", activeElement()));

Element Observation

createElementRect

Observes the full bounding rect of target via ResizeObserver. All eight DOMRect properties are reactive computeds.

import { createElementRect } from "elements-kit/utilities/element-rect";
const { width, height, top } = createElementRect(document.querySelector("#box")!);
effect(() => console.log(width(), height()));
function createElementRect(target: Element): {
x: Computed<number>; y: Computed<number>;
width: Computed<number>; height: Computed<number>;
top: Computed<number>; right: Computed<number>;
bottom: Computed<number>; left: Computed<number>;
} & Disposable

createElementScroll

Returns writable x / y signals for an element’s scroll position. Reading returns scrollLeft / scrollTop; writing scrolls the element.

import { createElementScroll } from "elements-kit/utilities/element-scroll";
const { x, y } = createElementScroll(document.querySelector(".list")!);
effect(() => console.log("scroll:", x(), y()));
y(200); // scrolls to top 200px
function createElementScroll(target: Element): { x: Signal<number>; y: Signal<number> } & Disposable

createResizeObserver

Raw ResizeObserver wrapper with automatic cleanup via onCleanup. Use elementRect for the common case.

import { createResizeObserver } from "elements-kit/utilities/resize-observer";
createResizeObserver(el, (entries) => {
for (const entry of entries) console.log(entry.contentRect);
});
function createResizeObserver(target: Element, callback: ResizeObserverCallback): Disposable

createIntersectionObserver

Raw IntersectionObserver wrapper with automatic cleanup.

import { createIntersectionObserver } from "elements-kit/utilities/intersection-observer";
createIntersectionObserver(el, ([entry]) => {
console.log("visible:", entry.isIntersecting);
}, { threshold: 0.5 });
function createIntersectionObserver(
target: Element,
callback: IntersectionObserverCallback,
options?: IntersectionObserverInit,
): Disposable

createMutationObserver

Watches target for DOM mutations with automatic cleanup.

import { createMutationObserver } from "elements-kit/utilities/mutation-observer";
createMutationObserver(el, { childList: true }, (records) => {
console.log("mutations:", records.length);
});
function createMutationObserver(
target: Element,
options: MutationObserverInit,
callback: (records: MutationRecord[]) => void,
): Disposable

Browser APIs

createMediaDevices

Returns a reactive list of available media devices, refreshed when devices are added or removed.

import { createMediaDevices } from "elements-kit/utilities/media-devices";
const devices = createMediaDevices();
effect(() => console.log(devices().map((d) => d.label)));
function createMediaDevices(): Computed<MediaDeviceInfo[]>

windowSize

Module-level singleton. Reactive innerWidth and innerHeight of the browser window.

import { windowSize } from "elements-kit/utilities/window-size";
effect(() => console.log(windowSize.width(), windowSize.height()));

orientation

Module-level singleton. Reactive screen.orientation angle and type.

import { orientation } from "elements-kit/utilities/orientation";
effect(() => console.log(orientation.type(), orientation.angle()));

online

Module-level singleton. Computed<boolean> β€” true when navigator.onLine is true. Reacts to online / offline window events.

import { online } from "elements-kit/utilities/network";
effect(() => {
if (!online()) showOfflineBanner();
});

retry

Wraps a () => Promise<T> with retry logic. Retries up to attempts times on failure. The optional delay is inserted between failures only β€” not after the final one. Each attempt runs in an effect scope, so onCleanup inside the function fires before each retry.

import { retry } from "elements-kit/utilities/retry";
const fn = retry(
() => fetch("/api/data").then((r) => r.json()),
3, // up to 3 attempts
(n) => n * 500, // 0ms, 500ms, 1000ms between failures
);
await fn(); // retries automatically on failure
function retry<T>(
fn: () => Promise<T>,
attempts: number,
delay?: number | ((attempt: number) => number),
): () => Promise<T>

Compose with async() for reactive retries:

import { async } from "elements-kit/utilities/async";
import { retry } from "elements-kit/utilities/retry";
import { onCleanup } from "elements-kit/signals";
const fetchTodo = async((id: number) =>
retry(() => {
const controller = new AbortController();
onCleanup(() => controller.abort()); // aborts before each retry
return fetch(`/api/todos/${id}`, { signal: controller.signal }).then((r) => r.json());
}, 3, 500)(),
).start();

createLocalStorage

Returns a Signal<T> persisted to localStorage. Writes in other tabs/windows are synchronised automatically via StorageEvent.

import {
function createLocalStorage<T>(key: string, initialValue: T, options?: StorageOptions<T>): Signal<T>

Returns a Signal persisted to localStorage.

Changes made in other tabs/windows are synchronised automatically via the StorageEvent.

@example

import { createLocalStorage } from "elements-kit/utilities/storage";
const theme = createLocalStorage<"light" | "dark">("theme", "light");
theme(); // read current
theme("dark"); // write β€” persists and notifies

createLocalStorage
} from "elements-kit/utilities/storage";
const
const theme: Signal<string>
theme
=
createLocalStorage<string>(key: string, initialValue: string, options?: StorageOptions<string> | undefined): Signal<string>

Returns a Signal persisted to localStorage.

Changes made in other tabs/windows are synchronised automatically via the StorageEvent.

@example

import { createLocalStorage } from "elements-kit/utilities/storage";
const theme = createLocalStorage<"light" | "dark">("theme", "light");
theme(); // read current
theme("dark"); // write β€” persists and notifies

createLocalStorage
("theme", "light");
const theme: () => string (+1 overload)
theme
(); // "light"
const theme: (value: string) => void (+1 overload)
theme
("dark"); // persisted immediately
function createLocalStorage<T>(
key: string,
initialValue: T,
options?: { serialise?: (v: T) => string; deserialise?: (raw: string) => T },
): Signal<T>

createSessionStorage

Same as createLocalStorage but scoped to the current tab β€” no cross-tab sync.

import { createSessionStorage } from "elements-kit/utilities/storage";
const draft = createSessionStorage("draft", "");
function createSessionStorage<T>(key: string, initialValue: T, options?: StorageOptions<T>): Signal<T>

Media

createMediaPlayer

Wraps an HTMLMediaElement (<audio> or <video>) with reactive state and playback controls. muted, volume, and time are writable β€” writing them updates the element. playing, duration, and ended are read-only.

import { createMediaPlayer } from "elements-kit/utilities/media-player";
const player = createMediaPlayer(document.querySelector("video")!);
effect(() => console.log("playing:", player.playing()));
player.volume(0.5); // set volume
player.time(30); // seek to 30s
player.toggle(); // play/pause
function createMediaPlayer<T extends HTMLMediaElement>(element: T): {
element: T;
playing: Computed<boolean>;
muted: Signal<boolean>;
volume: Signal<number>;
duration: Computed<number>;
time: Signal<number>;
ended: Computed<boolean>;
play(): void;
pause(): void;
toggle(): void;
} & Disposable

URL & Routing

All location signals react to popstate (back/forward) automatically. They also listen for pushstate and replacestate custom events, which must be dispatched by your router or by patching history:

// Patch history so pushState/replaceState fire custom events
for (const method of ["pushState", "replaceState"] as const) {
const original = history[method].bind(history);
history[method] = (...args) => {
original(...args);
window.dispatchEvent(new Event(method.toLowerCase()));
};
}

Without this patch, signals still update on back/forward β€” only programmatic navigation via pushState/replaceState won’t be reflected.

currentLocation

Returns reactive signals for hash, href, pathname, and search β€” all sharing a single event listener set.

currentLocation is a module-level singleton suitable for most apps.

import { currentLocation } from "elements-kit/utilities/location";
// Singleton β€” shared across the app
effect(() => console.log(currentLocation.pathname()));
type LocationResult = {
hash: Computed<string>;
href: Computed<string>;
pathname: Computed<string>;
search: Computed<string>;
};
const currentLocation: LocationResult

createSearchParam

Returns Computed<string | null> for a single URL search parameter.

import { createSearchParam } from "elements-kit/utilities/search-params";
const tab = createSearchParam("tab");
effect(() => console.log("tab:", tab())); // null when absent
function createSearchParam(key: string): Computed<string | null>

createURLPattern

Reactively tests a URL source against a URLPattern. The source can be a plain string/URL or a reactive getter.

No polyfill needed for modern browsers (Chrome 95+, Safari 16.4+, Firefox 117+). Use urlpattern-polyfill on npm for legacy targets.

import { createURLPattern } from "elements-kit/utilities/url-pattern";
import { currentLocation } from "elements-kit/utilities/location";
const match = createURLPattern(currentLocation.href, { pathname: "/users/:id" });
effect(() => console.log(match()?.pathname.groups.id));
function createURLPattern(
source: string | URL | Computed<string | URL>,
input?: URLPatternInput,
options?: URLPatternOptions,
): Computed<URLPatternResult | null>

Navigates to a URL via history.pushState (or replaceState). Patches history once on first call so all programmatic navigation β€” including third-party router calls β€” fires the pushstate / replacestate custom events that currentLocation signals react to.

import { navigate } from "elements-kit/utilities/routing";
navigate("/users/42");
navigate("/users/42", { replace: true }); // replaceState β€” no new history entry
navigate("/users/42", { state: { id: 42 } }); // pass history state
function navigate(url: string | URL, options?: { replace?: boolean; state?: unknown }): void

isLocalNavigationEvent

Returns true when a click event on an <a> element should be handled client-side β€” same origin, primary button, no modifier keys, no download attribute, no target="_blank". Walks up to the nearest anchor via closest("a"), so it works on container elements too.

Use alongside navigate() to intercept anchor clicks without hardwiring routing logic into this utility.

import { isLocalNavigationEvent, navigate } from "elements-kit/utilities/routing";
document.querySelector("nav")!.addEventListener("click", (e) => {
if (isLocalNavigationEvent(e)) {
e.preventDefault();
navigate((e.target as HTMLAnchorElement).href);
}
});
function isLocalNavigationEvent(e: MouseEvent): boolean

matches

Returns Computed<boolean> β€” true when the current URL matches input. Uses URLPattern.test() β€” faster than match when you don’t need captured groups.

Always use the object form { pathname: "..." } β€” relative string patterns require a base URL and will throw.

Requires urlpattern-polyfill for Safari < 26 and Firefox < 142.

import { matches } from "elements-kit/utilities/routing";
const isHome = matches({ pathname: "/" });
effect(() => {
if (isHome()) showHomeNav();
});

Conditional rendering with React:

import { useSignal } from "elements-kit/integrations/react";
import { matches } from "elements-kit/utilities/routing";
const isSettings = matches({ pathname: "/settings" });
function App() {
return useSignal(isSettings) ? <Settings /> : <NotFound />;
}
function matches(input: URLPatternInput, options?: URLPatternOptions): Computed<boolean>

match

Returns Computed<URLPatternResult | null> β€” the full match result when the current URL matches input, null when it does not. Use when you need captured groups. For a boolean gate, prefer matches().

Always use the object form { pathname: "..." } β€” relative string patterns require a base URL and will throw.

Requires urlpattern-polyfill for Safari < 26 and Firefox < 142.

import { match } from "elements-kit/utilities/routing";
const postMatch = match({ pathname: "/posts/:slug" });
effect(() => {
const slug = postMatch()?.pathname.groups.slug;
if (slug) loadPost(slug);
});

Extracting params in React:

import { useSignal } from "elements-kit/integrations/react";
import { match } from "elements-kit/utilities/routing";
const userMatch = match({ pathname: "/users/:id" });
function App() {
const result = useSignal(userMatch);
const id = result?.pathname.groups.id;
return id ? <UserPage id={id} /> : <NotFound />;
}
function match(input: URLPatternInput, options?: URLPatternOptions): Computed<URLPatternResult | null>

State

createPrevious

Returns a Computed that always holds the previous value of source. Starts as undefined until the source changes for the first time.

import { createPrevious } from "elements-kit/utilities/previous";
import { signal } from "elements-kit/signals";
const count = signal(0);
const prev = createPrevious(count);
count(1);
effect(() => console.log(prev())); // 0

Pass ignore to skip updates when a condition is met:

// Only track previous when value actually changes
const prev = createPrevious(count, (a, b) => a === b);
function createPrevious<T>(
source: Computed<T>,
ignore?: (next: T, current: T) => boolean,
): Computed<T | undefined>

DOM lifecycle

Drop-in custom element. Place inside any element (or wrap children) to be notified when the surrounding subtree connects, disconnects, moves, or is adopted into another document. Built on the platform’s own custom-element callbacks β€” no MutationObserver, no global registry. Useful when a JSX ref callback fires too early β€” e.g. resolving getContext, measuring layout, attaching observers that need a connected ancestor.

Position-tracking callbacks (onConnect, onDisconnect, onMove) receive the lifecycle element itself. Read self.parentElement for the surrounding element, self.firstElementChild / self.children for wrapped content, or self.getRootNode() to walk through a shadow root. self is always non-null β€” even when the lifecycle element is the direct child of a ShadowRoot (where parentElement is null).

Render-inert by default: display: contents removes its layout box so it doesn’t affect the parent’s layout, and role="none" strips its implicit a11y role. Children passed inside it participate in layout and a11y as if the wrapper weren’t there. Caveat: structural CSS selectors (:empty, :first-child, :nth-child) still see the element in the DOM tree.

<dom-lifecycle>

import "elements-kit/utilities/dom-lifecycle";
function FocusOnMount() {
return (
<div>
<dom-lifecycle
onConnect={(el) => (el.parentElement as HTMLElement | null)?.focus()}
/>
</div>
);
}

Wrap children β€” read the wrapped subtree through self.firstElementChild:

<section>
<dom-lifecycle onConnect={(el) => measure(el.firstElementChild)}>
<h1>Title</h1>
<p>Body</p>
</dom-lifecycle>
</section>

Wrap children to consume context

Call getContext(self, …) inside onConnect β€” the walk goes from the wrapper up through its ancestors, so any outer provider resolves. Expose the result as a signal so wrapped children read it without each one running its own lookup.

import { signal } from "elements-kit/signals";
import { getContext } from "elements-kit/utilities/context";
import "elements-kit/utilities/dom-lifecycle";
const THEME = Symbol("theme");
function ThemedSection() {
const theme = signal<string | undefined>(undefined);
return (
<dom-lifecycle onConnect={(el) => theme(getContext<string>(el, THEME))}>
<h1 data-theme={() => theme() ?? "default"}>Title</h1>
<p data-theme={() => theme() ?? "default"}>Body</p>
</dom-lifecycle>
);
}

The wrapper is transparent in the ancestor walk, so wrapped children may also call getContext directly on themselves and reach the same outer provider. Use onConnect when you want to read once at mount and fan the value out to multiple children via a single signal.

Observing descendant mutations

<dom-lifecycle> only fires on its own (re)connection β€” it does not observe descendant mutations (children being added, removed, or replaced while the wrapper stays mounted). For per-child mount/unmount inside its subtree, either nest a <dom-lifecycle> per child or use createMutationObserver on el inside onConnect:

import { createMutationObserver } from "elements-kit/utilities/mutation-observer";
<dom-lifecycle
onConnect={(el) => {
createMutationObserver(el, { childList: true }, (records) => {
for (const r of records) {
// r.addedNodes, r.removedNodes
}
});
}}
>
<For each={items}>{(item) => <li>{item}</li>}</For>
</dom-lifecycle>

Callbacks

CallbackMirrorsArgumentFires on
onConnectconnectedCallbackself: DomLifecycleElementevery connection β€” self.parentElement is the surrounding element, self.firstElementChild is the wrapped content
onDisconnectdisconnectedCallbackself: DomLifecycleElementevery disconnection β€” self.parentElement is null per spec; capture the parent inside onConnect if you need it on disconnect
onMoveconnectedMoveCallbackself: DomLifecycleElementmove via Node.moveBefore() (in browsers without that API, the disconnect+connect pair fires instead)
onAdoptedadoptedCallback(oldDocument, newDocument)when the element is adopted into a new document
<div>
<dom-lifecycle
onConnect={(el) => {
const io = new IntersectionObserver((entries) => {});
if (el.parentElement) io.observe(el.parentElement);
}}
onDisconnect={(el) => {
// pair cleanup with the resource you opened in onConnect
// el.parentElement is null here per spec
}}
onMove={(el) => {
// fires instead of disconnect+connect when moveBefore() is used
}}
/>
</div>

onConnect / onDisconnect re-fire on every (re)connection (item moves in <For>, portal moves between parents). The user removes the element themselves; it does not self-remove. To make a callback one-shot, set the property to null after the first fire.

class DomLifecycleElement extends HTMLElement {
onConnect: ((self: DomLifecycleElement) => void) | null;
onDisconnect: ((self: DomLifecycleElement) => void) | null;
onMove: ((self: DomLifecycleElement) => void) | null;
onAdopted: ((oldDocument: Document, newDocument: Document) => void) | null;
}

Works inside open and closed shadow roots, after cloneNode(true), after innerHTML upgrade, and under strict CSP β€” same guarantees the platform gives any custom element.


Context

DOM-tree dependency injection β€” pass values down a custom-element subtree without prop drilling or globals. Provider registers a value on a host element; descendants look it up by walking parentNode (crossing into shadow hosts via getRootNode().host). Innermost provider wins. Reactivity is opt-in: pass a Signal / Computed and read it inside an effect.

setContext

Registers value under key on host. Auto-removed via onCleanup when the surrounding scope disposes β€” must run inside a reactive scope (an effect, effectScope, the render setup, or a JSX ref callback, which all run inside one).

import { signal } from "elements-kit/signals";
import { setContext } from "elements-kit/utilities/context";
const THEME = Symbol("theme");
function ThemeProvider({ children }: { children: JSX.Element }) {
const theme = signal<"light" | "dark">("dark");
return setContext(<div>{children}</div>, THEME, theme);
}

setContext returns the host so it composes inline. The equivalent ref form does the same thing β€” pick whichever reads better at the call site:

function ThemeProvider({ children }: { children: JSX.Element }) {
const theme = signal<"light" | "dark">("dark");
return (
<div ref={(el) => setContext(el, THEME, theme)}>
{children}
</div>
);
}
function setContext<T>(host: EventTarget, key: PropertyKey, value: T): void

getContext

Walks up from consumer (across open shadow boundaries) and returns the first registered value for key, or undefined. One-shot β€” does not subscribe. Caller decides how to read the result (call signals inside an effect for reactive reads).

The consumer must be in the DOM tree at call time. Inside a JSX ref callback the element has not yet been inserted, so a synchronous getContext(el, …) returns undefined. Defer the lookup to mount time with <dom-lifecycle>:

import { signal } from "elements-kit/signals";
import { getContext } from "elements-kit/utilities/context";
import "elements-kit/utilities/dom-lifecycle";
function ThemedButton() {
const theme = signal<"light" | "dark" | undefined>(undefined);
return (
<button data-theme={() => theme() ?? "light"}>
<dom-lifecycle
onConnect={(el) => theme(getContext<"light" | "dark">(el, THEME))}
/>
Click
</button>
);
}

For one-off reads, call getContext directly from an event handler β€” the tree is always connected by the time the handler fires:

function ThemedButton() {
return (
<button
on:click={(e) => {
const theme = getContext<() => "light" | "dark">(e.currentTarget, THEME);
console.log("theme:", theme?.());
}}
>
Click
</button>
);
}
function getContext<T>(consumer: Element, key: PropertyKey): T | undefined

Consuming context from a component

For a component body that needs getContext after connection, defer with <dom-lifecycle> β€” it’s render-inert (zero layout box, no a11y role) and its onConnect fires once the consumer is in the tree:

import { signal } from "elements-kit/signals";
import { getContext } from "elements-kit/utilities/context";
import "elements-kit/utilities/dom-lifecycle";
function ThemeBadge() {
const theme = signal<"light" | "dark" | undefined>(undefined);
return (
<dom-lifecycle
onConnect={(el) => theme(getContext<"light" | "dark">(el, THEME))}
>
<span>{() => theme() ?? "light"}</span>
</dom-lifecycle>
);
}

For a Signal/Computed-valued context that should drive reactivity, read the resolved signal inside an effect.

Nesting

Innermost provider wins because the walk returns on first hit:

<ThemeProvider>
<ThemedButton /> {/* outer theme */}
<ThemeProvider>
<ThemedButton /> {/* inner theme */}
</ThemeProvider>
</ThemeProvider>

Static values

Reactivity is opt-in β€” plain values work the same way:

const CONFIG = Symbol("config");
function ConfigProvider({ children }: { children: JSX.Element }) {
return setContext(<div>{children}</div>, CONFIG, { apiUrl: "/v1" });
}

Custom elements

Inside a custom element, setContext(this, ...) registers on the host directly β€” no wrapper needed. Use the render(this, template) lifecycle pattern so the entry is auto-cleaned on disconnect:

import { render } from "elements-kit/render";
class ThemeProvider extends HTMLElement {
#unmount?: () => void;
#template = () => {
const theme = signal<"light" | "dark">("dark");
setContext(this, THEME, theme);
return null;
};
connectedCallback() { this.#unmount = render(this, this.#template); }
disconnectedCallback() { this.#unmount?.(); this.#unmount = undefined; }
}

Low-level Primitives

fromEvent

Returns a Subscribe function for one or more DOM events on a target. Use with sync to build reactive wrappers around DOM APIs.

import { fromEvent } from "elements-kit/utilities/event-driven";
const onResize = fromEvent(window, "resize");
// pass to sync() as the subscribe argument
function fromEvent(target: EventTarget, events: string | string[]): Subscribe

sync

Keeps a reactive value in sync with an external source. Pass a Subscribe + a getter to get a Computed<T>; add a setter to get a writable Signal<T>.

import { fromEvent, sync } from "elements-kit/utilities/event-driven";
// Read-only: re-reads getter whenever events fire
const [scrollY] = sync(
fromEvent(window, "scroll"),
() => window.scrollY,
);
// Writable: setter syncs writes back to the external source
const [volume, cleanup] = sync(
fromEvent(audioEl, "volumechange"),
() => audioEl.volume,
(v) => { audioEl.volume = v; },
);
volume(0.5); // writes to audioEl.volume
type Subscribe = (notify: () => void) => () => void;
function sync<T>(subscribe: Subscribe, getter: () => T): [Computed<T>, () => void];
function sync<T>(subscribe: Subscribe, getter: () => T, setter: (v: T) => void): [Signal<T>, () => void];

See also