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 |
class GreetingElement extends HTMLElement { connectedCallback() { this.textContent = `Hello, ${this.getAttribute("name") ?? "world"}!`; }}
customElements.define("x-greeting", GreetingElement);<x-greeting name="Alice"></x-greeting><!-- β Hello, Alice! -->Adopt ElementsKit progressively
| 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:
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; }}render works the same way at the app root too β pass document.getElementById("app")! as the target. See Scopes & cleanup 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).
// Wrong β fails in Sandpack and some iframe sandboxesclass MyElement extends HTMLElement { constructor() { super(); this.style.display = "contents"; this.setAttribute("role", "none"); }}
// Right β defer to connectedCallbackclass MyElement extends HTMLElement { #count = 0; // private fields only
connectedCallback() { this.style.display = "contents"; if (!this.hasAttribute("role")) this.setAttribute("role", "none"); }}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.
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 refThe same pattern applies when registering with customElements.define directly β augment ElementsKit.CustomElementRegistry regardless of which registration call you use.
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 | Attributes vs properties, @attributes decorator, inheritance |
| Styling | CSSStyleSheet, adoptedStyleSheets, ?raw imports |
| Slots | Native <slot> (Shadow DOM) and ElementsKit Slot (Light DOM) |