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.
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) |