This is the full developer documentation for ElementsKit # Introduction > Universal reactive primitives — signals, utilities, JSX, custom elements. import { Card, CardGrid, LinkCard, Tabs, TabItem } from "@astrojs/starlight/components"; ElementsKit is a toolkit of reactive primitives — signals, JSX, custom elements, and browser-API helpers. Import one at a time, compose them, or use any of them inside a UI framework (React, etc.). - **Compose, don't configure.** Small focused APIs — `signal`, `computed`, `on`, `fromEvent`, `async`. Combine primitives instead of maintaining an overloaded interface. - **Close to the platform.** JSX compiles to `document.createElement`. `promise` extends `Promise`. Custom elements *are* `HTMLElement`. Thin or absent abstraction layers — no virtual DOM, no proxies, no build steps. - **Predictable and explicit — no magic.** `signal/compose` are reactive; nothing else is. No heuristic dependency tracking, no hidden subscriptions. - **Designed for the AI age.** Code is cheap; maintenance still isn't. Primitives compose into higher-level blocks. Swap one block at a time instead of maintaining long lines of code. - **Bundler-friendly.** Every primitive is its own subpath — `elements-kit/signals`, `elements-kit/utilities/media-query`, `elements-kit/integrations/react`. Import only what you need. Fine-grained reactive state. `signal`, `computed`, `effect`, and `@reactive` class fields — plain classes, no proxies. Shared across custom elements, React components, and plain scripts, all in sync. Browser-API signals — `online`, `windowSize`, `createMediaQuery`, `on`, `fromEvent`, observers, and more. Reactive wrappers, pay-per-import. `async`, `promise`, `retry`, `createLocalStorage`. Compose queries, mutations, persistence, and revalidation — no query library required. JSX compiles to real `document.createElement` calls. Reactive props become live DOM bindings — no diffing, no reconciliation, no runtime overhead. Native `HTMLElement` subclasses enhanced with signals, JSX, and decorators. Usable in any HTML context — React, Vue, or plain HTML — no adapters needed. Bridge any signal into a UI framework via `useSignal` / `useScope`. React today, Svelte and Vue planned. ## 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). ```tsx twoslash // @noErrors 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 twoslash // @noErrors import { createMediaQuery } from "elements-kit/utilities/media-query"; import { render } from "elements-kit/render"; const isDark = createMediaQuery("(prefers-color-scheme: dark)"); const isMobile = createMediaQuery("(max-width: 640px)"); render(document.getElementById("app")!, () => ( )); ``` ```tsx twoslash // @noErrors 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)}
)); ```
```tsx twoslash // @noErrors 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 twoslash // @noErrors /** @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}

{" "}
); } ```
See the full progression — signals → element → function component → class → custom element — in the [Quick start](/getting-started/quick-start). :::note[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 ## Explore # Async > Reactive, awaitable async controllers — start, stop, rerun, and read state as signals. import Playground from "@/playground/Playground.astro"; import ASYNC_FLOW from "@/playground/files/async-flow.tsx?raw"; import ASYNC_TESTS from "@/playground/files/async.test.ts?raw"; 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 twoslash // @noErrors 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. ```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 ``` ### 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. :::caution[`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 twoslash // @noErrors 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 ``` :::caution[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](/promise) — the underlying `ComputedPromise` / `ReactivePromise` primitive. - [Data fetching](/examples/data-fetching) — full recipe composing retry, online, window-focus. - [Signals](/signals) — `onCleanup`, `untracked`. # Components > Class components — reactive state and element rendering in one place. import Playground from "@/playground/Playground.astro"; import COMPONENTS_FILE from "@/playground/files/components.tsx?raw"; A **component** is a class with a `render()` method that returns an `Element`. It combines a [store](/stores) (reactive state) with element construction (JSX). The simplest possible component: ## Write a component that owns its state A typical component owns its state and produces elements from it. `@reactive` turns class fields into signals; JSX reads them as live bindings. ```tsx magic-move // State only — a plain signal import { signal } from "elements-kit/signals"; const count = signal(0); --- // Add a derived value import { signal, computed } from "elements-kit/signals"; const count = signal(0); const doubled = computed(() => count() * 2); --- // Use accessor for natural property access (no () on reads) import { signal, computed } from "elements-kit/signals"; class CounterStore { #count = signal(0); get count() { return this.#count() } set count(value: number) { this.#count(value) } doubled = computed(() => this.count * 2); } --- // Full component — state, derived values, and actions import { signal, computed } from "elements-kit/signals"; class Counter { #count = signal(0); get count() { return this.#count() } set count(value: number) { this.#count(value) } doubled = computed(() => this.count * 2); render() { return (

Count: {() => this.count} — Doubled: {this.doubled}

); } } --- // Use @reactive for natural property access — same ergonomics, less boilerplate import { reactive, computed } from "elements-kit/signals"; import { render } from "elements-kit/render"; class Counter { @reactive() count = 0; doubled = computed(() => this.count * 2); render() { return (

Count: {() => this.count} — Doubled: {this.doubled}

); } } const unmount = render(document.getElementById("app")!, () => ); ``` ## Share state across components When state needs to be **shared** across components, move it into a standalone [store](/stores) — a class with `@reactive` fields and no `render()`. Components read from the store; the store holds no reference to components. ```ts twoslash // @noErrors // counter-store.ts — state only import { reactive, computed } from "elements-kit/signals"; export class CounterStore { @reactive() count = 0; doubled = computed(() => this.count * 2); increment() { this.count++; } reset() { this.count = 0; } } export const counter = new CounterStore(); ``` ```tsx // Two components, one store class CounterDisplay { render() { return (

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

); } } class CounterControls { render() { return (
); } } ``` The same `counter` instance can also drive a React component or a custom element — see [Stores](/stores) and [React integration](/integrations/react). ## Rendering lists For keyed list rendering, use the `For` component — it reconciles a reactive array into the DOM without re-rendering stable rows. See [`For`](/elements/for) for the full API. ## Function components A function component is a plain function that returns an `Element`. Props arrive **exactly as the caller wrote them** — the runtime transforms nothing. A prop passed as a signal arrives as that signal; a prop passed as a value arrives as that value. That makes the declared type the contract on both sides. Declare plain types when a component takes static props only — callers then cannot pass a signal: ```tsx twoslash // @noErrors function Greeting(props: { name: string }) { return

Hello, {props.name}

; } ``` Declare [`Props

`](/elements/types) to accept either form on every key. Hand a prop straight to JSX, which subscribes when it is reactive, or read it with `resolve`: ```tsx twoslash // @noErrors import { resolve } from "elements-kit/signals"; import type { Props } from "elements-kit/jsx-runtime"; function Greeting( props: Props<{ name: string; excited?: boolean }>, ) { return (

Hello, {props.name} {() => (resolve(props.excited) ? "!" : ".")}

); } ``` `Props

` is an alias for [`MaybeReactiveProps

`](/elements/types) — each key is `T | Computed`. Use `MaybeReactive` on individual keys when only some props should accept a signal. Because nothing is wrapped, an omitted optional prop is plain `undefined`, so `??` defaults work as written: ```tsx const placeholder = resolve(props.placeholder) ?? "Ask anything…"; ``` ## Opt into getter props When a body would rather read one uniform shape than branch on which form arrived, convert the props with `computedProps`. Every key becomes a getter — including keys the caller omitted — so reads never need `resolve`: ```tsx twoslash // @noErrors import { computedProps } from "elements-kit/signals"; import type { Props } from "elements-kit/jsx-runtime"; function Chat(raw: Props<{ placeholder?: string; layout?: string }>) { const props = computedProps(raw); return ( ); } ``` Note where the default sits. A getter is always truthy, so `props.placeholder ?? "…"` would never fall back — inside a bag the default goes **on the call**: `props.placeholder() ?? "…"`. Every key is a reactive source, whether the caller passed a signal or a plain value. That means a getter keeps working when you forward it to a child component — the child can call it, `resolve` it, or convert its own props with `computedProps` without wrapping it twice. ### Function props `computedProps` infers its shape from the argument, and a callable cannot be told apart from a getter — `Computed` *is* `() => T`. So a prop that takes arguments (a render prop, a handler with parameters) is rejected rather than mistyped: ```tsx // ✗ computedProps: a prop that takes arguments cannot be inferred here const props = computedProps({ render: (item: string) => item.length }); ``` Read those off the raw props instead. A **zero-arg** function prop cannot be rejected — `Signal` and `Computed` are zero-arg callables themselves, so banning them would ban reactive props. It types as its return value while the runtime hands back the function, so read those raw too. Reach for `computedProps` when a component has several props it reads repeatedly. For one or two props, raw reads and `resolve` are less machinery. ## See also - [Elements](/elements) — JSX → DOM, prop namespaces. - [For](/elements/for) — keyed list rendering. - [Stores](/stores) — shared reactive state. - [Custom elements](/custom-elements) — components as native HTML tags. # dom-lifecycle > Drop-in custom element for connect / disconnect / move / adopted notifications on a surrounding subtree. 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. ## Usage ```tsx import "elements-kit/utilities/dom-lifecycle"; function FocusOnMount() { return (

(el.parentElement as HTMLElement | null)?.focus()} />
); } ``` Wrap children — read the wrapped subtree through `self.firstElementChild`: ```tsx
measure(el.firstElementChild)}>

Title

Body

``` ## 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. ```tsx 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(undefined); return ( theme(getContext(el, THEME))}>

theme() ?? "default"}>Title

theme() ?? "default"}>Body

); } ``` 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 `` 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 `` per child or use `createMutationObserver` on `el` inside `onConnect`: ```tsx import { createMutationObserver } from "elements-kit/utilities/mutation-observer"; { createMutationObserver(el, { childList: true }, (records) => { for (const r of records) { // r.addedNodes, r.removedNodes } }); }} > {(item) =>
  • {item}
  • }
    ``` ## Callbacks | Callback | Mirrors | Argument | Fires on | |----------|---------|----------|----------| | `onConnect` | `connectedCallback` | `self: DomLifecycleElement` | every connection — `self.parentElement` is the surrounding element, `self.firstElementChild` is the wrapped content | | `onDisconnect` | `disconnectedCallback` | `self: DomLifecycleElement` | every disconnection — `self.parentElement` is `null` per spec; capture the parent inside `onConnect` if you need it on disconnect | | `onMove` | `connectedMoveCallback` | `self: DomLifecycleElement` | move via `Node.moveBefore()` (in browsers without that API, the disconnect+connect pair fires instead) | | `onAdopted` | `adoptedCallback` | `(oldDocument, newDocument)` | when the element is adopted into a new document | ```tsx
    { 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 }} />
    ``` `onConnect` / `onDisconnect` re-fire on every (re)connection (item moves in ``, 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. ```ts 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. # Custom Elements > Native HTMLElement authoring, enhanced gradually with signals, JSX, and decorators. import Playground from "@/playground/Playground.astro"; import PLAYGROUND_FILE from "@/playground/files/custom-elements.tsx?raw"; Custom elements are a native browser standard — a class that extends `HTMLElement` and registers under a hyphenated tag name. Once defined, they behave like built-in elements: usable in HTML, React, Vue, or any other context without adapters. ElementsKit enhances custom elements authoring with signals, JSX, and decorators — but these are optional. You can use the native API alone, then add features gradually as needed. --- ## The native API No dependencies. The lifecycle is three callbacks: | Callback | When it fires | |----------|--------------| | `connectedCallback` | Element attached to the DOM | | `disconnectedCallback` | Element removed from the DOM | | `attributeChangedCallback` | A listed attribute changes | ```ts class GreetingElement extends HTMLElement { connectedCallback() { this.textContent = `Hello, ${this.getAttribute("name") ?? "world"}!`; } } customElements.define("x-greeting", GreetingElement); ``` ```html ``` --- ## Adopt ElementsKit progressively ```tsx magic-move // Step 1 — bare custom element, native API only class CounterElement extends HTMLElement { #count = 0; connectedCallback() { this.innerHTML = `

    Count: 0

    `; this.querySelector("button")!.addEventListener("click", () => { this.#count++; this.querySelector("#val")!.textContent = String(this.#count); }); } } customElements.define("x-counter", CounterElement); --- // Step 2 — signal + render: reactive state with scoped cleanup import { signal, effect } from "elements-kit/signals"; import { render } from "elements-kit/render"; class CounterElement extends HTMLElement { #count = signal(0); #unmount?: () => void; #template = () => { const root = document.createElement("section"); root.innerHTML = `

    Count:

    `; effect(() => { root.querySelector("#val")!.textContent = String(this.#count()); }); root.querySelector("button")!.addEventListener("click", () => { this.#count(this.#count() + 1); }); return root; }; connectedCallback() { this.#unmount = render(this, this.#template); } disconnectedCallback() { this.#unmount?.(); this.#unmount = undefined; } } customElements.define("x-counter", CounterElement); --- // Step 3 — JSX: declarative DOM, live bindings replace manual effects import { signal } from "elements-kit/signals"; import { render } from "elements-kit/render"; class CounterElement extends HTMLElement { #count = signal(0); #unmount?: () => void; #template = () => (

    Count: {this.#count}

    ); connectedCallback() { this.#unmount = render(this, this.#template); } disconnectedCallback() { this.#unmount?.(); this.#unmount = undefined; } } customElements.define("x-counter", CounterElement); --- // Step 4 — @reactive: natural property access + computed import { computed, reactive } from "elements-kit/signals"; import { render } from "elements-kit/render"; class CounterElement extends HTMLElement { @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); --- // Step 5 — @attributes: HTML attributes ↔ reactive properties import { computed, reactive } 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] = { count(this: CounterElement, value: string | null) { this.count = Number(value ?? 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); // or --- // Step 6 — defineElement + typed JSX: strict registration, typed props import { computed, reactive } from "elements-kit/signals"; import { attributes, ATTRIBUTES as attr } from "elements-kit/attributes"; import { render } from "elements-kit/render"; import { defineElement } from "elements-kit/custom-elements"; @attributes class CounterElement extends HTMLElement { static [attr] = { count(this: CounterElement, value: string | null) { this.count = Number(value ?? 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; } } defineElement("x-counter", CounterElement); declare global { namespace ElementsKit { interface CustomElementRegistry { "x-counter": typeof CounterElement; } } } // — typed props, typed ref ``` | Step | ElementsKit | What you gain | |------|-------------|---------------| | 1 | — (plain browser API) | Zero deps, runs anywhere | | 2 | `signals` + `render` | Reactive state, scoped cleanup via a single `unmount` thunk | | 3 | JSX runtime | Declarative DOM, live text and attribute bindings replace manual effects | | 4 | `@reactive` decorator | Natural class-field syntax, derived values with `computed` | | 5 | `@attributes` | HTML attribute ↔ reactive property wiring | | 6 | `defineElement` | Typed JSX via `CustomElementRegistry` augmentation | --- ## Cleanup Unlike JSX elements, a custom element is **not** wrapped in an `effectScope` automatically. Effects and timers started in `connectedCallback` leak unless you tie them to a scope you dispose in `disconnectedCallback`. Use `render` from `elements-kit/render` — it mounts a JSX tree and returns a single `unmount` thunk that tears down both the DOM and every effect registered inside: ```tsx import { signal, onCleanup } from "elements-kit/signals"; import { render } from "elements-kit/render"; class ClockElement extends HTMLElement { #time = signal(new Date()); #unmount?: () => void; #template = () => { const id = setInterval(() => this.#time(new Date()), 1000); onCleanup(() => clearInterval(id)); return ; } connectedCallback() { this.#unmount = render(this, this.#template); } disconnectedCallback() { this.#unmount?.(); this.#unmount = undefined; } } ``` :::caution[Create effects inside the scope] Create `effect` and `onCleanup` **inside** the `render` callback — not in the constructor, in a field initializer, or in `attributeChangedCallback`. Effects created outside aren't owned by any scope and won't stop on disconnect, so they keep running (and holding the element) after the element leaves the DOM. `computed` is lazy and self-disposes when it has no subscribers, so a class-field `computed` is fine. ::: `render` works the same way at the app root too — pass `document.getElementById("app")!` as the target. See [Scopes & cleanup](/scopes/) for the full lifetime contract. --- ## Constructor vs `connectedCallback` The constructor should only call `super()` and initialize private fields. Defer DOM mutations — `this.style.*`, `this.setAttribute(...)`, `this.append(...)`, child rendering — to `connectedCallback`. The spec permits constructor mutations in theory, but Sandpack and some sandbox / iframe environments throw `NotSupportedError` when an element mutates itself during construction. Moving the work to `connectedCallback` is portable and keeps the element upgrade-safe (the constructor runs once; `connectedCallback` runs on every (re)connection). ```ts // Wrong — fails in Sandpack and some iframe sandboxes class MyElement extends HTMLElement { constructor() { super(); this.style.display = "contents"; this.setAttribute("role", "none"); } } // Right — defer to connectedCallback class MyElement extends HTMLElement { #count = 0; // private fields only connectedCallback() { this.style.display = "contents"; if (!this.hasAttribute("role")) this.setAttribute("role", "none"); } } ``` :::caution[`NotSupportedError` from constructor mutations] Touching `style`, attributes, or child nodes inside the constructor throws in some sandboxed environments even though the spec allows it. Move all DOM work to `connectedCallback`. ::: --- ## Typing JSX for a custom element ElementsKit sets `jsxImportSource: "elements-kit"`, so TypeScript pulls the `JSX` namespace from the runtime's own module. **Global `JSX` augmentations don't merge with that namespace** and have no effect — typed props on `` come from a different surface. Augment `ElementsKit.CustomElementRegistry` in the global namespace instead. The JSX runtime reads tag names from that interface to type props and refs. ```ts import { defineElement } from "elements-kit/custom-elements"; class XCounter extends HTMLElement {} defineElement("x-counter", XCounter); declare global { namespace ElementsKit { interface CustomElementRegistry { "x-counter": typeof XCounter; } } } // — typed props, typed ref ``` The same pattern applies when registering with `customElements.define` directly — augment `ElementsKit.CustomElementRegistry` regardless of which registration call you use. --- ## Using your element outside elements-kit A custom element is a platform object — React, Svelte, Vue, Angular, or vanilla DOM can all consume it with zero elements-kit involvement. Three raw type helpers describe its surfaces, derived straight from the class: ```ts import type { PropertiesOf, AttributesOf, EventsOf, } from "elements-kit/custom-elements"; type P = PropertiesOf; // { min?: number; value?: number } type A = AttributesOf; // { min?: string | null; variant?: string | null } type E = EventsOf; // { commit: CustomEvent } ``` | Helper | Shape | Consume via | |---|---|---| | `PropertiesOf` | Public instance fields, `HTMLElement` surface dropped | property assignment / framework prop binding | | `AttributesOf` | `static [ATTRIBUTES]` keys → `string \| null` | `setAttribute`, HTML markup | | `EventsOf` | `static events` map, verbatim | `addEventListener`, framework event bindings | Typed listeners fall out of an `HTMLElementEventMap` augmentation: ```ts declare global { interface HTMLElementEventMap extends EventsOf {} } el.addEventListener("commit", (e) => e.detail); // e: CustomEvent ``` Compose them however your host needs — see the [framework integration guides](/integrations/react) for per-framework augmentations. ### Slots **Inside elements-kit JSX, slots are just properties — no decorator needed.** This holds for both plain class components and custom elements: elements-kit JSX assigns each prop to the instance, and your `render()` places it. ```tsx class Card extends HTMLElement { header!: Children; children!: Children; render() { return (
    {this.header}
    {this.children}
    ); } } // Title}>body content… ``` `applyProps` sets `this.header =

    Title

    ` and `{this.header}` places it. Make the field `@reactive` and read it as `{() => this.header}` if you want a *reassignment* to update live. #### `@slot()` — filling slots from plain, imperative DOM Everything above relies on elements-kit JSX doing the property assignment and your `render()` placing the value. Reach for `@slot()` when the element's slots are filled by **imperative DOM code** rather than elements-kit JSX — a vanilla script, a `document.createElement` builder, or any place you hold a DOM node and want it to appear (and later be swapped) inside the mounted element. `@slot()` turns the property into a **live comment-marker region**: assigning a node replaces the slot's content directly in the DOM, with no elements-kit reactive context. ```ts import { slot, type SlotContent } from "elements-kit/slot"; // Authored with plain DOM — no elements-kit rendering. Reading a @slot() // property returns its live region; `append()` places it and mounts the markers. class XCard extends HTMLElement { @slot() header!: SlotContent; connectedCallback() { const article = document.createElement("article"); article.append(this.header); this.replaceChildren(article); } } // vanilla consumer — no elements-kit rendering, no framework: const el = document.querySelector("x-card")!; el.header = document.createElement("h1"); // fills the slot, in the live DOM el.header = "plain text"; // native append() content el.header = "updated"; // replaces the previous content in place el.header = null; // clears it ``` - **Reading places the region.** The getter yields the slot's comment-marker fragment — append it wherever the content should live. Any renderer that can append a fragment works. Reads are for *placement*, not inspection (a later read extracts the current content — the re-render semantic). - **Assigning fills it.** Native `append()` content — a `Node`, a string, or an array; `null` clears. The swap happens directly in the DOM, so it works with **no effect, scope, or reactive context** — that's the whole reason `@slot()` exists. Assignments before the region is placed are buffered and flush on mount. Other JSX frameworks (React, Vue, Svelte) each have their *own* children model and don't pass DOM nodes as props, so they rarely set slots this way directly. When they need to, they do it through the same imperative escape hatch — a `ref` (or equivalent) to the element, then `el.header = node`. `@slot()` is what makes that assignment update the live DOM. Rule of thumb: **all-elements-kit → plain (or `@reactive`) fields; slots driven from another framework → `@slot()`.** `PropertiesOf` types the key by its declared field type either way. --- ## When NOT to use custom elements Custom elements are the right tool for **reusable, framework-agnostic UI**. They're the wrong tool when: - **The UI is a one-off.** A class component or inline JSX has lower overhead — no registration, no attribute wiring. - **You need SSR.** Custom elements are client-only. - **Parent-to-child data is complex.** Attributes are strings; properties work but lose the HTML-first contract. Complex data bridges best via stores. - **Shadow DOM style isolation is a hard requirement and you're not ready for it.** Start with light DOM; add shadow only when style collisions actually bite. ## Go deeper | Topic | What it covers | |-------|---------------| | [Attributes](/custom-elements/attributes) | Attributes vs properties, `@attributes` decorator, inheritance | | [Styling](/custom-elements/styling) | `CSSStyleSheet`, `adoptedStyleSheets`, `?raw` imports | | [Slots](/custom-elements/slots) | Native `` (Shadow DOM) and ElementsKit `Slot` (Light DOM) | --- ## Playground ## See also - [Attributes](/custom-elements/attributes) - [Styling](/custom-elements/styling) - [Slots](/custom-elements/slots) - [Signals](/signals) - [Elements](/elements) # Attributes > Attributes vs properties — and how @attributes wires them to reactive state. import Playground from "@/playground/Playground.astro"; import PLAYGROUND_FILE from "@/playground/files/attributes.tsx?raw"; HTML elements have two distinct ways to receive data: **attributes** and **properties**. Understanding the difference is essential before using `@attributes` — which bridges the two for custom elements. ## Attributes vs properties **Attributes** live in the HTML markup. They are always strings (or absent). The browser parses them from the HTML source and exposes them via `setAttribute` / `getAttribute`. **Properties** are JavaScript object members. They can be any type — numbers, booleans, arrays, objects. ```html ``` ```ts const input = document.querySelector("input")!; // Attribute API — always strings input.getAttribute("value"); // "hello" input.setAttribute("value", "bye"); // sets the HTML attribute // Property API — typed JavaScript values input.value; // "hello" (synced from attribute initially) input.value = "bye"; // sets the JS property directly input.disabled; // true (boolean, not "disabled") ``` For many built-in elements, attributes and properties start in sync, then diverge: ```ts const input = document.createElement("input"); input.setAttribute("value", "initial"); input.value; // "initial" — synced on creation input.value = "typed"; // user types or JS sets property input.getAttribute("value"); // still "initial" — attribute unchanged ``` The `value` attribute is the **initial/default value**. The `value` property is the **current live value**. Changing one does not automatically change the other (for most built-ins). Here's a quick comparison: | | Attribute | Property | |---|---|---| | Location | HTML markup / `data-*` | JavaScript object | | Type | Always `string \| null` | Any — `number`, `boolean`, `object`… | | API | `getAttribute` / `setAttribute` | Direct assignment | | Observed changes | `attributeChangedCallback` | Getter/setter | | Serialisable to HTML | Yes | Not automatically | | Available before JS runs | Yes | No | --- ## Custom element attributes For built-in elements the browser manages the attribute↔property relationship. For **custom elements** you manage it yourself via `observedAttributes` and `attributeChangedCallback`. ```ts class CounterElement extends HTMLElement { // Must declare which attributes to observe static observedAttributes = ["count", "step"]; // Called whenever a listed attribute changes attributeChangedCallback(name: string, _old: string | null, next: string | null) { if (name === "count") this.#count = Number(next ?? 0); if (name === "step") this.#step = Number(next ?? 1); this.#render(); } #count = 0; #step = 1; #render() { // ... } } ``` This is repetitive. Every attribute needs a manual type conversion, a branch in `attributeChangedCallback`, and a `#render()` call. The `@attributes` decorator automates all of it. --- ## `@attributes` decorator `@attributes` reads a static `[ATTRIBUTES]` map on the class and wires up `observedAttributes` and `attributeChangedCallback` automatically. Each key in the map is an observed attribute name; the value is a handler called with `this` bound to the element instance. ```ts twoslash // @noErrors import { attributes, ATTRIBUTES as attr } from "elements-kit/attributes"; import { reactive } from "elements-kit/signals"; @attributes class CounterElement extends HTMLElement { static [attr] = { // Called whenever the HTML "count" attribute changes count(this: CounterElement, value: string | null) { this.count = Number(value ?? 0); // string → number, write to reactive property }, step(this: CounterElement, value: string | null) { this.step = Number(value ?? 1); }, }; @reactive() count = 0; @reactive() step = 1; } customElements.define("x-counter", CounterElement); ``` The handler is your type conversion layer — `value` is always `string | null` (the raw HTML attribute), and you decide what to do with it. ### What `@attributes` generates ```ts // Before @attributes — manual boilerplate: class CounterElement extends HTMLElement { static observedAttributes = ["count", "step"]; attributeChangedCallback(name, _old, next) { if (name === "count") /* handler */; if (name === "step") /* handler */; } } // After @attributes — generated automatically: // static observedAttributes = ["count", "step"] ← from [ATTRIBUTES] keys // attributeChangedCallback(...) ← dispatches to handlers ``` --- ## Inheriting attributes `@attributes` walks the **prototype chain** — subclasses inherit parent attribute handlers automatically: ```ts @attributes class BaseInput extends HTMLElement { static [attr] = { disabled(this: BaseInput, value: string | null) { this.disabled = value !== null; }, name(this: BaseInput, value: string | null) { this.name = value ?? ""; }, }; @reactive() disabled = false; @reactive() name = ""; } @attributes class TextInput extends BaseInput { static [attr] = { // Adds "value" on top of inherited "disabled" + "name" value(this: TextInput, value: string | null) { this.value = value ?? ""; }, }; @reactive() value = ""; } // TextInput.observedAttributes = ["disabled", "name", "value"] ``` --- ## See also - [Custom elements](/custom-elements) - [Signals](/signals) # Slots > Pass content into components — native slots with Shadow DOM, and ElementsKit Slots without it. import Playground from "@/playground/Playground.astro"; import PLAYGROUND_FILE from "@/playground/files/slots.tsx?raw"; Slots let consumers inject content into a component's layout. ElementsKit supports two approaches: **native ``** (browser-managed, Shadow DOM only) and **`Slot`** (ElementsKit-managed, works with or without Shadow DOM). ## Native slots — Shadow DOM When an element uses a shadow root, the browser projects slotted children into named `` placeholders automatically. The children stay in the light DOM — they are only visually projected. ```tsx class CardElement extends HTMLElement { connectedCallback() { const shadow = this.attachShadow({ mode: "open" }); // Three slots: named "header", unnamed default, named "footer" shadow.innerHTML = `
    Untitled
    `; } } customElements.define("x-card", CardElement); ``` Consumer HTML: ```html

    My Card

    This goes in the default slot.

    ``` Consumer JSX (ElementsKit): ```tsx

    My Card

    This goes in the default slot.

    ``` The standard `slot` HTML attribute routes each child into the matching named slot. The browser handles projection with no extra JavaScript. ### Shadow DOM slot with JSX template Use JSX instead of `innerHTML` to build the shadow tree — the `` elements work the same: ```tsx import { attributes, ATTRIBUTES as attr } from "elements-kit/attributes"; import { render } from "elements-kit/render"; @attributes class CardElement extends HTMLElement { static [attr] = { title(this: CardElement, value: string | null) { this.title = value ?? ""; }, }; #unmount?: () => void; #template = () => (
    {/* Named slot — consumer fills with slot="header" */}
    {/* Default slot — consumer children with no slot attribute */}
    ); connectedCallback() { const shadow = this.attachShadow({ mode: "open" }); shadow.adoptedStyleSheets = [cardSheet]; this.#unmount = render(shadow, this.#template); } disconnectedCallback() { this.#unmount?.(); this.#unmount = undefined; } } ``` --- ## ElementsKit `Slot` — Light DOM Without Shadow DOM, the browser does not project children. ElementsKit's `Slot` primitive fills this gap: a pair of **comment markers** that reserve a region in the DOM. Content between them can be replaced reactively, with no wrapper element. ```tsx import { Slot } from "elements-kit/slot"; const slot = new Slot(); // Mounts the comment markers + optional default content const section =
    {slot.get("Loading…")}
    ; // Later — replace content in place (native `append()` content: Node/string/array) slot.set(

    Content loaded!

    ); slot.isMounted(); // true slot.parent(); // the
    element ``` ### When do I need a `Slot`? The rule is about **who fills the slot**, not the component kind. Anything consumed through elements-kit JSX needs nothing special. | Filled by | Slot needed? | How | |---|---|---| | **elements-kit JSX** (function, class, or custom element) | No | Slots are plain properties — pass them as props, place `{this.foo}` / `{props.foo}` in the template. elements-kit JSX assigns each prop; your render places it. Use `@reactive` to update on reassignment. | | **Imperative DOM code** (a vanilla script holding a `Node` and doing `el.foo = node`) | Yes (`@slot()`) | `@slot()` makes the property a live comment-marker region so imperative code can fill and *replace* content against the mounted DOM — no reactive context required. | Other JSX frameworks (React, Vue, Svelte) each have their own children model and don't hand DOM nodes to props, so they seldom fill slots directly. When they must, it's through their imperative escape hatch (a `ref` to the element, then `el.foo = node`) — the same path as vanilla, which is what `@slot()` serves. #### Function component — no `Slot` at all ```tsx function Card(props) { return (
    {props.header}
    {props.children}
    {props.actions}
    ); } Title} actions={} > Body content ``` `{props.children}` and any function-typed prop flows through `mountChild`, which creates a `Slot` internally and updates in place when the source signal changes. The component doesn't see the slot — it just renders the prop. #### Custom element slots for imperative consumers — `@slot()` If your custom element is consumed through elements-kit JSX, you don't need `@slot()` — declare plain (or `@reactive`) properties and place them in your render, exactly like the class-component case above. Reach for `@slot()` only when **imperative DOM code** (a vanilla script, or another framework's `ref`-then-assign escape hatch) must fill and replace slots against the mounted element. Here the element is authored with **no elements-kit rendering at all** — plain DOM in `connectedCallback`. Reading a `@slot()` property returns its live region; placing it with `append()` mounts the markers: ```ts import { slot, type SlotContent } from "elements-kit/slot"; class XCard extends HTMLElement { @slot() header!: SlotContent; // named slot @slot() body!: SlotContent; // named slot connectedCallback() { const article = document.createElement("article"); const head = document.createElement("header"); const main = document.createElement("main"); head.append(this.header); // reading the region places it — markers mount here main.append(this.body); article.append(head, main); this.replaceChildren(article); } } customElements.define("x-card", XCard); ``` Now imperative code can fill and *replace* a slot at any time — the swap lands directly in the live DOM, after mount, with no reactive context: ```ts const el = document.querySelector("x-card")!; el.header = document.createElement("h2"); // Node el.header = "plain text"; // native append() content el.header = "updated"; // replaces the previous content in place el.header = null; // clears ``` That post-mount live swap is the *only* thing `@slot()` buys you. Consume the same element through elements-kit JSX (`}>`) and you wouldn't need `@slot()` at all — a plain (or `@reactive`) field works, because elements-kit JSX assigns props *before* the element mounts, so the element's own render reads the final value directly. ### Reactive slot content Pass a signal or `() => T` as slot content — the region updates in place when it changes. Works identically for function components and custom elements: ```tsx const title = signal("Initial Title");

    {title}

    }> Body content
    // Slot content updates reactively — no re-render of surrounding tree title("Updated Title"); ``` --- ## Comparison | | Native `` | ElementsKit `Slot` | |---|---|---| | Shadow DOM required | Yes | No | | Style encapsulation | Yes | No (global CSS) | | Browser-native projection | Yes | No (comment markers) | | Reactive content updates | Requires JS re-render | Yes — `slot.set()` | | No wrapper element | Yes | Yes | | Named slots | Yes (`name` attribute) | Yes (`@slot()` properties) | | TypeScript named slot prop | Via `IntrinsicElements` | The property's declared type (`PropertiesOf`) | Choose native `` when you need style encapsulation or are building a reusable web component for external consumers. Choose ElementsKit `Slot` when you want reactive content swapping in a light DOM component without Shadow DOM overhead. --- ## See also - [Custom elements](/custom-elements) - [Elements](/elements) - [Components](/components) # Styling > Style custom elements efficiently with Constructable Stylesheets and raw CSS imports. import Playground from "@/playground/Playground.astro"; import PLAYGROUND_FILE from "@/playground/files/styling.tsx?raw"; Custom elements can be styled two ways: with **global CSS** (light DOM) or **scoped CSS** inside a Shadow DOM. In both cases, using a `CSSStyleSheet` object avoids parsing the same CSS for every element instance. ## The problem with per-instance ` `; } } ``` 100 instances → 100 parse operations, 100 `CSSStyleDeclaration` objects in memory. With large stylesheets this adds up. --- ## Constructable Stylesheets `CSSStyleSheet` is a first-class object. Create it **once at module level**, share it across every instance by reference via `adoptedStyleSheets`. The browser parses the CSS once. ```ts // Parsed once when the module loads const sheet = new CSSStyleSheet(); sheet.replaceSync(` :host { display: block; font-family: sans-serif; } button { padding: 4px 12px; border-radius: 4px; cursor: pointer; } `); ``` Adopt it in Shadow DOM: ```tsx import { render } from "elements-kit/render"; class CounterElement extends HTMLElement { #unmount?: () => void; #template = () => (

    Count: {this.#count}

    ); connectedCallback() { const shadow = this.attachShadow({ mode: "open" }); // All instances share the same parsed sheet — zero extra parsing shadow.adoptedStyleSheets = [sheet]; this.#unmount = render(shadow, this.#template); } disconnectedCallback() { this.#unmount?.(); this.#unmount = undefined; } } ``` `adoptedStyleSheets` is an array — you can compose multiple sheets: ```ts shadow.adoptedStyleSheets = [baseSheet, tokenSheet, componentSheet]; ``` --- ## `?raw` CSS import When using **Vite**, **esbuild**, or most modern bundlers, append `?raw` to a CSS import to receive the file contents as a plain string. This keeps CSS in proper `.css` files (IDE support, linting, source maps) while inlining it at build time. ```ts // counter.css stays a real file — bundler inlines it as a string import styles from "./counter.css?raw"; const sheet = new CSSStyleSheet(); sheet.replaceSync(styles); ``` ```css /* counter.css */ :host { display: block; } button { padding: 4px 12px; border-radius: 4px; } ``` ### Singleton sheet module The standard pattern: one module exports the shared sheet, the element imports it. ```ts // counter.styles.ts import css from "./counter.css?raw"; export const counterSheet = new CSSStyleSheet(); counterSheet.replaceSync(css); ``` ```tsx // counter.ts import { reactive, computed } from "elements-kit/signals"; import { render } from "elements-kit/render"; import { counterSheet } from "./counter.styles"; class CounterElement extends HTMLElement { @reactive() count = 0; #unmount?: () => void; #template = () => (

    Count: {() => this.count}

    ); connectedCallback() { const shadow = this.attachShadow({ mode: "open" }); shadow.adoptedStyleSheets = [counterSheet]; this.#unmount = render(shadow, this.#template); } disconnectedCallback() { this.#unmount?.(); this.#unmount = undefined; } } ``` --- ## Light DOM (no Shadow DOM) Without a shadow root, there is no style encapsulation — your element's styles are global. Two clean options: ### Option 1 — `document.adoptedStyleSheets` Adopt the sheet on the document once. All instances benefit, and it is still parsed only once. ```ts import css from "./counter.css?raw"; const sheet = new CSSStyleSheet(); sheet.replaceSync(css); let adopted = false; class CounterElement extends HTMLElement { #unmount?: () => void; #template = () => /* JSX tree */; connectedCallback() { if (!adopted) { document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; adopted = true; } this.#unmount = render(this, this.#template); } disconnectedCallback() { this.#unmount?.(); this.#unmount = undefined; } } ``` ### Option 2 — inject `