Skip to content

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:

CallbackWhen it fires
connectedCallbackElement attached to the DOM
disconnectedCallbackElement removed from the DOM
attributeChangedCallbackA 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

StepElementsKitWhat you gain
1β€” (plain browser API)Zero deps, runs anywhere
2signals + renderReactive state, scoped cleanup via a single unmount thunk
3JSX runtimeDeclarative DOM, live text and attribute bindings replace manual effects
4@reactive decoratorNatural class-field syntax, derived values with computed
5@attributesHTML attribute ↔ reactive property wiring
6defineElementTyped 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

TopicWhat it covers
AttributesAttributes vs properties, @attributes decorator, inheritance
StylingCSSStyleSheet, adoptedStyleSheets, ?raw imports
SlotsNative <slot> (Shadow DOM) and ElementsKit Slot (Light DOM)

Playground

See also