# Introduction Source: https://elements-kit.com/ **ElementsKit is a library of reactive primitives.** Pick one, use it in plain JavaScript, React, a custom element, or on the server. Everything builds on one small core; JSX, custom elements and the UI Kit are modules you add only if you want them. Every piece is its own import, so you ship only what you use. * **Compose, don't configure.** Small focused APIs: `signal`, `computed`, `on`, `fromEvent`, `async`. Combine primitives instead of maintaining an overloaded interface. * **Close to the platform.** `promise` extends `Promise`. Custom elements *are* `HTMLElement`. JSX compiles to `document.createElement`. No virtual DOM, no proxies. * **Predictable and explicit, no magic.** Signals are reactive; nothing else is. No hidden subscriptions. * **Designed for the AI age.** Code is cheap; maintenance still isn't. Swap one block at a time. ## Install ```sh pnpm add elements-kit ``` ```sh npm install elements-kit ``` ```sh yarn add elements-kit ``` ```sh bun add elements-kit ``` Full setup (TypeScript JSX config, CDN, Deno) in [Installation](/getting-started/installation). ```ts import { signal, computed, effect } from "elements-kit/signals"; import { online } from "elements-kit/utilities/network"; import { createMediaQuery } from "elements-kit/utilities/media-query"; import { on } from "elements-kit/utilities/event-listener"; const clicks = signal(0); const dark = createMediaQuery("(prefers-color-scheme: dark)"); const label = computed(() => `${clicks()} clicks · ${online() ? "online" : "offline"}`); const button = document.querySelector("button")!; on(button, "click", () => clicks(clicks() + 1)); effect(() => { button.textContent = label(); document.body.classList.toggle("dark", dark()); }); ``` ```tsx /** @jsxImportSource react */ import { signal, computed, effect } from "elements-kit/signals"; import { useSignal, useScope } from "elements-kit/integrations/react"; // Signals live outside React, shared with any other consumer. const count = signal(0); const doubled = computed(() => count() * 2); export default function Counter() { const value = useSignal(count); const double = useSignal(doubled); useScope(() => { effect(() => console.log("count:", count())); }); return (

{value} × 2 = {double}

{" "}
); } ``` ```tsx import { reactive, computed } from "elements-kit/signals"; import { attributes, ATTRIBUTES as attr } from "elements-kit/attributes"; import { render } from "elements-kit/render"; @attributes class CounterElement extends HTMLElement { static [attr] = { initial(this: CounterElement, v: string | null) { this.count = Number(v ?? 0); }, }; @reactive() count = 0; doubled = computed(() => this.count * 2); #unmount?: () => void; #template = () => (

{() => this.count} × 2 = {this.doubled}{" "}

); connectedCallback() { this.#unmount = render(this, this.#template); } disconnectedCallback() { this.#unmount?.(); this.#unmount = undefined; } } customElements.define("x-counter", CounterElement); // Usable anywhere: plain HTML, React, Vue. // ``` ```tsx import { signal, computed, resolve } from "elements-kit/signals"; import { render } from "elements-kit/render"; import type { Props } from "elements-kit/jsx-runtime"; function Counter(props: Props<{ initial?: number }>) { const count = signal(resolve(props.initial) ?? 0); const doubled = computed(() => count() * 2); return (

{count} × 2 = {doubled}

{" "}
); } render(document.getElementById("app")!, () => ); ``` ```tsx import { signal } from "elements-kit/signals"; import { async } from "elements-kit/utilities/async"; import { render } from "elements-kit/render"; const query = signal("hello"); const search = async(async () => { const res = await fetch(`/api/search?q=${query()}`); return res.json(); }).start(); render(document.getElementById("app")!, () => (
query((e.target as HTMLInputElement).value)} />

state: {() => search.state}

{() => JSON.stringify(search.value, null, 2)}
)); ``` Same signals in every tab. See the full progression (signals → element → component → custom element) in the [Quick start](/getting-started/quick-start). > **Built for the AI age** > > Explicit contracts survive edits by humans or agents. Machine-readable index: [`/llms.txt`](/llms.txt) (short) · [`/llms-full.txt`](/llms-full.txt) (every page concatenated). ## Start here - [Installation](/getting-started/installation): Install the package and configure JSX in your TypeScript project. - [Quick start](/getting-started/quick-start): Build a reactive counter five ways, from signals to custom elements. - [Philosophy](/getting-started/philosophy): Four principles behind the API: compose don't configure, close to the platform, predictable and explicit, designed for the AI age. ## Explore - [Signals](/signals): Core: signal · computed · effect · batch · onCleanup - [Stores](/stores): Core: @reactive classes shared across components and frameworks - [Primitives catalog](/primitives): Every primitive, by area, with where it runs - [Async & promise](/primitives/async): Primitives: queries, mutations, revalidation without a query library - [Routing](/primitives/routing): Primitives: location, URL patterns and navigate - [Form object](/primitives/form-object): Primitives: read and write a form as a nested object - [JSX elements](/elements): Rendering: JSX → real DOM with live bindings - [Custom elements](/custom-elements): Rendering: native HTMLElement with signals and decorators - [React](/integrations/react): Integrations: useSignal · useScope - [Data fetching recipe](/examples/data-fetching): Example: async + retry + online + window focus *** ElementsKit is built and maintained by the [Quba](https://www.quba.co) team. --- # Installation Source: https://elements-kit.com/getting-started/installation Install the package and point TypeScript at the ElementsKit JSX runtime. No build-step config on the consumer side — everything runs through your existing bundler. ## Install ```sh pnpm add elements-kit ``` ```sh npm install elements-kit ``` ```sh yarn add elements-kit ``` ```sh bun add elements-kit ``` ```sh deno add npm:elements-kit ``` ```html ``` ## Configure JSX Point TypeScript at the ElementsKit JSX runtime. Required if you write JSX; skip otherwise. ```json { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "elements-kit" } } ``` Per-file pragma is also supported if you need JSX from a different source elsewhere in the project: ```tsx /* @jsxImportSource elements-kit */ ``` ## Verify A minimal script confirms the install works: ```ts import { signal, effect } from "elements-kit/signals"; const count = signal(0); effect(() => console.log("count:", count())); count(1); // → count: 1 count(2); // → count: 2 ``` ## See also * [Quick start](/getting-started/quick-start) * [Philosophy](/getting-started/philosophy) * [Signals](/signals) --- # Quick start Source: https://elements-kit.com/getting-started/quick-start Start with signals, grow into a component, end as a native HTML tag. Each step below adds one layer on top of the previous — the reactive core never changes, only how you package it. ## From signal to custom element ```tsx // 5. Custom element. The same counter, usable as . import { reactive, computed } from "elements-kit/signals"; import { attributes, ATTRIBUTES as attr } from "elements-kit/attributes"; import { render } from "elements-kit/render"; @attributes class CounterElement extends HTMLElement { static [attr] = { initial(this: CounterElement, v: string | null) { this.count = Number(v ?? 0); }, }; @reactive() count = 0; doubled = computed(() => this.count * 2); #unmount?: () => void; #template = () => (

{() => this.count} × 2 = {this.doubled}

); connectedCallback() { this.#unmount = render(this, this.#template); } disconnectedCallback() { this.#unmount?.(); this.#unmount = undefined; } } customElements.define("x-counter", CounterElement); ``` ## What's happening * **Step 1 — signals.** `signal(0)` holds state; `computed(() => count() * 2)` derives from it. Both are framework-agnostic — no DOM involved. * **Step 2 — signals + JSX.** `render(target, setup)` runs `setup` inside an [`effectScope`](/scopes), appends the returned node to `target`, and hands back a single `unmount` thunk. `{count}` creates a live text node that updates in place — no diffing, no re-render. * **Step 3 — function component.** Props are typed with `Props`; each key on `props` arrives as a `Computed` (callable getter). Return type is `Element` — a real DOM node. * **Step 4 — class component.** `@reactive()` turns class fields into signals with natural property access. `render(): Element` returns the same shape, called once when the instance mounts. * **Step 5 — custom element.** `@attributes` wires the `static [attr]` map so HTML attributes feed reactive properties. `connectedCallback` mounts; `disconnectedCallback` tears down. Usable as a plain HTML tag in any framework or no framework at all. The reactive core (steps 1–2) is doing all the work. Steps 3–5 are just packaging. ## Next steps * Learn the signal primitives in depth → [Signals](/signals). * Understand JSX → DOM compilation → [Elements](/elements). * Wrap the same pattern in a native custom element → [Custom elements](/custom-elements). ## See also * [Installation](/getting-started/installation) * [Philosophy](/getting-started/philosophy) * [Scopes](/scopes) --- # Philosophy Source: https://elements-kit.com/getting-started/philosophy ElementsKit is a library of reactive primitives, not a framework. Each piece is its own import, runs on its own, and composes with the others — inside React, inside a custom element, or on its own in a script. ## Principles Four threads run through every API choice: **Compose, don't configure** Small focused APIs — `signal`, `computed`, `on`, `fromEvent`, `async`. Combine primitives instead of maintaining an overloaded interface. **Close to the platform** Thin or absent abstraction — no virtual DOM, no proxies, no build steps. JSX is `createElement`, custom elements *are* `HTMLElement`. **Predictable and explicit** `signal` / `compose` are reactive; nothing else is. No heuristic dependency tracking, no hidden subscriptions. **Designed for the AI age** Primitives compose into higher-level blocks. Swap one block at a time instead of maintaining long lines of code. ### Compose, don't configure ElementsKit gives you primitives you combine, not a system you configure. No config object, no plugin tree, no convention to memorize — the call site is the contract. Need a query layer? Compose `async` + `retry` + storage — swap the fetcher; leave the retry and storage alone. Need browser-API hooks? Import the utility. The surface grows by adding primitives, not by stacking flags on an existing one — overloaded interfaces accumulate breaking changes and deprecations that every consumer has to track and migrate through. The table below maps ElementsKit equivalents to each major feature from the usual stack — a **UI framework** (React, Vue, Solid), a **custom-element** library (Lit), a **server-state** library (TanStack Query, SWR), a **browser-hooks** pack (react-use, vueuse, solid-primitives), and a **state** manager (Zustand, Jotai, MobX). | Layer | Feature | ElementsKit | | ------------------- | --------------------- | -------------------------------------------------- | | **State** | Shared reactive state | `class Store { @reactive() count = 0 }` | | | Derived value | `computed(() => store.count * 2)` | | **Server state** | Query | `async(() => fetch(url())).start()` | | | Mutation | `mutation.run({ id, name })` | | | Retry with backoff | `async(retry(fn, 5, n => 2**n * 100)).start()` | | | Persist result | `async(fetcher.then(v => store(v))).start()` | | **Browser hooks** | Online / offline | `online()` | | | Viewport size | `windowSize.width()` | | | Media query | `createMediaQuery("(prefers-color-scheme: dark)")` | | | Debounce | `createInterval(300)` | | **UI framework** | Mount into DOM | `render(el, () => )` | | | Function component | `function Counter(props) { return
}` | | | Class component | `class Counter { render() { return
} }` | | | Lifecycle cleanup | `onCleanup(() => …)` | | **Custom elements** | Reactive HTMLElement | `@attributes class X extends HTMLElement {}` | | | Reactive property | `@reactive() count = 0` | | | Attribute handler | `static [attr] = { name(this, v) {} }` | | | Slot | `children = new Slot()` | ### Close to the platform Heavy abstraction over native APIs is where beginners get burned — they learn the abstraction, not the platform underneath, and the leak surfaces in production. ElementsKit keeps the layer thin or absent. JSX compiles to `document.createElement`. `promise` *extends* the native `Promise` (still awaitable, still chainable). `async` instances are thenable. Custom elements *are* `HTMLElement`. | ElementsKit | Native API | Relationship | | -------------------- | ------------------------ | ------------------------------------------------ | | JSX | `document.createElement` | Compiles to | | `promise` | `Promise` | Extends (still awaitable, still chainable) | | `async` | `Promise` | Thenable — `await asyncInstance` works | | Custom element | `HTMLElement` | Extends — registered via `customElements.define` | | `@reactive` field | Plain class field | Decorator — no proxy, no property rewrite | | `on(el, "click", …)` | `addEventListener` | Thin wrapper returning `Disposable` | ### Predictable and explicit No magic. `signal` / `compose` are reactive; nothing else is. No heuristic dependency tracking, no hidden subscriptions. Derivations live in `computed(...)`, cleanup in `onCleanup`. Stores are plain classes with `@reactive` field decorators. ```tsx // Framework — magic. Reactive tracking is implicit; // dep arrays, reducer indirection, StrictMode double-fires. function Counter() { const [s, dispatch] = useReducer((s, a) => { switch (a.type) { case "inc": return { n: s.n + 1 }; } }, { n: 0 }); const doubled = useMemo(() => s.n * 2, [s.n]); useEffect(() => { const id = setInterval(() => dispatch({ type: "inc" }), 1000); return () => clearInterval(id); }, []); return
{s.n} / {doubled}
; } ``` ### Designed for the AI age Writing code is cheap — an agent will draft most of it. *Maintaining* it is the bottleneck. A codebase is read and rewritten long after it's first authored, often by an agent rebuilding context from scratch. ElementsKit is built from primitives that compose into higher-level blocks. Each unit stays testable in isolation, which lets you — or an agent — swap one block at a time without touching the rest. ## Framework integration Any signal can drive a UI-framework component. The hooks are the bridge, not the primitive — the same utilities work inside React today, Solid or Vue when those integrations ship. Two hooks bridge signals into React: **`useSignal`** subscribes a component to any signal or computed; **`useScope`** ties `effect`, `onCleanup`, and factory utilities to the component's lifetime for automatic disposal. See [React integration](/integrations/react) for the full treatment. ```tsx // effect + onCleanup inside useScope — torn down with the component. /** @jsxImportSource react */ import { effect, onCleanup } from "elements-kit/signals"; import { useScope } from "elements-kit/integrations/react"; import { online } from "elements-kit/utilities/network"; export function NetworkLogger() { const offline = useScope(() => { effect(() => { console.log("online:", online()); }); const id = setInterval(() => console.log("tick"), 1000); onCleanup(() => clearInterval(id)); return computed(() => !online()); }); return

Watching network in the console.

; } ``` ## Architecture The pieces are independent and composable — not a strict hierarchy. Signals are the shared language that connects everything. ## Pattern guide | Pattern | When to use it | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Plain signals | Pure logic, no UI. Node scripts, workers, utilities that need reactive state. | | Elements / JSX | Inline reactive UI in scripts. Quick components appended directly to the DOM. | | Components | Class-based UI with `render()`. Share state across multiple elements via stores. | | Custom elements | Reusable across frameworks. Native HTML integration, Shadow DOM, framework-agnostic distribution. | | Framework integration | Drop a signal, store, or utility into an existing React (today; Solid/Vue planned) app via `useSignal` / `useScope`. Share reactive state with code that lives outside the framework. | Start with the simplest option that fits — you can always combine patterns. ## Not for > **Reach for something else if you need** > > ElementsKit is deliberately narrow. > > * **Server-side rendering or streaming HTML.** No SSR, no hydration, no React Server Components. Rendering happens in the browser. > * **A complete app framework.** No router, no data layer, no file-system conventions — bring or compose your own. > * **A pre-built component library.** This ships primitives, not components. A design system on top has to come from you or a third party. Narrow doesn't mean incomplete — signals + elements + custom elements cover the full client-side reactive-UI surface. ## See also * [Installation](/getting-started/installation) * [Quick start](/getting-started/quick-start) * [Signals](/signals) * [Elements](/elements) --- # Signals Source: https://elements-kit.com/signals Signals are the reactive core of ElementsKit. Every UI update, derived value, and side effect is driven by three primitives: `signal`, `computed`, and `effect`. ## signal A `signal` holds a single reactive value. Call it with no arguments to **read**, with an argument to **write**. ```ts import { signal } from "elements-kit/signals"; const count = signal(0); count(); // Read → 0 count(5); // Write — notify all dependents count(); // Read → 5 ``` ```ts import { signal } from "elements-kit/signals"; const name = signal("Alice"); // ^? name(); // "Alice" name("Bob"); // write name(); // "Bob" ``` Signals are synchronous — every write immediately notifies dependents. > **Reactive children need `() =>`** > > In JSX, signal/computed values must be wrapped in a function: > > ```tsx > // ❌ Wrong — reads once, never updates >

{count()}

> > // ✅ Correct — reads on every change >

{() => count()}

> ``` ## computed A `computed` derives a value from one or more signals. It is **lazy** — only re-evaluates when a dependency has changed and the value is read again. Results are cached between reads. ```ts import { signal, computed } from "elements-kit/signals"; const price = signal(10); const qty = signal(3); const total = computed(() => price() * qty()); total(); // → 30 price(20); total(); // → 60 (recomputed — price changed) qty(1); total(); // → 20 (recomputed again) ``` ```ts import { signal, computed } from "elements-kit/signals"; const firstName = signal("Ada"); const lastName = signal("Lovelace"); const fullName = computed(() => `${firstName()} ${lastName()}`); // ^? fullName(); // "Ada Lovelace" firstName("Grace"); fullName(); // "Grace Lovelace" ``` A `computed` is read-only — you can only call it with no arguments. *** ## effect An `effect` runs a side effect whenever its signal dependencies change. It runs **immediately** on creation to collect dependencies, then re-runs on every change. Returns a stop function. ```ts import { signal, effect } from "elements-kit/signals"; const count = signal(0); const stop = effect(() => { console.log("count:", count()); // count: 0 }); count(1); // → count: 1 count(2); // → count: 2 stop(); // Dispose — no more logs ``` ```ts import { signal, effect } from "elements-kit/signals"; const theme = signal("light"); const stop = effect(() => { // ^? // Reads theme — becomes a dependency automatically document.body.dataset.theme = theme(); }); theme("dark"); // body.dataset.theme = "dark" stop(); // Unsubscribe — no more updates ``` Effects track dependencies **dynamically** — only the signals actually read during the last run are tracked. > **Reading signals outside effects** > > Signals only trigger updates when read inside a reactive context (effect, computed, or reactive JSX). Reading outside gives you the current value but won't re-run: > > ```ts > const name = signal("Alice"); > > // Logs once — not reactive > console.log(name()); > > // Re-runs whenever name changes > effect(() => console.log(name())); > ``` ## effectScope Groups multiple effects under a single lifecycle. Calling the returned stop function disposes **all** effects at once. ```ts import { signal, effect, effectScope } from "elements-kit/signals"; const user = signal("Alice"); const page = signal("/home"); // All effects inside are grouped together const stop = effectScope(() => { // ^? effect(() => console.log("user:", user())); effect(() => console.log("page:", page())); }); user("Bob"); // → user: Bob page("/about"); // → page: /about stop(); // Both effects disposed — silence ``` Use `effectScope` when you want to start and stop a group of related effects together — e.g. tied to a component's lifetime. > **JSX already scopes for you** > > Function components, intrinsic elements, fragments, and `` items each create their own `effectScope`. You only need to create one by hand in custom-element lifecycle hooks or other non-JSX entry points — see [Scopes & cleanup](/scopes/). *** ## onCleanup Registers a cleanup callback inside the currently running `effect` or `effectScope`. The callback runs **before the effect re-runs** and **when the effect is disposed**. ```ts import { signal, effect, onCleanup } from "elements-kit/signals"; const url = signal("/api/data"); effect(() => { const controller = new AbortController(); fetch(url(), { signal: controller.signal }) .then((r) => r.json()) .then(console.log); // Runs before the next fetch (url changed) or on stop() onCleanup(() => controller.abort()); }); url("/api/other"); // previous fetch aborted, new one starts ``` `onCleanup` works at **any call depth** inside the effect — you can call it anywhere without passing anything down. You can use `onCleanup` inside a `computed` getter to manage resources that need cleanup (like timers or subscriptions). The cleanup runs **before the computed re-evaluates** (when dependencies change), and also when the computed loses its last subscriber (to prevent resource leaks). ```ts import { signal, computed, onCleanup } from "elements-kit/signals"; const source = signal(0); const derived = computed(() => { // Setup resource const id = setInterval(() => { // ...do something... }, 1000); // Register cleanup onCleanup(() => clearInterval(id)); return source() * 2; }); ``` This ensures that any resources created inside the computed are always cleaned up at the right time. *** ## batch Defers all signal notifications until the batch completes. Use it to group multiple writes into a single update pass — avoids intermediate re-renders. ```ts import { signal, effect, batch } from "elements-kit/signals"; const x = signal(1); const y = signal(2); effect(() => console.log(x(), y())); // → 1 2 (initial run) // Without batch: effect would run twice (once per write) batch(() => { x(10); y(20); }); // → 10 20 (single run) ``` *** ## untracked Reads a signal **without** creating a dependency. The current effect will not re-run when that signal changes. ```ts import { signal, effect, untracked } from "elements-kit/signals"; const count = signal(0); const secret = signal("hidden"); effect(() => { // Tracked — effect reruns when count changes console.log("count:", count()); // Untracked — effect does NOT rerun when secret changes console.log("secret:", untracked(() => secret())); }); count(1); // triggers re-run secret("x"); // silent — not a dependency ``` > **Mutating vs. reassigning** > > Arrays and objects are passed by reference. Mutating them (`push`, `pop`, `arr[0] = x`) doesn't notify subscribers — the signal value (reference) hasn't changed. > > ```ts > const items = signal([]); > > // ❌ Won't trigger > items().push("Hello"); > ``` > > **Option 1 — Mutation + trigger** (for expensive data): > > ```ts > const items = signal([]); > > items().push("hello"); > trigger(items); // ✅ Mutate in place, then notify > ``` > > **Option 2 — Reassignment**: > > ```ts > const items = signal([]); > > items([...items(), "hello"]); // ✅ Creates new array > ``` > > The same applies to objects: > > ```ts > const user = signal({ name: "Alice" }); > > // ❌ Won't trigger > user().name = "Bob"; > > // ✅ Reassignment > user({ ...user(), name: "Bob" }); > > // ✅ Or mutation + trigger > user().name = "Bob"; > trigger(user); > ``` *** ## @reactive decorator Backs a class field with a signal transparently. The field reads and writes naturally — no `()` needed on the consumer side. ```ts import { reactive, computed } from "elements-kit/signals"; class Counter { @reactive() count = 0; doubled = computed(() => this.count * 2); // ^? } const c = new Counter(); c.count = 5; c.doubled(); // → 10 ``` `@reactive` is equivalent to a private `signal` + `get`/`set` accessor — it just removes the boilerplate. Works with `computed` too: pass the signal source to make a field read-only. ```ts class Store { #items = signal([]); // Read-only reactive field backed by a computed @reactive((s) => computed(() => s.#items().length)) count = 0; } ``` *** ## Store A **store** is a class with `@reactive` fields. It holds state — no `render()`, no DOM. See [Stores](/stores) for full documentation. ## See also * [Stores](/stores) — shared reactive state. * [Elements](/elements) — use signals as JSX children/props. * [Promise](/primitives/promise) — reactive state over any `Promise`. * [Async](/primitives/async) — start/stop/run a reactive async controller. --- # Store Source: https://elements-kit.com/stores A **store** is a plain class whose fields are made reactive with `@reactive`. It holds state — no `render()`, no DOM. Any component, effect, or framework integration that reads a store field tracks it as a signal dependency and re-runs when it changes. ## Create a shared store ```ts import { reactive, computed } from "elements-kit/signals"; export class CartStore { @reactive() items: { name: string; price: number }[] = []; @reactive() discount = 0; subtotal = computed(() => this.items.reduce((sum, i) => sum + i.price, 0), ); total = computed(() => this.subtotal() * (1 - this.discount)); add(item: { name: string; price: number }) { this.items = [...this.items, item]; } remove(name: string) { this.items = this.items.filter((i) => i.name !== name); } } export const cart = new CartStore(); ``` Stores are **framework-agnostic** — the same instance drives a custom element, a React component, and a plain effect, all in sync. *** ## Reading from a store `@reactive()` installs a `get` / `set` accessor on the class field. Reading the field calls the signal's read function — so anywhere you pass `() => store.field`, you create a live subscription. ```tsx import { effect, computed } from "elements-kit/signals"; import { cart } from "./cart-store"; // Effect — reruns when cart.items or cart.discount changes effect(() => { console.log(cart.items.length, "items, total:", cart.total()); }); // In an element — live text binding

Total: {() => cart.total()}

// Compose into another computed const isEmpty = computed(() => cart.items.length === 0); // In React (via useSignal) const items = useSignal(() => cart.items); const total = useSignal(cart.total); // Computed directly ``` *** ## Singleton vs. instance **Singleton** — one shared instance for the whole app or a module: ```ts // cart-store.ts export const cart = new CartStore(); // anywhere.ts import { cart } from "./cart-store"; cart.add({ name: "Widget", price: 9.99 }); ``` **Instance** — a new store per component or subtree: ```tsx class TodoComponent { store = new TodoStore(); // private, scoped to this component render() { return
    {() => this.store.todos.map(...)}
; } } ``` *** ## Store with actions Group mutations as methods. Methods are plain functions — they write to `@reactive` fields and the reactivity system handles the rest. ```ts import { reactive, computed } from "elements-kit/signals"; export class AuthStore { @reactive() user: { name: string; role: string } | null = null; @reactive() loading = false; isAdmin = computed(() => this.user?.role === "admin"); async login(email: string, password: string) { this.loading = true; try { this.user = await api.login(email, password); } finally { this.loading = false; } } logout() { this.user = null; } } export const auth = new AuthStore(); ``` *** ## Composing stores Stores can read from each other — just access their fields inside a `computed`: ```ts export class UIStore { @reactive() sidebarOpen = true; // Derived from another store pageTitle = computed(() => auth.user ? `Welcome, ${auth.user.name}` : "Sign in", ); } export const ui = new UIStore(); ``` *** ## See also * [Signals](/signals) * [Components](/components) * [React integration](/integrations/react) --- # Scopes Source: https://elements-kit.com/scopes Every `effect`, and `onCleanup` belongs to a **scope**. When the scope disposes, everything inside it tears down. Most JSX boundaries create a scope for you — knowing which ones, and where that scope is missing, is the whole contract. ## The table | Boundary | Auto-scoped | Disposed when | | --------------------------------------------------- | ----------------------------- | --------------------------------------------------------- | | Function component body — `(props) => ` | yes | the element is removed | | Intrinsic or class element — `
`, `` | yes | the element is removed | | Fragment — `<>...` | yes (via the JSX transform) | the fragment's disposables run | | Reactive child slot — `{() => signal()}` | yes | the slot updates or its parent unmounts | | `` render callback, per item | yes | the item's key leaves `each`, or the list unmounts | | Custom element `connectedCallback` | **no** — opt in with `render` | you call the returned `unmount` in `disconnectedCallback` | | App root (`document.getElementById("app")`) | **no** — opt in with `render` | you call the returned `unmount` | If you write `onCleanup(...)` or `effect(...)` inside any of the first five rows, the library owns its lifetime. At the boundaries below, you own it. ## `render` — mount with a scoped lifetime `render(target, setup)` runs `setup` inside a detached `effectScope`, appends its returned node to `target`, and hands back a single `unmount` thunk. Calling `unmount` removes the node from the DOM, disposes its `Symbol.dispose` hook, and tears down every `effect` / `onCleanup` registered inside `setup`. ### Mounting an app Typical entry point — mount a root component into the page container and keep a reference to the `unmount` thunk (e.g. for hot-module replacement): ```tsx import { render } from "elements-kit/render"; import { App } from "./App"; const unmount = render( document.getElementById("app")!, () => as Element, ); ``` ### Inside a custom element Use the same helper in `connectedCallback`; stash the returned `unmount` on the element and call it from `disconnectedCallback`. ```tsx import { onCleanup } from "elements-kit/signals"; import { render } from "elements-kit/render"; class Clock extends HTMLElement { #unmount?: () => void; #template = () => { const el = document.createElement("time"); const id = setInterval(() => (el.textContent = String(Date.now())), 1000); onCleanup(() => clearInterval(id)); return el; }; connectedCallback() { this.#unmount = render(this, this.#template); } disconnectedCallback() { this.#unmount?.(); this.#unmount = undefined; } } ``` > **Effects must live inside the render callback** > > Put every `effect` and `onCleanup` **inside** the `render` callback. Anything created outside — in the constructor, a field initializer, or `attributeChangedCallback` — has no owning scope and will not stop when the element disconnects. The same rule applies to effects started from a timer or event handler: create them inside the `render` callback, not on first event. `computed` is exempt — it's lazy and disposes itself when it has no subscribers, so a class-field `computed` is fine. `render` runs `setup` inside an `effectScope` detached from any enclosing effect — re-running an outer effect won't tear your scope down; only calling the returned `unmount` will. ## Why `For` items get their own scope Each `` item runs its render callback inside its own `effectScope`. `onCleanup` registered during render fires when that item's key leaves `each` — not only on full-list unmount. This keeps per-row resources (subscriptions, intervals, observers) bound to the row's actual lifetime. ```tsx r.id}> {(row) => { const off = subscribe(row.id); onCleanup(off); // fires when this row is removed return
  • {row.label}
  • ; }}
    ``` ## See also * [Signals](/signals/) — `effect`, `effectScope`, `onCleanup`. * [For (list rendering)](/elements/for/) — per-item scope details. * [Custom elements overview](/custom-elements/) — lifecycle hooks. --- # Primitives Source: https://elements-kit.com/primitives Primitives are small factories that return signals. Each one is its own import, needs no framework, and cleans up with the scope it was created in. Read them in plain scripts, custom elements, JSX, or React. ```ts import { effect } from "elements-kit/signals"; import { online } from "elements-kit/utilities/network"; import { createMediaQuery } from "elements-kit/utilities/media-query"; const dark = createMediaQuery("(prefers-color-scheme: dark)"); effect(() => { document.title = `${online() ? "Online" : "Offline"} · ${dark() ? "dark" : "light"}`; }); ``` - [Data](/primitives/promise) - [Forms](/primitives/form-object) - [Routing](/primitives/routing) - [Context](/primitives/context) - [DOM events](/primitives/events) - [Observers](/primitives/observers) - [Browser state](/primitives/browser-state) - [Timing](/primitives/timing) - [Media](/primitives/media) - [Building primitives](/primitives/building-primitives) ## Catalog Every primitive imports from `elements-kit/utilities/`. **Universal** primitives run anywhere; **browser** ones need a DOM and return neutral defaults on the server. | Primitive | Module | Returns | Runs in | | -------------------------------------------------------------------------------- | ----------------------- | ---------------------------------------------------------- | --------- | | **Data** | | | | | [`promise`](/primitives/promise) | `promise` | `ComputedPromise` | Universal | | [`async`](/primitives/async) | `async` | `Async` | Universal | | [`retry`](/primitives/retry) | `retry` | `() => Promise` | Universal | | [`createLocalStorage`](/primitives/storage#createlocalstorage) | `storage` | `Signal` | Browser | | [`createSessionStorage`](/primitives/storage#createsessionstorage) | `storage` | `Signal` | Browser | | **Forms** | | | | | [`FormObject`](/primitives/form-object) | `form-object` | `{ toObject, fromObject, clear }` | Browser | | **Routing** | | | | | [`currentLocation`](/primitives/routing#currentlocation) | `location` | `{ hash, href, pathname, search }` | Browser | | [`createSearchParam`](/primitives/routing#createsearchparam) | `search-params` | `Computed` | Browser | | [`navigate`](/primitives/routing#navigate) | `routing` | `void` | Browser | | [`matches`](/primitives/routing#matches) / [`match`](/primitives/routing#match) | `routing` | `Computed` / `Computed` | Browser | | [`isLocalNavigationEvent`](/primitives/routing#islocalnavigationevent) | `routing` | `boolean` | Browser | | **Context** | | | | | [`setContext` / `getContext`](/primitives/context) | `context` | `void` / `T \| undefined` | Universal | | **DOM events** | | | | | [`on`](/primitives/events#on) | `event-listener` | `() => void` | Browser | | [`createHover`](/primitives/events#createhover) | `hover` | `Computed` | Browser | | [`createFocusWithin`](/primitives/events#createfocuswithin) | `focus-within` | `Computed` | Browser | | [`onClickOutside`](/primitives/events#onclickoutside) | `on-click-outside` | `() => void` | Browser | | [`createLongPress`](/primitives/events#createlongpress) | `long-press` | `() => void` | Browser | | [`activeElement`](/primitives/events#activeelement) | `active-element` | `Computed` | Browser | | **Observers** | | | | | [`createElementRect`](/primitives/observers#createelementrect) | `element-rect` | `ElementRect` | Browser | | [`createElementScroll`](/primitives/observers#createelementscroll) | `element-scroll` | `{ x, y } & Disposable` | Browser | | [`createResizeObserver`](/primitives/observers#createresizeobserver) | `resize-observer` | `Disposable` | Browser | | [`createIntersectionObserver`](/primitives/observers#createintersectionobserver) | `intersection-observer` | `Disposable` | Browser | | [`createMutationObserver`](/primitives/observers#createmutationobserver) | `mutation-observer` | `Disposable` | Browser | | **Browser state** | | | | | [`createMediaQuery`](/primitives/browser-state#createmediaquery) | `media-query` | `Computed` | Browser | | [`windowSize`](/primitives/browser-state#windowsize) | `window-size` | `{ width, height }` | Browser | | [`orientation`](/primitives/browser-state#orientation) | `orientation` | `Computed<…>` | Browser | | [`online`](/primitives/browser-state#online) | `network` | `Computed` | Browser | | `windowFocused` | `window-focus` | `Computed` | Browser | | [`createMediaDevices`](/primitives/browser-state#createmediadevices) | `media-devices` | `Computed` | Browser | | **Timing** | | | | | [`createTimeout`](/primitives/timing#createtimeout) | `timeout` | `{ pending, start, stop, reset }` | Universal | | [`createInterval`](/primitives/timing#createinterval) | `interval` | `{ pending, start, stop, reset }` | Universal | | [`createDebounced`](/primitives/timing#createdebounced) | `debounced` | `Computed` | Universal | | [`createThrottled`](/primitives/timing#createthrottled) | `throttled` | `Computed` | Universal | | [`createPrevious`](/primitives/timing#createprevious) | `previous` | `Computed` | Universal | | **Media** | | | | | [`createMediaPlayer`](/primitives/media#createmediaplayer) | `media-player` | `{ playing, muted, volume, … }` | Browser | | **Building primitives** | | | | | [`fromEvent`](/primitives/building-primitives#fromevent) | `event-driven` | `Subscribe` | Universal | | [`sync`](/primitives/building-primitives#sync) | `event-driven` | `[Computed \| Signal, () => void]` | Universal | *** ## Use them anywhere 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`: ```tsx 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

    {path}: {width}px wide

    ; } ``` **Factory utilities** with per-component lifetime go inside `useScope` so they are created and cleaned up with the component: ```tsx 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 ( <> query(e.currentTarget.value)} />

    Searching for: {debounced}

    ); } ``` **Writable signals** from storage or scroll work as both getter (via `useSignal`) and setter (call directly): ```tsx 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 ( ); } ``` *** ## See also * [Signals](/signals): the core every primitive returns * [Scopes & cleanup](/scopes): how primitives release listeners and timers * [Integrations](/integrations): React, Vue, Svelte and more --- # Promise Source: https://elements-kit.com/primitives/promise The `promise` utility wraps any native `Promise`, executor function, or `ReactivePromise` into a `ComputedPromise` — an object that is both **awaitable** like a regular promise and **reactive** like a signal. ## Basic Usage ```ts import { promise } from "elements-kit/utilities/promise"; // From an executor const cp = promise((resolve) => { setTimeout(() => resolve(42), 1000); }); // From an existing Promise const cp2 = promise(fetch("/api/data").then(res => res.json())); ``` ## Awaitable A `ComputedPromise` is a real `Promise` — you can `await` it, chain `.then()` / `.catch()` / `.finally()`, and use it anywhere a `Promise` is expected: ```ts const data = await cp; cp.then(value => console.log(value)) .catch(err => console.error(err)); ``` ## Reactive Calling the `ComputedPromise` as a function inside an `effect` or `computed` tracks it as a reactive dependency: * **Pending** → returns `undefined` * **Fulfilled** → returns the resolved value * **Rejected** → throws the rejection reason ```ts import { effect } from "elements-kit/signals"; effect(() => { try { const value = cp(); // undefined while pending, T when fulfilled console.log("value:", value); } catch (err) { console.error("error:", err); } }); ``` ## State Properties All three state properties are reactive — reading them inside an `effect` or `computed` subscribes to changes: ```ts cp.state; // "pending" | "fulfilled" | "rejected" cp.value; // T | undefined cp.reason; // E | undefined ``` ## Result `.result` is a convenience property that returns `T | E | undefined` — the resolved value when fulfilled, the rejection reason when rejected, or `undefined` while pending. Useful when you don't need to distinguish between fulfilled and rejected but just want the current value: ```ts effect(() => { console.log("current result:", cp.result); }); ``` ## `ReactivePromise` `ReactivePromise` is the lower-level class that powers `ComputedPromise`. Use it directly when you need to construct or wrap a promise and expose its reactive state without the `Computed` callable interface: ```ts import { ReactivePromise } from "elements-kit/utilities/promise"; // From an executor const rp = new ReactivePromise((resolve, reject) => { fetch("/api/data") .then(res => res.json()) .then(resolve) .catch(reject); }); // Wrap an existing promise const rp2 = ReactivePromise.from(fetch("/api/data")); rp.state; // "pending" | "fulfilled" | "rejected" rp.value; // T | undefined rp.reason; // E | undefined rp.result; // PromiseResult ``` `cp instanceof ReactivePromise` returns `true` for any `ComputedPromise`. ## See also * [Async](/primitives/async) — higher-level controller with `start` / `stop` / `run`. * [Data fetching](/examples/data-fetching) — composed async recipe. * [Signals](/signals) --- # Async Source: https://elements-kit.com/primitives/async The `async` utility wraps an async function into a reactive, awaitable controller. You can start, stop, and rerun async operations, and read their state, value, and errors as reactive signals. ## Basic usage ```ts import { async } from "elements-kit/utilities/async"; const fetchItems = async(() => fetch("/api/items").then((res) => res.json())); fetchItems.start(); // begin reactive execution ``` ## Control methods Start for reactive execution, run for one-shot, stop to tear down, reset to clear. ```ts op.start(); // run and track reactive dependencies — reruns when signals change op.run(); // run once without tracking — does not rerun on signal changes op.stop(); // stop reactive reruns and run cleanup logic op.reset(); // stop, then clear state back to `idle` ``` ### When to `start()` vs `run()` `start()` enables fetching automatically. The body is tracked, so the query re-runs whenever a signal it reads before the first `await` changes. Reach for it when the load is parameterised by reactive state — "fetch user X whenever `userId` changes". The [data-fetching recipe](/examples/data-fetching) uses `start()` with reactive params. `run()` is one-shot and untracked. Reach for it when an external event drives the load — intersection, click, form submit — especially when the body **writes** to the same signals it reads. The [infinite-scroll recipe](/examples/infinite-scroll) uses `run()` for sentinel-driven page loads. > **`start()` cascades when the body writes its own deps** > > A tracked body that mutates a signal it depends on retriggers itself. Use `run()` for externally-driven loads instead. > > ```ts > // Wrong — body reads `cursor`, body writes `cursor`. Each write retriggers the > // tracking effect; on first mount this fetches every page in a tight loop. > const loadMore = async(async () => { > const page = await fetchPage(cursor()); > cursor(page.next); > }).start(); > > // Right — `run()` is untracked, so the write doesn't re-fire the body. > // Drive it from an event (intersection, click, etc.). > const loadMore = async(async () => { > const page = await fetchPage(cursor()); > cursor(page.next); > }); > sentinelInView.subscribe(() => loadMore.run()); > ``` `Async` implements `Symbol.dispose`, so `using` stops it automatically when it goes out of scope: ```ts { using op = async(() => fetch("/api/data").then((r) => r.json())).start(); await op; console.log(op.value); } // op.stop() called automatically here ``` ## Reactive state `Async` exposes the same reactive state interface as `ReactivePromise`, plus an `"idle"` state for the window before the first `run()` / `start()`: ```ts op.state; // "idle" | "pending" | "fulfilled" | "rejected" op.value; // resolved value (T | undefined) op.reason; // rejection reason (E | undefined) op.result; // value if fulfilled, reason if rejected, undefined otherwise op.pending; // true only while a run is in flight ``` Unlike a bare `ReactivePromise` (which wraps an already in-flight promise and so starts `pending`), an `Async` is a lazy runner: with no request yet it starts `idle` and `op.pending` is `false` until you trigger a run. Drive loading UI off `op.pending` and it stays quiet until then. All properties are reactive — reading them inside an `effect` or `computed` subscribes to changes. ## Callable signal An `Async` instance is also callable as a signal. `op()` returns `op.result` and tracks it as a reactive dependency: ```ts import { effect } from "elements-kit/signals"; effect(() => { const result = op(); // undefined while pending, T when fulfilled, E when rejected console.log(result); }); ``` This makes `Async` composable with `computed` and templates. ## Awaitable `Async` implements `.then`, `.catch`, and `.finally`, so you can `await` it directly: ```ts const op = async(() => Promise.resolve(123)).start(); const value = await op; // 123 ``` ## Reactive reruns Read signals inside the async function to make it re-execute when they change. Only signal reads **before the first `await`** are tracked. ```ts import { signal } from "elements-kit/signals"; import { async } from "elements-kit/utilities/async"; const id = signal(1); const fetchTodo = async(() => fetch(`https://jsonplaceholder.typicode.com/todos/${id()}`) // tracked .then((res) => res.json()), ).start(); // re-fetches automatically when id changes id(2); // triggers a new fetch ``` > **Signals aren't tracked after an `await`** > > ```ts > // ❌ Not reactive — id() is read after an await > const op = async(async () => { > await someAsyncSetup(); > const currentId = id(); // not tracked > }); > > // ✅ Reactive — id() is read before any await > const op = async(async () => { > const currentId = id(); // tracked > await fetch(`/todos/${currentId}`); > }); > ``` `run()` is untracked — signals inside the fn do not trigger re-runs. To get reactive reruns with explicit parameters, wrap it in an external `effect`: ```ts import { effect, signal } from "elements-kit/signals"; const todoId = signal(1); effect(() => { fetchTodo.run(todoId()); // re-fetches when todoId changes (tracked by outer effect) }); ``` ## Cleanups Register cleanup logic inside your async function using `onCleanup`. It runs when `stop()` is called or when `start()` re-runs due to a signal change: ```ts import { onCleanup } from "elements-kit/signals"; const query = async((id: number) => { const controller = new AbortController(); onCleanup(() => controller.abort()); return fetch(`/api/todos/${id}`, { signal: controller.signal }).then((r) => r.json(), ); }).start(); ``` `onCleanup` also works inside `run()` — the cleanup fires when `stop()` is called or when the next `run()` replaces it. ## See also * [Promise](/primitives/promise) — the underlying `ComputedPromise` / `ReactivePromise` primitive. * [Data fetching](/examples/data-fetching) — full recipe composing retry, online, window-focus. * [Signals](/signals) — `onCleanup`, `untracked`. --- # Retry Source: https://elements-kit.com/primitives/retry `retry` wraps an async function and re-runs it on failure. ## retry Wraps a `() => Promise` 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. ```ts 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 ``` ```ts function retry( fn: () => Promise, attempts: number, delay?: number | ((attempt: number) => number), ): () => Promise ``` Compose with `async()` for reactive retries: ```ts 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(); ``` *** ## See also * [Async](/primitives/async) * [Data fetching example](/examples/data-fetching) --- # Storage Source: https://elements-kit.com/primitives/storage Persist state with a signal: read it, write it, and it stays in storage. ## createLocalStorage Returns a `Signal` persisted to `localStorage`. Writes in other tabs/windows are synchronised automatically via `StorageEvent`. ```ts import { createLocalStorage } from "elements-kit/utilities/storage"; const theme = createLocalStorage("theme", "light"); theme(); // "light" theme("dark"); // persisted immediately ``` ```ts function createLocalStorage( key: string, initialValue: T, options?: { serialise?: (v: T) => string; deserialise?: (raw: string) => T }, ): Signal ``` *** ## createSessionStorage Same as `createLocalStorage` but scoped to the current tab — no cross-tab sync. ```ts import { createSessionStorage } from "elements-kit/utilities/storage"; const draft = createSessionStorage("draft", ""); ``` ```ts function createSessionStorage(key: string, initialValue: T, options?: StorageOptions): Signal ``` *** ## See also * [Promise](/primitives/promise) * [Async](/primitives/async) --- # Form object Source: https://elements-kit.com/primitives/form-object `FormObject` turns an `HTMLFormElement` into a structured plain object — and writes one back — using the control `name` to shape the result. It is a static snapshot, like `new FormData()`, with no reactivity or dependencies. Type into the form below and watch the object update. ## Field names shape the object The `name` attribute is a path. Dots nest objects, numeric segments build arrays, and a trailing `[]` marks an append array. ```ts import { FormObject } from "elements-kit/utilities/form-object"; // → { user: { name } } // → { user: { address: { city } } } // → { tags: [ "…" ] } (explicit index) // → { colors: [ "…" ] } (append, one per control) const data = new FormObject(form).toObject(); ``` A trailing `[]` always produces an array — a single `colors[]` control gives `["x"]`, and a group with none checked still serializes to `[]`. Use it whenever a field is conceptually a list. ## Reading a form `toObject()` walks the form's named controls and returns the nested object. It mirrors native form submission: disabled and unchecked controls are dropped, `` yields `File` objects. `toJSON()` is an alias, so `JSON.stringify(instance)` works. ```ts const form = document.querySelector("form")!; const data = new FormObject(form).toObject(); ``` > **Bare repeated names: last value wins** > > When several controls share a bare `name` (no `[]`), the **last** value wins — matching form-data-json. So the `` + `` idiom yields `"1"` when checked and `"0"` when unchecked. Reach for `name="x[]"` when you actually want both values in an array. ## Writing back `fromObject(data)` assigns values from a nested object onto matching controls — setting `.value`, checking boxes and radios, and selecting options. Missing paths are left untouched. `clear()` resets every named control. Both return `this` for chaining. ```ts new FormObject(form).fromObject({ user: { name: "Wael" }, colors: ["red", "blue"], // checks the matching colors[] boxes }); ``` ## Transforms Extraction runs each field through a pipeline of `FormFieldTransform`s. Each transform receives the field (`{ control, name, value, checked? }`) and returns it, a modified copy, or `null` to drop it. The default pipeline skips disabled controls, buttons, and unchecked boxes: ```ts import { FormObject, defaultTransforms, uncheckedAs, skipEmpty, } from "elements-kit/utilities/form-object"; // Replace the defaults — include unchecked checkboxes as `false` new FormObject(form, { transforms: [...defaultTransforms.slice(0, 2), uncheckedAs(false)], }).toObject(); // Extend — drop empty fields and trim strings new FormObject(form, { transforms: [ ...defaultTransforms, skipEmpty, (f) => ({ ...f, value: typeof f.value === "string" ? f.value.trim() : f.value }), ], }).toObject(); ``` Built-ins: `skipDisabled`, `skipButtons`, `dropUnchecked` (the defaults), plus `uncheckedAs(value)` and `skipEmpty`. Passing `transforms` **replaces** the defaults — spread `...defaultTransforms` to keep them. > **An empty pipeline keeps everything** > > `transforms: []` removes all filtering, so disabled controls, buttons, and unchecked checkbox values are all emitted. Inspect `field.checked` in a custom transform when you need the checkbox state. Field names that walk an object's prototype (`__proto__`, `prototype`, `constructor`) are ignored, so a malicious `name` cannot pollute `Object.prototype`. ## See also * [Primitives](/primitives) * [Signals](/signals) * Source & full behavior table: [src/utilities/form-object.ts](https://github.com/elements-kit/elements-kit/blob/main/src/utilities/form-object.ts) --- # Routing Source: https://elements-kit.com/primitives/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`: ```ts // 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. ```ts import { currentLocation } from "elements-kit/utilities/location"; // Singleton — shared across the app effect(() => console.log(currentLocation.pathname())); ``` ```ts type LocationResult = { hash: Computed; href: Computed; pathname: Computed; search: Computed; }; const currentLocation: LocationResult ``` ## createSearchParam Returns `Computed` for a single URL search parameter. ```ts import { createSearchParam } from "elements-kit/utilities/search-params"; const tab = createSearchParam("tab"); effect(() => console.log("tab:", tab())); // null when absent ``` ```ts function createSearchParam(key: string): Computed ``` ## createURLPattern Reactively tests a URL source against a [`URLPattern`](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API). 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. ```ts 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)); ``` ```ts function createURLPattern( source: string | URL | Computed, input?: URLPatternInput, options?: URLPatternOptions, ): Computed ``` ## navigate 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. ```ts 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 ``` ```ts function navigate(url: string | URL, options?: { replace?: boolean; state?: unknown }): void ``` ## isLocalNavigationEvent Returns `true` when a click event on an `` 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. ```ts 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); } }); ``` ```ts function isLocalNavigationEvent(e: MouseEvent): boolean ``` ## matches Returns `Computed` — `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`](https://www.npmjs.com/package/urlpattern-polyfill) for Safari \< 26 and Firefox \< 142. ```ts import { matches } from "elements-kit/utilities/routing"; const isHome = matches({ pathname: "/" }); effect(() => { if (isHome()) showHomeNav(); }); ``` **Conditional rendering with React:** ```tsx import { useSignal } from "elements-kit/integrations/react"; import { matches } from "elements-kit/utilities/routing"; const isSettings = matches({ pathname: "/settings" }); function App() { return useSignal(isSettings) ? : ; } ``` ```ts function matches(input: URLPatternInput, options?: URLPatternOptions): Computed ``` ## match Returns `Computed` — 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`](https://www.npmjs.com/package/urlpattern-polyfill) for Safari \< 26 and Firefox \< 142. ```ts 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:** ```tsx 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 ? : ; } ``` ```ts function match(input: URLPatternInput, options?: URLPatternOptions): Computed ``` *** ## See also * [Routing example](/examples/routing) * [Context](/primitives/context) --- # Context Source: https://elements-kit.com/primitives/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). ```tsx 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(
    {children}
    , 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: ```tsx function ThemeProvider({ children }: { children: JSX.Element }) { const theme = signal<"light" | "dark">("dark"); return (
    setContext(el, THEME, theme)}> {children}
    ); } ``` ```ts function setContext(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 [``](/components/dom-lifecycle/): ```tsx 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 ( ); } ``` For one-off reads, call `getContext` directly from an event handler — the tree is always connected by the time the handler fires: ```tsx function ThemedButton() { return ( ); } ``` ```ts function getContext(consumer: Element, key: PropertyKey): T | undefined ``` ## Consuming context from a component For a component body that needs `getContext` after connection, defer with [``](/components/dom-lifecycle/) — it's render-inert (zero layout box, no a11y role) and its `onConnect` fires once the consumer is in the tree: ```tsx 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 ( theme(getContext<"light" | "dark">(el, THEME))} > {() => theme() ?? "light"} ); } ``` 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: ```tsx {/* outer theme */} {/* inner theme */} ``` ## Static values Reactivity is opt-in — plain values work the same way: ```tsx const CONFIG = Symbol("config"); function ConfigProvider({ children }: { children: JSX.Element }) { return setContext(
    {children}
    , 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: ```ts 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; } } ``` *** ## See also * [Context example](/examples/context) * [Scopes](/scopes) --- # DOM events Source: https://elements-kit.com/primitives/events Listeners that remove themselves when their scope ends, and signals for common interaction state. ## 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. ```ts 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 ``` ```ts function on( target: EventTarget | Computed, type: string, handler: EventListener, options?: AddEventListenerOptions, ): () => void ``` ## createHover Returns `Computed` — `true` while pointer is over `target`. ```ts import { createHover } from "elements-kit/utilities/hover"; const hovered = createHover(document.querySelector("#btn")!); effect(() => console.log("hovered:", hovered())); ``` ```ts function createHover(target: Element): Computed ``` ## createFocusWithin Returns `Computed` — `true` while focus is anywhere inside `target` (including `target` itself). ```ts import { createFocusWithin } from "elements-kit/utilities/focus-within"; const focused = createFocusWithin(document.querySelector("form")!); ``` ```ts function createFocusWithin(target: Element): Computed ``` ## onClickOutside Fires `handler` whenever a pointer-down event occurs outside `target`. Returns a cleanup function. ```ts import { onClickOutside } from "elements-kit/utilities/on-click-outside"; const cleanup = onClickOutside(menuEl, () => closeMenu()); ``` ```ts 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). ```ts import { createLongPress } from "elements-kit/utilities/long-press"; const cleanup = createLongPress(el, (e) => openContextMenu(e), { delay: 600 }); ``` ```ts function createLongPress( target: Element, handler: (e: PointerEvent) => void, options?: { delay?: number }, ): () => void ``` ## activeElement Module-level singleton. `Computed` bound to `document.activeElement`. ```ts import { activeElement } from "elements-kit/utilities/active-element"; effect(() => console.log("focused:", activeElement())); ``` *** ## See also * [Observers](/primitives/observers) * [Building primitives](/primitives/building-primitives) --- # Observers Source: https://elements-kit.com/primitives/observers Signals and scoped wrappers over `ResizeObserver`, `IntersectionObserver` and `MutationObserver`. ## createElementRect Observes `target`'s viewport box and returns it as four reactive fields — `x`, `y`, `width`, `height`. Size comes from a `ResizeObserver`'s border box (a transform doesn't shrink it); position from capture-phase `scroll` and window `resize`, since a `ResizeObserver` stays silent when an element merely moves. Each field notifies only when it changes: a scroll moves `y`, so an effect reading `width` alone doesn't re-run. It tracks only while an effect or computed reads a field. The observer and listeners start with the first reader and stop with the last, so a rect nobody reads — an anchor whose overlay is closed — costs nothing on scroll. A returning reader is measured on the spot. A read outside any effect measures right then. `target` may be a getter; the rect follows it. ```ts import { createElementRect } from "elements-kit/utilities/element-rect"; const rect = createElementRect(document.querySelector("#box")!); effect(() => console.log(rect.width(), rect.height())); ``` ```ts function createElementRect(target: MaybeReactive): ElementRect interface ElementRect extends Disposable { x: Computed; y: Computed; width: Computed; height: Computed; } ``` ## createElementScroll Returns writable `x` / `y` signals for an element's scroll position. Reading returns `scrollLeft` / `scrollTop`; writing scrolls the element. ```ts 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 ``` ```ts function createElementScroll(target: Element): { x: Signal; y: Signal } & Disposable ``` ## createResizeObserver Raw `ResizeObserver` wrapper with automatic cleanup via `onCleanup`. Use `elementRect` for the common case. ```ts import { createResizeObserver } from "elements-kit/utilities/resize-observer"; createResizeObserver(el, (entries) => { for (const entry of entries) console.log(entry.contentRect); }); ``` ```ts function createResizeObserver(target: Element, callback: ResizeObserverCallback): Disposable ``` ## createIntersectionObserver Raw `IntersectionObserver` wrapper with automatic cleanup. ```ts import { createIntersectionObserver } from "elements-kit/utilities/intersection-observer"; createIntersectionObserver(el, ([entry]) => { console.log("visible:", entry.isIntersecting); }, { threshold: 0.5 }); ``` > **Root defaults to the viewport** > > Without `root`, the platform observes intersections with the viewport — not with `target`'s nearest scrollable ancestor. For a sentinel inside an in-page scroll container, pass `root: containerEl` explicitly. Otherwise the sentinel reads as "intersecting" the viewport and fires immediately and repeatedly. > > ```ts > createIntersectionObserver( > sentinelEl, > ([entry]) => { if (entry.isIntersecting) loadMore.run(); }, > { root: containerEl, rootMargin: "100px" }, > ); > ``` ```ts function createIntersectionObserver( target: Element, callback: IntersectionObserverCallback, options?: IntersectionObserverInit, ): Disposable ``` ## createMutationObserver Watches `target` for DOM mutations with automatic cleanup. ```ts import { createMutationObserver } from "elements-kit/utilities/mutation-observer"; createMutationObserver(el, { childList: true }, (records) => { console.log("mutations:", records.length); }); ``` ```ts function createMutationObserver( target: Element, options: MutationObserverInit, callback: (records: MutationRecord[]) => void, ): Disposable ``` *** ## See also * [Infinite scroll example](/examples/infinite-scroll) * [DOM events](/primitives/events) --- # Browser state Source: https://elements-kit.com/primitives/browser-state Signals over page and device state. Singletons (`windowSize`, `online`, `orientation`) are shared app-wide; factories (`createMediaQuery`, `createMediaDevices`) clean up with their scope. ## createMediaQuery Creates a `Computed` that tracks a [CSS media query](https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia). Returns `true` when the query matches, `false` otherwise. ```ts import { createMediaQuery } from "elements-kit/utilities/media-query"; const isDark = createMediaQuery("(prefers-color-scheme: dark)"); isDark(); // true or false — live, updates on OS change ``` ### Signature ```ts function createMediaQuery( query: string, defaultState?: boolean, ): Computed ``` | Parameter | Description | | -------------- | ------------------------------------------------------------------------------- | | `query` | Any valid CSS media query string | | `defaultState` | Value returned during SSR (where `window` is unavailable). Defaults to `false`. | ### Examples **Dark mode toggle** ```ts 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** ```ts 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: ```ts // 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: ```ts // 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) ``` *** ## windowSize Module-level singleton. Reactive `innerWidth` and `innerHeight` of the browser window. ```ts 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. ```ts import { orientation } from "elements-kit/utilities/orientation"; effect(() => console.log(orientation.type(), orientation.angle())); ``` *** ## online Module-level singleton. `Computed` — `true` when `navigator.onLine` is `true`. Reacts to `online` / `offline` window events. ```ts import { online } from "elements-kit/utilities/network"; effect(() => { if (!online()) showOfflineBanner(); }); ``` *** ## createMediaDevices Returns a reactive list of available media devices, refreshed when devices are added or removed. ```ts import { createMediaDevices } from "elements-kit/utilities/media-devices"; const devices = createMediaDevices(); effect(() => console.log(devices().map((d) => d.label))); ``` ```ts function createMediaDevices(): Computed ``` *** ## See also * [DOM events](/primitives/events) * [Observers](/primitives/observers) * [Server rendering](/server-rendering) --- # Media Source: https://elements-kit.com/primitives/media Drive and observe an `