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.
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> }| 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:
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 DOMel.header = "plain text"; // native append() contentel.header = "updated"; // replaces the previous content in placeel.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 — aNode, a string, or an array;nullclears. 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 | Attributes vs properties, @attributes decorator, inheritance |
| Styling | CSSStyleSheet, adoptedStyleSheets, ?raw imports |
| Slots | Native <slot> (Shadow DOM) and ElementsKit Slot (Light DOM) |