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.


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

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 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:

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> }
HelperShapeConsume via
PropertiesOf<C>Public instance fields, HTMLElement surface droppedproperty assignment / framework prop binding
AttributesOf<C>static [ATTRIBUTES] keys → string | nullsetAttribute, HTML markup
EventsOf<C>static events map, verbatimaddEventListener, framework event bindings

Typed listeners fall out of an HTMLElementEventMap augmentation:

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

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.

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

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