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 (
{" "}
);
}
```
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 (
);
}
}
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 (
`](/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 = `
{" "}
);
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}
);
}
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 `