# Custom Elements

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
<x-greeting name="Alice"></x-greeting>
<!-- → Hello, Alice! -->
```

***

## Adopt ElementsKit progressively

```tsx
// Step 6 — defineElement + typed JSX: strict registration, typed props
import { computed, reactive } from "elements-kit/signals";
import { attributes, ATTRIBUTES as attr } from "elements-kit/attributes";
import { render } from "elements-kit/render";
import { defineElement } from "elements-kit/custom-elements";

@attributes
class CounterElement extends HTMLElement {
  static [attr] = {
    count(this: CounterElement, value: string | null) {
      this.count = Number(value ?? 0);
    },
  };

  @reactive() count = 0;
  doubled = computed(() => this.count * 2);
  #unmount?: () => void;

  #template = () => (
    <section>
      <p>
        <strong>{() => this.count}</strong>
        {" × 2 = "}
        <strong>{this.doubled}</strong>
      </p>
      <button on:click={() => this.count++}>+1</button>{" "}
      <button on:click={() => this.count--}>−1</button>
    </section>
  );

  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;
    }
  }
}
// <x-counter count={5} /> — 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 <time>{() => this.#time().toLocaleTimeString()}</time>;
  }

  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.

***

## 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");
  }
}
```

> **`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 `<x-my-element />` 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;
    }
  }
}

// <x-counter /> — 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<typeof XRange>; // { min?: number; value?: number }
type A = AttributesOf<typeof XRange>; // { min?: string | null; variant?: string | null }
type E = EventsOf<typeof XRange>;     // { commit: CustomEvent<number> }
```

| Helper            | Shape                                                 | Consume via                                  |
| ----------------- | ----------------------------------------------------- | -------------------------------------------- |
| `PropertiesOf<C>` | Public instance fields, `HTMLElement` surface dropped | property assignment / framework prop binding |
| `AttributesOf<C>` | `static [ATTRIBUTES]` keys → `string \| null`         | `setAttribute`, HTML markup                  |
| `EventsOf<C>`     | `static events` map, verbatim                         | `addEventListener`, framework event bindings |

Typed listeners fall out of an `HTMLElementEventMap` augmentation:

```ts
declare global {
  interface HTMLElementEventMap extends EventsOf<typeof XRange> {}
}

el.addEventListener("commit", (e) => e.detail); // e: CustomEvent<number>
```

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 (
      <article>
        <header>{this.header}</header>
        <main>{this.children}</main>
      </article>
    );
  }
}

// <Card header={<h1>Title</h1>}>body content…</Card>
```

`applyProps` sets `this.header = <h1>Title</h1>` 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 `<slot>` (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)
