This is the full developer documentation for ElementsKit # Introduction > Universal reactive primitives — signals, utilities, JSX, custom elements. 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. Signals & Stores 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. Utilities Browser-API signals — `online`, `windowSize`, `createMediaQuery`, `on`, `fromEvent`, observers, and more. Reactive wrappers, pay-per-import. Async `async`, `promise`, `retry`, `createLocalStorage`. Compose queries, mutations, persistence, and revalidation — no query library required. Elements JSX compiles to real `document.createElement` calls. Reactive props become live DOM bindings — no diffing, no reconciliation, no runtime overhead. Custom Elements Native `HTMLElement` subclasses enhanced with signals, JSX, and decorators. Usable in any HTML context — React, Vue, or plain HTML — no adapters needed. Framework Integration Bridge any signal into a UI framework via `useSignal` / `useScope`. React today, Svelte and Vue planned. ## Install * pnpm ```sh pnpm add elements-kit ``` * npm ```sh npm install elements-kit ``` * yarn ```sh yarn add elements-kit ``` * bun ```sh bun add elements-kit ``` Full setup — TypeScript JSX config, CDN, Deno — in [Installation](/getting-started/installation). * Counter ```tsx import { function signal(): Updater & Computed (+1 overload) Creates a mutable reactive signal. Read: call with no arguments → returns the current value and subscribes the active tracking context. Write: call with a value → updates the signal and schedules downstream effects if the value changed. @example const count = signal(0); count(); // → 0 (read) count(1); // write – effects depending on count will re-run count(); // → 1 signal, function computed(getter: (previousValue?: T) => T): () => T Creates a lazily-evaluated computed value. The getter is only called when the computed value is read and one of its dependencies has changed since the last evaluation. If nothing has changed the cached value is returned without re-running getter. Computed values are read-only; they cannot be set directly. @param ― getter - Pure function deriving a value from other reactive sources. Receives the previous value as an optional optimisation hint. @example const a = signal(1); const b = signal(2); const sum = computed(() => a() + b()); sum(); // → 3 a(10); sum(); // → 12 (re-evaluated lazily) computed } from "elements-kit/signals"; import { function render(target: Element | DocumentFragment, setup: () => Node | null | undefined): () => void Mount a node into target with a scoped lifetime. setup runs inside a detached effectScope. The returned node is appended to target. Calling the returned unmount removes the node from the DOM, disposes its Symbol.dispose hook (JSX-created elements carry one), and tears down every effect / onCleanup registered inside setup. @example import { render } from "elements-kit/render"; const unmount = render(document.getElementById("app")!, () => ); // later unmount(); render } from "elements-kit/render"; import type { type ReactiveProps

= { readonly [K in keyof P]: Computed; } & { readonly [RAW_PROPS]?: P; } ReactiveProps } from "elements-kit/jsx-runtime"; function function Counter(props: ReactiveProps<{ initial?: number; }>): JSX$1.Element Counter( props: ReactiveProps<{ initial?: number; }> props: type ReactiveProps

= { readonly [K in keyof P]: Computed; } & { readonly [RAW_PROPS]?: P; } ReactiveProps<{ initial?: number | undefined initial?: number }>) { const const count: Updater & Computed count = signal(initialValue: number): Updater & Computed (+1 overload) Creates a mutable reactive signal. Read: call with no arguments → returns the current value and subscribes the active tracking context. Write: call with a value → updates the signal and schedules downstream effects if the value changed. @example const count = signal(0); count(); // → 0 (read) count(1); // write – effects depending on count will re-run count(); // → 1 signal( props: ReactiveProps<{ initial?: number; }> props. initial?: Computed | undefined initial() ?? 0); const const doubled: () => number doubled = computed(getter: (previousValue?: number | undefined) => number): () => number Creates a lazily-evaluated computed value. The getter is only called when the computed value is read and one of its dependencies has changed since the last evaluation. If nothing has changed the cached value is returned without re-running getter. Computed values are read-only; they cannot be set directly. @param ― getter - Pure function deriving a value from other reactive sources. Receives the previous value as an optional optimisation hint. @example const a = signal(1); const b = signal(2); const sum = computed(() => a() + b()); sum(); // → 3 a(10); sum(); // → 12 (re-evaluated lazily) computed(() => const count: () => number (+1 overload) count() * 2); return ( < section: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/section @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement section> < p: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/p @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLParagraphElement p>< strong: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/strong @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement strong>{ const count: Updater & Computed count}> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/strong @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement strong> × 2 = < strong: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/strong @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement strong>{ const doubled: () => number doubled}> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/strong @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement strong>> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/p @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLParagraphElement p> < button: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLButtonElement button JSX.CustomEventHandlersNamespaced["on:click"]?: JSX.EventHandlerWithOptionsUnion> | undefined on: JSX.CustomEventHandlersNamespaced["on:click"]?: JSX.EventHandlerWithOptionsUnion> | undefined click={() => const count: (value: number) => void (+1 overload) count( const count: () => number (+1 overload) count() + 1)}>+1> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLButtonElement button>{" "} < button: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLButtonElement button JSX.CustomEventHandlersNamespaced["on:click"]?: JSX.EventHandlerWithOptionsUnion> | undefined on: JSX.CustomEventHandlersNamespaced["on:click"]?: JSX.EventHandlerWithOptionsUnion> | undefined click={() => const count: (value: number) => void (+1 overload) count( const count: () => number (+1 overload) count() - 1)}>−1> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLButtonElement button> > @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/section @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement section> ); } function render(target: Element | DocumentFragment, setup: () => Node | null | undefined): () => void Mount a node into target with a scoped lifetime. setup runs inside a detached effectScope. The returned node is appended to target. Calling the returned unmount removes the node from the DOM, disposes its Symbol.dispose hook (JSX-created elements carry one), and tears down every effect / onCleanup registered inside setup. @example import { render } from "elements-kit/render"; const unmount = render(document.getElementById("app")!, () => ); // later unmount(); render( var document: Document window.document returns a reference to the document contained in the window. MDN Reference document. Document.getElementById(elementId: string): HTMLElement | null The getElementById() method of the Document interface returns an Element object representing the element whose id property matches the specified string. Since element IDs are required to be unique if specified, they're a useful way to get access to a specific element quickly. getElementById("app")!, () => < function Counter(props: ReactiveProps<{ initial?: number; }>): JSX$1.Element Counter initial?: MaybeReactive | undefined initial={0} />); ``` * Utility ```tsx import { function createMediaQuery(query: string, defaultState?: boolean): Computed Creates a signal that tracks a CSS media query. @param ― query The media query string (e.g. '(max-width: 600px)') @param ― defaultState The default value (for SSR/hydration) @returns ― Computed that is true if the query matches createMediaQuery } from "elements-kit/utilities/media-query"; import { function render(target: Element | DocumentFragment, setup: () => Node | null | undefined): () => void Mount a node into target with a scoped lifetime. setup runs inside a detached effectScope. The returned node is appended to target. Calling the returned unmount removes the node from the DOM, disposes its Symbol.dispose hook (JSX-created elements carry one), and tears down every effect / onCleanup registered inside setup. @example import { render } from "elements-kit/render"; const unmount = render(document.getElementById("app")!, () => ); // later unmount(); render } from "elements-kit/render"; const const isDark: Computed isDark = function createMediaQuery(query: string, defaultState?: boolean): Computed Creates a signal that tracks a CSS media query. @param ― query The media query string (e.g. '(max-width: 600px)') @param ― defaultState The default value (for SSR/hydration) @returns ― Computed that is true if the query matches createMediaQuery("(prefers-color-scheme: dark)"); const const isMobile: Computed isMobile = function createMediaQuery(query: string, defaultState?: boolean): Computed Creates a signal that tracks a CSS media query. @param ― query The media query string (e.g. '(max-width: 600px)') @param ― defaultState The default value (for SSR/hydration) @returns ― Computed that is true if the query matches createMediaQuery("(max-width: 640px)"); function render(target: Element | DocumentFragment, setup: () => Node | null | undefined): () => void Mount a node into target with a scoped lifetime. setup runs inside a detached effectScope. The returned node is appended to target. Calling the returned unmount removes the node from the DOM, disposes its Symbol.dispose hook (JSX-created elements carry one), and tears down every effect / onCleanup registered inside setup. @example import { render } from "elements-kit/render"; const unmount = render(document.getElementById("app")!, () => ); // later unmount(); render( var document: Document window.document returns a reference to the document contained in the window. MDN Reference document. Document.getElementById(elementId: string): HTMLElement | null The getElementById() method of the Document interface returns an Element object representing the element whose id property matches the specified string. Since element IDs are required to be unique if specified, they're a useful way to get access to a specific element quickly. getElementById("app")!, () => ( < ul: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/ul @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLUListElement ul> < li: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/li @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLLIElement li>Dark mode: < strong: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/strong @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement strong>{ const isDark: Computed isDark}> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/strong @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement strong>> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/li @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLLIElement li> < li: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/li @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLLIElement li>Mobile: < strong: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/strong @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement strong>{ const isMobile: Computed isMobile}> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/strong @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement strong>> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/li @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLLIElement li> > @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/ul @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLUListElement ul> )); ``` * Async ```tsx import { function signal(): Updater & Computed (+1 overload) Creates a mutable reactive signal. Read: call with no arguments → returns the current value and subscribes the active tracking context. Write: call with a value → updates the signal and schedules downstream effects if the value changed. @example const count = signal(0); count(); // → 0 (read) count(1); // write – effects depending on count will re-run count(); // → 1 signal } from "elements-kit/signals"; import { function async(fn: MaybeReactive<(input: TInput) => Promise>): Async & ((...args: any[]) => TOutput | undefined) Create an Async that is also callable as a signal: invoking it (with no args) reads the current result, so it drops into any reactive context that expects a zero-arg getter. @example import { async } from "elements-kit/utilities/async"; const load = async((id: string) => fetch(`/u/${id}`).then(r => r.json())); load.run("alice"); // Read as a signal — subscribes to result changes effect(() => console.log(load())); await load; // Await the current run — works like a normal promise async } from "elements-kit/utilities/async"; import { function render(target: Element | DocumentFragment, setup: () => Node | null | undefined): () => void Mount a node into target with a scoped lifetime. setup runs inside a detached effectScope. The returned node is appended to target. Calling the returned unmount removes the node from the DOM, disposes its Symbol.dispose hook (JSX-created elements carry one), and tears down every effect / onCleanup registered inside setup. @example import { render } from "elements-kit/render"; const unmount = render(document.getElementById("app")!, () => ); // later unmount(); render } from "elements-kit/render"; const const query: Updater & Computed query = signal(initialValue: string): Updater & Computed (+1 overload) Creates a mutable reactive signal. Read: call with no arguments → returns the current value and subscribes the active tracking context. Write: call with a value → updates the signal and schedules downstream effects if the value changed. @example const count = signal(0); count(); // → 0 (read) count(1); // write – effects depending on count will re-run count(); // → 1 signal("hello"); const const search: Async & ((...args: any[]) => any) search = async(fn: MaybeReactive<(input: any) => Promise>): Async & ((...args: any[]) => any) Create an Async that is also callable as a signal: invoking it (with no args) reads the current result, so it drops into any reactive context that expects a zero-arg getter. @example import { async } from "elements-kit/utilities/async"; const load = async((id: string) => fetch(`/u/${id}`).then(r => r.json())); load.run("alice"); // Read as a signal — subscribes to result changes effect(() => console.log(load())); await load; // Await the current run — works like a normal promise async(async () => { const const res: Response res = await function fetch(input: RequestInfo | URL, init?: RequestInit): Promise MDN Reference fetch(`/api/search?q=${ const query: () => string (+1 overload) query()}`); return const res: Response res. Body.json(): Promise MDN Reference json(); }). Async.start(...args: [] | [input: any]): Async & ((...args: any[]) => any) Starts a new reactive async operation, stopping any currently active one. start(); function render(target: Element | DocumentFragment, setup: () => Node | null | undefined): () => void Mount a node into target with a scoped lifetime. setup runs inside a detached effectScope. The returned node is appended to target. Calling the returned unmount removes the node from the DOM, disposes its Symbol.dispose hook (JSX-created elements carry one), and tears down every effect / onCleanup registered inside setup. @example import { render } from "elements-kit/render"; const unmount = render(document.getElementById("app")!, () => ); // later unmount(); render( var document: Document window.document returns a reference to the document contained in the window. MDN Reference document. Document.getElementById(elementId: string): HTMLElement | null The getElementById() method of the Document interface returns an Element object representing the element whose id property matches the specified string. Since element IDs are required to be unique if specified, they're a useful way to get access to a specific element quickly. getElementById("app")!, () => ( < section: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/section @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement section> < input: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement input JSX.InputHTMLAttributes.value?: JSX.FunctionMaybe value={ const query: Updater & Computed query} JSX.CustomEventHandlersNamespaced["on:input"]?: JSX.EventHandlerWithOptionsUnion> | undefined on: JSX.CustomEventHandlersNamespaced["on:input"]?: JSX.EventHandlerWithOptionsUnion> | undefined input={( e: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement; } e) => const query: (value: string) => void (+1 overload) query(( e: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement; } e. target: EventTarget & HTMLInputElement The read-only target property of the Event interface is a reference to the object onto which the event was dispatched. It is different from Event.currentTarget when the event handler is called during the bubbling or capturing phase of the event. MDN Reference target as interface HTMLInputElement The HTMLInputElement interface provides special properties and methods for manipulating the options, layout, and presentation of elements. MDN Reference HTMLInputElement). HTMLInputElement.value: string The value property of the HTMLInputElement interface represents the current value of the element as a string. MDN Reference value)} /> < p: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/p @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLParagraphElement p>state: < strong: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/strong @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement strong>{() => const search: Async & ((...args: any[]) => any) search. Async.state: "pending" | "fulfilled" | "rejected" state}> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/strong @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement strong>> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/p @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLParagraphElement p> < pre: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/pre @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLPreElement pre>{() => var JSON: JSON An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format. JSON. JSON.stringify(value: any, replacer?: (number | string)[] | null, space?: string | number): string (+1 overload) Converts a JavaScript value to a JavaScript Object Notation (JSON) string. @param ― value A JavaScript value, usually an object or array, to be converted. @param ― replacer An array of strings and numbers that acts as an approved list for selecting the object properties that will be stringified. @param ― space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read. @throws ― {TypeError} If a circular reference or a BigInt value is found. stringify( const search: Async & ((...args: any[]) => any) search. Async.value: any value, null, 2)}> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/pre @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLPreElement pre> > @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/section @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement section> )); ``` * Custom element ```tsx import { function reactive(source?: (self: This) => Signal): (_target: unknown, context: ClassFieldDecoratorContext) => (this: This, initialValue: Value) => Value A decorator that makes a class field reactive by automatically wrapping its value in a signal. The field behaves like a normal property (get/set) but reactivity is tracked under the hood. Any reads will subscribe to the signal and any writes will trigger updates. @example class Counter { \@reactive() count: number = 0; } const counter = new Counter(); counter.count++; // Triggers reactivity console.log(counter.count); // Subscribes to changes @remarks ― Equivalent to manually creating a private signal and getter/setter: class Counter { #count = signal(0); get count() { return this.#count(); } set count(value) { this.#count(value); } } reactive, function computed(getter: (previousValue?: T) => T): () => T Creates a lazily-evaluated computed value. The getter is only called when the computed value is read and one of its dependencies has changed since the last evaluation. If nothing has changed the cached value is returned without re-running getter. Computed values are read-only; they cannot be set directly. @param ― getter - Pure function deriving a value from other reactive sources. Receives the previous value as an optional optimisation hint. @example const a = signal(1); const b = signal(2); const sum = computed(() => a() + b()); sum(); // → 3 a(10); sum(); // → 12 (re-evaluated lazily) computed } from "elements-kit/signals"; import { function attributes HTMLElement>(target: AttributeTarget, context: ClassDecoratorContext): AttributeDecorated A class decorator that automatically wires up observedAttributes and attributeChangedCallback from a static [ATTRIBUTES] map. The this type inside attribute handlers is automatically inferred from the decorated class. @example \@attributes class MyElement extends HTMLElement { static [ATTRIBUTES] = { count(this: MyElement, value: string | null) { this.count = Number(value); }, }; } attributes, const ATTRIBUTES: typeof attr export ATTRIBUTES Static-field key used by the @attributes decorator (and dispatchAttrChange / observedAttributes ) to locate the attribute handler map on a custom-element class. @example class MyElement extends HTMLElement { static [ATTRIBUTES]: Attributes = { name(value) { this.name = value ?? ""; }, }; } ATTRIBUTES as const attr: typeof attr Static-field key used by the @attributes decorator (and dispatchAttrChange / observedAttributes ) to locate the attribute handler map on a custom-element class. @example class MyElement extends HTMLElement { static [ATTRIBUTES]: Attributes = { name(value) { this.name = value ?? ""; }, }; } attr } from "elements-kit/attributes"; import { function render(target: Element | DocumentFragment, setup: () => Node | null | undefined): () => void Mount a node into target with a scoped lifetime. setup runs inside a detached effectScope. The returned node is appended to target. Calling the returned unmount removes the node from the DOM, disposes its Symbol.dispose hook (JSX-created elements carry one), and tears down every effect / onCleanup registered inside setup. @example import { render } from "elements-kit/render"; const unmount = render(document.getElementById("app")!, () => ); // later unmount(); render } from "elements-kit/render"; @ function attributes HTMLElement>(target: AttributeTarget, context: ClassDecoratorContext): AttributeDecorated A class decorator that automatically wires up observedAttributes and attributeChangedCallback from a static [ATTRIBUTES] map. The this type inside attribute handlers is automatically inferred from the decorated class. @example \@attributes class MyElement extends HTMLElement { static [ATTRIBUTES] = { count(this: MyElement, value: string | null) { this.count = Number(value); }, }; } attributes class class CounterElement CounterElement extends var HTMLElement: { new (): HTMLElement; prototype: HTMLElement; } The HTMLElement interface represents any HTML element. Some elements directly implement this interface, while others implement it via an interface that inherits it. MDN Reference HTMLElement { static [ const attr: typeof attr Static-field key used by the @attributes decorator (and dispatchAttrChange / observedAttributes ) to locate the attribute handler map on a custom-element class. @example class MyElement extends HTMLElement { static [ATTRIBUTES]: Attributes = { name(value) { this.name = value ?? ""; }, }; } attr] = { function initial(this: CounterElement, v: string | null): void initial( this: CounterElement this: class CounterElement CounterElement, v: string | null v: string | null) { this. CounterElement.count: number count = var Number: NumberConstructor (value?: any) => number An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers. Number( v: string | null v ?? 0); }, }; @ reactive(source?: ((self: object) => Signal) | undefined): (_target: unknown, context: ClassFieldDecoratorContext) => (this: object, initialValue: unknown) => unknown A decorator that makes a class field reactive by automatically wrapping its value in a signal. The field behaves like a normal property (get/set) but reactivity is tracked under the hood. Any reads will subscribe to the signal and any writes will trigger updates. @example class Counter { \@reactive() count: number = 0; } const counter = new Counter(); counter.count++; // Triggers reactivity console.log(counter.count); // Subscribes to changes @remarks ― Equivalent to manually creating a private signal and getter/setter: class Counter { #count = signal(0); get count() { return this.#count(); } set count(value) { this.#count(value); } } reactive() CounterElement.count: number count = 0; CounterElement.doubled: () => number doubled = computed(getter: (previousValue?: number | undefined) => number): () => number Creates a lazily-evaluated computed value. The getter is only called when the computed value is read and one of its dependencies has changed since the last evaluation. If nothing has changed the cached value is returned without re-running getter. Computed values are read-only; they cannot be set directly. @param ― getter - Pure function deriving a value from other reactive sources. Receives the previous value as an optional optimisation hint. @example const a = signal(1); const b = signal(2); const sum = computed(() => a() + b()); sum(); // → 3 a(10); sum(); // → 12 (re-evaluated lazily) computed(() => this. CounterElement.count: number count * 2); #unmount?: () => void; #template = () => ( < p: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/p @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLParagraphElement p> < strong: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/strong @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement strong>{() => this. CounterElement.count: number count}> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/strong @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement strong> × 2 = < strong: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/strong @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement strong>{this. CounterElement.doubled: () => number doubled}> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/strong @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement strong>{" "} < button: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLButtonElement button JSX.CustomEventHandlersNamespaced["on:click"]?: JSX.EventHandlerWithOptionsUnion> | undefined on: JSX.CustomEventHandlersNamespaced["on:click"]?: JSX.EventHandlerWithOptionsUnion> | undefined click={() => this. CounterElement.count: number count++}>+1> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLButtonElement button> > @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/p @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLParagraphElement p> ); CounterElement.connectedCallback(): void connectedCallback() { this.#unmount = function render(target: Element | DocumentFragment, setup: () => Node | null | undefined): () => void Mount a node into target with a scoped lifetime. setup runs inside a detached effectScope. The returned node is appended to target. Calling the returned unmount removes the node from the DOM, disposes its Symbol.dispose hook (JSX-created elements carry one), and tears down every effect / onCleanup registered inside setup. @example import { render } from "elements-kit/render"; const unmount = render(document.getElementById("app")!, () => ); // later unmount(); render(this, this.#template); } CounterElement.disconnectedCallback(): void disconnectedCallback() { this.#unmount?.(); this.#unmount = var undefined undefined; } } var customElements: CustomElementRegistry The customElements read-only property of the Window interface returns a reference to the CustomElementRegistry object, which can be used to register new custom elements and get information about previously registered custom elements. MDN Reference customElements. CustomElementRegistry.define(name: string, constructor: CustomElementConstructor, options?: ElementDefinitionOptions): void The define() method of the CustomElementRegistry interface adds a definition for a custom element to the custom element registry, mapping its name to the constructor which will be used to create it. MDN Reference define("x-counter", class CounterElement CounterElement); // Usable anywhere — plain HTML, React, Vue: // ``` * Use in React.js ```tsx /** @jsxImportSource react */ import { function signal(): Updater & Computed (+1 overload) Creates a mutable reactive signal. Read: call with no arguments → returns the current value and subscribes the active tracking context. Write: call with a value → updates the signal and schedules downstream effects if the value changed. @example const count = signal(0); count(); // → 0 (read) count(1); // write – effects depending on count will re-run count(); // → 1 signal, function computed(getter: (previousValue?: T) => T): () => T Creates a lazily-evaluated computed value. The getter is only called when the computed value is read and one of its dependencies has changed since the last evaluation. If nothing has changed the cached value is returned without re-running getter. Computed values are read-only; they cannot be set directly. @param ― getter - Pure function deriving a value from other reactive sources. Receives the previous value as an optional optimisation hint. @example const a = signal(1); const b = signal(2); const sum = computed(() => a() + b()); sum(); // → 3 a(10); sum(); // → 12 (re-evaluated lazily) computed, function effect(fn: () => void): () => void Creates a reactive side-effect that runs immediately and re-runs whenever any signal or computed it read during its last execution changes. Use onCleanup inside fn to register teardown logic that runs before each re-execution and on final disposal. If effect is called inside an effectScope or another effect, the new effect is automatically owned by the outer scope and will be disposed when the scope is disposed. @param ― fn - The side-effect body. Reactive reads inside this function establish dependency links. @returns ― A disposal function. Call it to stop the effect and run any registered cleanup. @example const url = signal('/api/data'); const stop = effect(() => { const controller = new AbortController(); fetch(url(), { signal: controller.signal }); onCleanup(() => controller.abort()); }); url('/api/other'); // previous fetch is aborted, new one starts stop(); // final cleanup: abort the last fetch effect } from "elements-kit/signals"; import { function useSignal(value: () => T): T Subscribe to any readable signal — writable or computed — returning its current value. Accepts any zero-argument callable () => T, which includes both Signal and Computed. Using () => T instead of Computed prevents TypeScript from picking the write overload of Signal during type inference. @template ― T - The type of the signal value. @param ― value - A writable Signal or a derived Computed. @returns ― The current value, updated on every signal change. @example const count = signal(0); const double = computed(() => count() * 2); function Display() { const countValue = useSignal(count); const doubleValue = useSignal(double); return

{countValue} × 2 = {doubleValue}
; } useSignal, function useScope(callback: () => Computed | void): T | void Create a signal effect scope tied to a React component's lifetime. All effects registered inside callback are grouped into a single scope. The scope — and every effect within it — is automatically stopped when the component unmounts. If your callback returns a Computed signal, the hook will always return its current value, updating reactively as dependencies change. If your callback returns void, the value will be undefined. Returns the current value of the computed signal (or undefined). Use this when you want to create multiple related effects at once without individually managing each one's lifecycle. All effects and cleanups inside the callback are automatically cleaned up on unmount. @template ― T - The type of the computed value (if any). @param ― callback - A function that registers one or more signal effects, optionally returning a Computed. @returns ― value — the current value of the computed signal (or undefined). @example function Analytics() { useScope(() => { effect(() => console.log("page:", currentPage())); effect(() => console.log("user:", currentUser())); }); return null; } // With computed value: function DoubleCounter() { const double = useScope(() => computed(() => count() * 2)); return
{double}
; } useScope } from "elements-kit/integrations/react"; // Signals live outside React — shared with any other consumer. const const count: Updater & Computed count = signal(initialValue: number): Updater & Computed (+1 overload) Creates a mutable reactive signal. Read: call with no arguments → returns the current value and subscribes the active tracking context. Write: call with a value → updates the signal and schedules downstream effects if the value changed. @example const count = signal(0); count(); // → 0 (read) count(1); // write – effects depending on count will re-run count(); // → 1 signal(0); const const doubled: () => number doubled = computed(getter: (previousValue?: number | undefined) => number): () => number Creates a lazily-evaluated computed value. The getter is only called when the computed value is read and one of its dependencies has changed since the last evaluation. If nothing has changed the cached value is returned without re-running getter. Computed values are read-only; they cannot be set directly. @param ― getter - Pure function deriving a value from other reactive sources. Receives the previous value as an optional optimisation hint. @example const a = signal(1); const b = signal(2); const sum = computed(() => a() + b()); sum(); // → 3 a(10); sum(); // → 12 (re-evaluated lazily) computed(() => const count: () => number (+1 overload) count() * 2); export default function function Counter(): JSX.Element Counter() { const const value: number value = useSignal(value: () => number): number Subscribe to any readable signal — writable or computed — returning its current value. Accepts any zero-argument callable () => T, which includes both Signal and Computed. Using () => T instead of Computed prevents TypeScript from picking the write overload of Signal during type inference. @template ― T - The type of the signal value. @param ― value - A writable Signal or a derived Computed. @returns ― The current value, updated on every signal change. @example const count = signal(0); const double = computed(() => count() * 2); function Display() { const countValue = useSignal(count); const doubleValue = useSignal(double); return
{countValue} × 2 = {doubleValue}
; } useSignal( const count: Updater & Computed count); const const double: number double = useSignal(value: () => number): number Subscribe to any readable signal — writable or computed — returning its current value. Accepts any zero-argument callable () => T, which includes both Signal and Computed. Using () => T instead of Computed prevents TypeScript from picking the write overload of Signal during type inference. @template ― T - The type of the signal value. @param ― value - A writable Signal or a derived Computed. @returns ― The current value, updated on every signal change. @example const count = signal(0); const double = computed(() => count() * 2); function Display() { const countValue = useSignal(count); const doubleValue = useSignal(double); return
{countValue} × 2 = {doubleValue}
; } useSignal( const doubled: () => number doubled); useScope(callback: () => void | Computed): unknown Create a signal effect scope tied to a React component's lifetime. All effects registered inside callback are grouped into a single scope. The scope — and every effect within it — is automatically stopped when the component unmounts. If your callback returns a Computed signal, the hook will always return its current value, updating reactively as dependencies change. If your callback returns void, the value will be undefined. Returns the current value of the computed signal (or undefined). Use this when you want to create multiple related effects at once without individually managing each one's lifecycle. All effects and cleanups inside the callback are automatically cleaned up on unmount. @template ― T - The type of the computed value (if any). @param ― callback - A function that registers one or more signal effects, optionally returning a Computed. @returns ― value — the current value of the computed signal (or undefined). @example function Analytics() { useScope(() => { effect(() => console.log("page:", currentPage())); effect(() => console.log("user:", currentUser())); }); return null; } // With computed value: function DoubleCounter() { const double = useScope(() => computed(() => count() * 2)); return
{double}
; } useScope(() => { function effect(fn: () => void): () => void Creates a reactive side-effect that runs immediately and re-runs whenever any signal or computed it read during its last execution changes. Use onCleanup inside fn to register teardown logic that runs before each re-execution and on final disposal. If effect is called inside an effectScope or another effect, the new effect is automatically owned by the outer scope and will be disposed when the scope is disposed. @param ― fn - The side-effect body. Reactive reads inside this function establish dependency links. @returns ― A disposal function. Call it to stop the effect and run any registered cleanup. @example const url = signal('/api/data'); const stop = effect(() => { const controller = new AbortController(); fetch(url(), { signal: controller.signal }); onCleanup(() => controller.abort()); }); url('/api/other'); // previous fetch is aborted, new one starts stop(); // final cleanup: abort the last fetch effect(() => var console: Console console. Console.log(...data: any[]): void The console.log() static method outputs a message to the console. MDN Reference log("count:", const count: () => number (+1 overload) count())); }); return ( < React.JSX.IntrinsicElements.section: React.DetailedHTMLProps, HTMLElement> section> < React.JSX.IntrinsicElements.p: React.DetailedHTMLProps, HTMLParagraphElement> p>< React.JSX.IntrinsicElements.strong: React.DetailedHTMLProps, HTMLElement> strong>{ const value: number value}, HTMLElement> strong> × 2 = < React.JSX.IntrinsicElements.strong: React.DetailedHTMLProps, HTMLElement> strong>{ const double: number double}, HTMLElement> strong>, HTMLParagraphElement> p> < React.JSX.IntrinsicElements.button: React.DetailedHTMLProps, HTMLButtonElement> button React.DOMAttributes.onClick?: React.MouseEventHandler | undefined onClick={() => const count: (value: number) => void (+1 overload) count( const count: () => number (+1 overload) count() + 1)}>+1, HTMLButtonElement> button>{" "} < React.JSX.IntrinsicElements.button: React.DetailedHTMLProps, HTMLButtonElement> button React.DOMAttributes.onClick?: React.MouseEventHandler | undefined onClick={() => const count: (value: number) => void (+1 overload) count( const count: () => number (+1 overload) count() - 1)}>−1, HTMLButtonElement> button> , HTMLElement> section> ); } ``` See the full progression — signals → element → function component → class → 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)signal · computed · effect · @reactive · batch · onCleanup [Stores ](/stores)Class-based reactive stores — share state across components and frameworks [Elements ](/elements)JSX → real DOM, live bindings, reactive props, event listeners [Components ](/components)Class components — store + render pattern [Custom Elements ](/custom-elements)Native HTMLElement enhanced with signals, JSX, and decorators [Utilities ](/utilities)Pre-built signal factories for browser APIs [React ](/integrations/react)useSignal · useScope — connect any signal or store to React [Data fetching recipe ](/recipes/data-fetching)Compose async + retry + online + window-focus # Async > Reactive, awaitable async controllers — start, stop, rerun, and read state as signals. 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 { function async(fn: MaybeReactive<(input: TInput) => Promise>): Async & ((...args: any[]) => TOutput | undefined) Create an Async that is also callable as a signal: invoking it (with no args) reads the current result, so it drops into any reactive context that expects a zero-arg getter. @example import { async } from "elements-kit/utilities/async"; const load = async((id: string) => fetch(`/u/${id}`).then(r => r.json())); load.run("alice"); // Read as a signal — subscribes to result changes effect(() => console.log(load())); await load; // Await the current run — works like a normal promise async } from "elements-kit/utilities/async"; const const fetchItems: Async & ((...args: any[]) => any) fetchItems = async(fn: MaybeReactive<(input: any) => Promise>): Async & ((...args: any[]) => any) Create an Async that is also callable as a signal: invoking it (with no args) reads the current result, so it drops into any reactive context that expects a zero-arg getter. @example import { async } from "elements-kit/utilities/async"; const load = async((id: string) => fetch(`/u/${id}`).then(r => r.json())); load.run("alice"); // Read as a signal — subscribes to result changes effect(() => console.log(load())); await load; // Await the current run — works like a normal promise async(() => function fetch(input: RequestInfo | URL, init?: RequestInit): Promise MDN Reference fetch("/api/items"). Promise.then(onfulfilled?: ((value: Response) => any) | null | undefined, onrejected?: ((reason: any) => PromiseLike) | null | undefined): Promise Attaches callbacks for the resolution and/or rejection of the Promise. @param ― onfulfilled The callback to execute when the Promise is resolved. @param ― onrejected The callback to execute when the Promise is rejected. @returns ― A Promise for the completion of which ever callback is executed. then(( res: Response res) => res: Response res. Body.json(): Promise MDN Reference json())); const fetchItems: Async & ((...args: any[]) => any) fetchItems. Async.start(...args: [] | [input: any]): Async & ((...args: any[]) => any) Starts a new reactive async operation, stopping any currently active one. 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 ``` `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`: ```ts op.state; // "pending" | "fulfilled" | "rejected" op.value; // resolved value (T | undefined) op.reason; // rejection reason (E | undefined) op.result; // value if fulfilled, reason if rejected, undefined if pending op.pending; // true while pending ``` 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 { function signal(): Updater & Computed (+1 overload) Creates a mutable reactive signal. Read: call with no arguments → returns the current value and subscribes the active tracking context. Write: call with a value → updates the signal and schedules downstream effects if the value changed. @example const count = signal(0); count(); // → 0 (read) count(1); // write – effects depending on count will re-run count(); // → 1 signal } from "elements-kit/signals"; import { function async(fn: MaybeReactive<(input: TInput) => Promise>): Async & ((...args: any[]) => TOutput | undefined) Create an Async that is also callable as a signal: invoking it (with no args) reads the current result, so it drops into any reactive context that expects a zero-arg getter. @example import { async } from "elements-kit/utilities/async"; const load = async((id: string) => fetch(`/u/${id}`).then(r => r.json())); load.run("alice"); // Read as a signal — subscribes to result changes effect(() => console.log(load())); await load; // Await the current run — works like a normal promise async } from "elements-kit/utilities/async"; const const id: Updater & Computed id = signal(initialValue: number): Updater & Computed (+1 overload) Creates a mutable reactive signal. Read: call with no arguments → returns the current value and subscribes the active tracking context. Write: call with a value → updates the signal and schedules downstream effects if the value changed. @example const count = signal(0); count(); // → 0 (read) count(1); // write – effects depending on count will re-run count(); // → 1 signal(1); const const fetchTodo: Async & ((...args: any[]) => any) fetchTodo = async(fn: MaybeReactive<(input: any) => Promise>): Async & ((...args: any[]) => any) Create an Async that is also callable as a signal: invoking it (with no args) reads the current result, so it drops into any reactive context that expects a zero-arg getter. @example import { async } from "elements-kit/utilities/async"; const load = async((id: string) => fetch(`/u/${id}`).then(r => r.json())); load.run("alice"); // Read as a signal — subscribes to result changes effect(() => console.log(load())); await load; // Await the current run — works like a normal promise async(() => function fetch(input: RequestInfo | URL, init?: RequestInit): Promise MDN Reference fetch(`https://jsonplaceholder.typicode.com/todos/${ const id: () => number (+1 overload) id()}`) // tracked . Promise.then(onfulfilled?: ((value: Response) => any) | null | undefined, onrejected?: ((reason: any) => PromiseLike) | null | undefined): Promise Attaches callbacks for the resolution and/or rejection of the Promise. @param ― onfulfilled The callback to execute when the Promise is resolved. @param ― onrejected The callback to execute when the Promise is rejected. @returns ― A Promise for the completion of which ever callback is executed. then(( res: Response res) => res: Response res. Body.json(): Promise MDN Reference json()), ). Async.start(...args: [] | [input: any]): Async & ((...args: any[]) => any) Starts a new reactive async operation, stopping any currently active one. start(); // re-fetches automatically when id changes const id: (value: number) => void (+1 overload) 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](/promise) — the underlying `ComputedPromise` / `ReactivePromise` primitive. * [Data fetching](/recipes/data-fetching) — full recipe composing retry, online, window-focus. * [Signals](/signals) — `onCleanup`, `untracked`. # Components > Class components — reactive state and element rendering in one place. 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 ``` Prev 1 / 5 Next ## 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 // counter-store.ts — state only import { function reactive(source?: (self: This) => Signal): (_target: unknown, context: ClassFieldDecoratorContext) => (this: This, initialValue: Value) => Value A decorator that makes a class field reactive by automatically wrapping its value in a signal. The field behaves like a normal property (get/set) but reactivity is tracked under the hood. Any reads will subscribe to the signal and any writes will trigger updates. @example class Counter { \@reactive() count: number = 0; } const counter = new Counter(); counter.count++; // Triggers reactivity console.log(counter.count); // Subscribes to changes @remarks ― Equivalent to manually creating a private signal and getter/setter: class Counter { #count = signal(0); get count() { return this.#count(); } set count(value) { this.#count(value); } } reactive, function computed(getter: (previousValue?: T) => T): () => T Creates a lazily-evaluated computed value. The getter is only called when the computed value is read and one of its dependencies has changed since the last evaluation. If nothing has changed the cached value is returned without re-running getter. Computed values are read-only; they cannot be set directly. @param ― getter - Pure function deriving a value from other reactive sources. Receives the previous value as an optional optimisation hint. @example const a = signal(1); const b = signal(2); const sum = computed(() => a() + b()); sum(); // → 3 a(10); sum(); // → 12 (re-evaluated lazily) computed } from "elements-kit/signals"; export class class CounterStore CounterStore { @ reactive(source?: ((self: object) => Signal) | undefined): (_target: unknown, context: ClassFieldDecoratorContext) => (this: object, initialValue: unknown) => unknown A decorator that makes a class field reactive by automatically wrapping its value in a signal. The field behaves like a normal property (get/set) but reactivity is tracked under the hood. Any reads will subscribe to the signal and any writes will trigger updates. @example class Counter { \@reactive() count: number = 0; } const counter = new Counter(); counter.count++; // Triggers reactivity console.log(counter.count); // Subscribes to changes @remarks ― Equivalent to manually creating a private signal and getter/setter: class Counter { #count = signal(0); get count() { return this.#count(); } set count(value) { this.#count(value); } } reactive() CounterStore.count: number count = 0; CounterStore.doubled: () => number doubled = computed(getter: (previousValue?: number | undefined) => number): () => number Creates a lazily-evaluated computed value. The getter is only called when the computed value is read and one of its dependencies has changed since the last evaluation. If nothing has changed the cached value is returned without re-running getter. Computed values are read-only; they cannot be set directly. @param ― getter - Pure function deriving a value from other reactive sources. Receives the previous value as an optional optimisation hint. @example const a = signal(1); const b = signal(2); const sum = computed(() => a() + b()); sum(); // → 3 a(10); sum(); // → 12 (re-evaluated lazily) computed(() => this. CounterStore.count: number count * 2); CounterStore.increment(): void increment() { this. CounterStore.count: number count++; } CounterStore.reset(): void reset() { this. CounterStore.count: number count = 0; } } export const const counter: CounterStore counter = new constructor CounterStore(): CounterStore 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`. The JSX runtime auto-wraps the props bag, so every key on `props` arrives as a callable getter — call it to read, pass it (or wrap a call in a getter) into JSX to subscribe. ```tsx import type { type ReactiveProps

= { readonly [K in keyof P]: Computed; } & { readonly [RAW_PROPS]?: P; } ReactiveProps } from "elements-kit/jsx-runtime"; function function Greeting(props: ReactiveProps<{ name: string; excited?: boolean; }>): JSX$1.Element Greeting( props: ReactiveProps<{ name: string; excited?: boolean; }> props: type ReactiveProps

= { readonly [K in keyof P]: Computed; } & { readonly [RAW_PROPS]?: P; } ReactiveProps<{ name: string name: string; excited?: boolean | undefined excited?: boolean }>, ) { return ( < p: WithJsxNamespaces> @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/p @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLParagraphElement p> Hello, { props: ReactiveProps<{ name: string; excited?: boolean; }> props. name: Computed name} {() => ( props: ReactiveProps<{ name: string; excited?: boolean; }> props. excited?: Computed | undefined excited() ? "!" : ".")} > @url ― https://developer.mozilla.org/en-US/docs/Web/HTML/Element/p @url ― https://developer.mozilla.org/en-US/docs/Web/API/HTMLParagraphElement p> ); } ``` Each key on `props` is a `Computed`. Signals and computed pass through with identity preserved; static values become stable thunks. For non-JSX call sites or nested prop bags you can still call [`resolveProps`](/elements/types) manually. ## 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. # Custom Elements > Native HTMLElement authoring, enhanced gradually with signals, JSX, and decorators. 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 ``` Prev 1 / 6 Next | 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; } } ``` 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. *** ## 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. 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 import { function attributes HTMLElement>(target: AttributeTarget, context: ClassDecoratorContext): AttributeDecorated A class decorator that automatically wires up observedAttributes and attributeChangedCallback from a static [ATTRIBUTES] map. The this type inside attribute handlers is automatically inferred from the decorated class. @example \@attributes class MyElement extends HTMLElement { static [ATTRIBUTES] = { count(this: MyElement, value: string | null) { this.count = Number(value); }, }; } attributes, const ATTRIBUTES: typeof attr export ATTRIBUTES Static-field key used by the @attributes decorator (and dispatchAttrChange / observedAttributes ) to locate the attribute handler map on a custom-element class. @example class MyElement extends HTMLElement { static [ATTRIBUTES]: Attributes = { name(value) { this.name = value ?? ""; }, }; } ATTRIBUTES as const attr: typeof attr Static-field key used by the @attributes decorator (and dispatchAttrChange / observedAttributes ) to locate the attribute handler map on a custom-element class. @example class MyElement extends HTMLElement { static [ATTRIBUTES]: Attributes = { name(value) { this.name = value ?? ""; }, }; } attr } from "elements-kit/attributes"; import { function reactive(source?: (self: This) => Signal): (_target: unknown, context: ClassFieldDecoratorContext) => (this: This, initialValue: Value) => Value A decorator that makes a class field reactive by automatically wrapping its value in a signal. The field behaves like a normal property (get/set) but reactivity is tracked under the hood. Any reads will subscribe to the signal and any writes will trigger updates. @example class Counter { \@reactive() count: number = 0; } const counter = new Counter(); counter.count++; // Triggers reactivity console.log(counter.count); // Subscribes to changes @remarks ― Equivalent to manually creating a private signal and getter/setter: class Counter { #count = signal(0); get count() { return this.#count(); } set count(value) { this.#count(value); } } reactive } from "elements-kit/signals"; @ function attributes HTMLElement>(target: AttributeTarget, context: ClassDecoratorContext): AttributeDecorated A class decorator that automatically wires up observedAttributes and attributeChangedCallback from a static [ATTRIBUTES] map. The this type inside attribute handlers is automatically inferred from the decorated class. @example \@attributes class MyElement extends HTMLElement { static [ATTRIBUTES] = { count(this: MyElement, value: string | null) { this.count = Number(value); }, }; } attributes class class CounterElement CounterElement extends var HTMLElement: { new (): HTMLElement; prototype: HTMLElement; } The HTMLElement interface represents any HTML element. Some elements directly implement this interface, while others implement it via an interface that inherits it. MDN Reference HTMLElement { static [ const attr: typeof attr Static-field key used by the @attributes decorator (and dispatchAttrChange / observedAttributes ) to locate the attribute handler map on a custom-element class. @example class MyElement extends HTMLElement { static [ATTRIBUTES]: Attributes = { name(value) { this.name = value ?? ""; }, }; } attr] = { // Called whenever the HTML "count" attribute changes function count(this: CounterElement, value: string | null): void count( this: CounterElement this: class CounterElement CounterElement, value: string | null value: string | null) { this. CounterElement.count: number count = var Number: NumberConstructor (value?: any) => number An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers. Number( value: string | null value ?? 0); // string → number, write to reactive property }, function step(this: CounterElement, value: string | null): void step( this: CounterElement this: class CounterElement CounterElement, value: string | null value: string | null) { this. CounterElement.step: number step = var Number: NumberConstructor (value?: any) => number An object that represents a number of any kind. All JavaScript numbers are 64-bit floating-point numbers. Number( value: string | null value ?? 1); }, }; @ reactive(source?: ((self: object) => Signal) | undefined): (_target: unknown, context: ClassFieldDecoratorContext) => (this: object, initialValue: unknown) => unknown A decorator that makes a class field reactive by automatically wrapping its value in a signal. The field behaves like a normal property (get/set) but reactivity is tracked under the hood. Any reads will subscribe to the signal and any writes will trigger updates. @example class Counter { \@reactive() count: number = 0; } const counter = new Counter(); counter.count++; // Triggers reactivity console.log(counter.count); // Subscribes to changes @remarks ― Equivalent to manually creating a private signal and getter/setter: class Counter { #count = signal(0); get count() { return this.#count(); } set count(value) { this.#count(value); } } reactive() CounterElement.count: number count = 0; @ reactive(source?: ((self: object) => Signal) | undefined): (_target: unknown, context: ClassFieldDecoratorContext) => (this: object, initialValue: unknown) => unknown A decorator that makes a class field reactive by automatically wrapping its value in a signal. The field behaves like a normal property (get/set) but reactivity is tracked under the hood. Any reads will subscribe to the signal and any writes will trigger updates. @example class Counter { \@reactive() count: number = 0; } const counter = new Counter(); counter.count++; // Triggers reactivity console.log(counter.count); // Subscribes to changes @remarks ― Equivalent to manually creating a private signal and getter/setter: class Counter { #count = signal(0); get count() { return this.#count(); } set count(value) { this.#count(value); } } reactive() CounterElement.step: number step = 1; } var customElements: CustomElementRegistry The customElements read-only property of the Window interface returns a reference to the CustomElementRegistry object, which can be used to register new custom elements and get information about previously registered custom elements. MDN Reference customElements. CustomElementRegistry.define(name: string, constructor: CustomElementConstructor, options?: ElementDefinitionOptions): void The define() method of the CustomElementRegistry interface adds a definition for a custom element to the custom element registry, mapping its name to the constructor which will be used to create it. MDN Reference define("x-counter", class CounterElement 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. 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 = Slot.new(); // Mounts the comment markers + optional default content const section =
{slot("Loading…")}
; // Later — replace content in place slot.set(

Content loaded!

); slot.isMounted(); // true slot.parent(); // the
element ``` ### Named slots with `Slots` For components with multiple named regions, use `Slots` — a typed keyed collection of `Slot` instances. Attach it to your component instance via the `SLOTS` symbol so JSX’s `slot:name` prop wiring works automatically. ```tsx import { Slot, Slots, SLOTS } from "elements-kit/slot"; import type { Child, Props } from "elements-kit/jsx-runtime"; /** * Props for `` — derived from its public instance fields * (via `Props<>`) and the `[SLOTS]` declaration. */ export type CardProps = Props; class CardComponent { // Phantom signature: JSX reads it to infer accepted props. Runtime // constructs with no args — createElement assigns each prop via // property set afterwards. constructor(_props?: CardProps) {} // Named slots — keys flow into `CardProps` as `slot:header` / `slot:footer`. [SLOTS] = Slots.new(["header", "footer"] as const); // Default slot — CardProps picks this up as a typed `children` key. children: Child = Slot.new();; render() { return (
{/* Mount slot — "Default header" renders until filled */} {this[SLOTS].header("Default header")}
{this.children()}
{this[SLOTS].footer()}
); } } ``` Consumer JSX — fill slots via `slot:name` props: ```tsx My Title} slot:footer={} >

Main body content.

``` ElementsKit’s JSX runtime reads `slot:header` and calls `slot.set(

My Title

)` on the matching `SlotInstance`. ### Reactive slot content Pass a signal or `() => T` as slot content — the region updates in place when it changes: ```tsx const title = signal("Initial Title");

{title}

}> Body content
// Slot content updates reactively — no re-render of surrounding tree title("Updated Title"); ``` ### `SlotProps` type helper `Props` already infers slots from `[SLOTS]` — prefer that when you have a concrete class to point at. `SlotProps` is the **fallback** for cases where you can’t derive from an instance: function components, components that don’t declare `[SLOTS]`, or hand-written prop interfaces. ```tsx import type { Child, SlotProps } from "elements-kit/jsx-runtime"; // Declare which slot names are accepted alongside your own props interface CardProps extends SlotProps<"header" | "footer"> { title?: string; children?: Child; } // TypeScript now knows slot:header and slot:footer are valid Hi} /> ``` *** ## 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 (`Slots`) | | TypeScript `slot:name` prop | Via `IntrinsicElements` | Via `SlotProps` | 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. 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); ``` counter.css ```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. counter.styles.ts ```ts import css from "./counter.css?raw"; export const counterSheet = new CSSStyleSheet(); counterSheet.replaceSync(css); ``` counter.ts ```tsx 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 `