Extend Component, return an html template, and add only the decorators you need.
Web Components engine
Build components without leaving the platform behind.
Ion gives Custom Elements a compact reactive model for rendering, state, properties, lifecycle, events, plugins and hooks.
Components remain real custom elements with a real shadow root, native attributes, events and DOM.
Render scheduling, reconnect behavior, and owned event cleanup are part of the component model.
01 · Start
Install
pnpm add @gottheflag/ion
Ion exposes separate entry points so optional subsystems stay explicit and bundlers can remove unused code.
02 · Component
Your first component
import {
Component,
Ion
} from "@gottheflag/ion";
import {
state
} from "@gottheflag/ion/decorators";
import {
html
} from "@gottheflag/ion/template";
@Ion.create("x-counter")
class Counter extends Component {
@state
private count = 0;
protected override created() {
this.on(
"click",
"#increment",
() => {
this.count++;
}
);
}
protected override render() {
return html`
<button id="increment">
Count: ${this.count}
</button>
`;
}
}
Changing reactive state schedules one render. Ion reconciles the returned template into the existing shadow DOM instead of replacing the whole root.
03 · Registration
Ion.create()
Register while declaring the class:
@Ion.create("x-panel")
class Panel extends Component {}
Or register later:
class Panel extends Component {}
Ion.create(
Panel,
"x-panel"
);
Shadow root options
@Ion.create(
"x-dialog",
{
root: {
mode: "open",
delegatesFocus: true
}
}
)
class Dialog extends Component {}
04 · Lifecycle
A clean component cycle
| Method | When it runs |
|---|---|
created() | After a real DOM connection. Runs again after a true disconnect/reconnect. |
ready() | After the first successful render of that component instance. |
removed() | After a genuine detach. Same-tick DOM moves do not create a false teardown. |
adopted(oldDoc, newDoc) | When the element moves to another document. |
attributeChanged(...) | When an observed attribute changes. |
protected override created() {
this.on(
window,
"resize",
() => {
this.requestRender();
}
);
}
Listeners registered through this.on() belong to the current component lifetime and are released automatically on a real detach.
05 · Rendering
Templates
protected override render() {
return html`
<h2>${this.title}</h2>
<ul>
${this.items.map(
item => html`<li>${item}</li>`
)}
</ul>
`;
}
| Interpolation | Behavior |
|---|---|
| string / number / bigint | Serialized with HTML-significant characters escaped. |
| null / undefined / boolean | Renders nothing. |
nested html | Rendered recursively. |
| arrays | Recursively serialized using the same contract. |
| object / function / symbol / DOM Node | Rejected instead of silently stringified. |
Escaping protects serialization boundaries. Values used in URL-like or otherwise security-sensitive attributes should still be validated for that semantic context.
06 · Reactivity
@state
Use state for internal reactive values. A changed value requests a render; writing the same value again is ignored through Object.is.
@state
private open = false;
toggle() {
this.open = !this.open;
}
07 · Public state
@property()
@property({
type: Number,
reflect: true,
render: true
})
value = 0;
@property({
attribute: "busy",
type: Boolean
})
loading = false;
| Option | Default | Purpose |
|---|---|---|
type | String | String, Number, or Boolean conversion from attributes. |
reflect | true | Reflect property writes to the matching attribute. |
render | true | Request a render after a changed value. |
attribute | property name | Override the HTML attribute name. |
08 · Events
Own listeners and emit typed events
Delegated event
this.on(
"click",
"[data-remove]",
(event, matched) => {
matched.remove();
}
);
Explicit target
this.on(
window,
"keydown",
event => {
if (event.key === "Escape") {
this.close();
}
}
);
Typed custom event
declare global {
interface HTMLElementEventMap {
"counter:change":
CustomEvent<{
value: number;
}>;
}
}
this.emit(
"counter:change",
{
value: this.count
}
);
Custom events bubble and cross the shadow boundary by default. Override bubbles, composed, or cancelable when needed.
09 · DOM state
Queries and watchers
@query
@query("#search")
private search:
HTMLInputElement | null = null;
@query(".row", {
all: true
})
private rows:
HTMLElement[] = [];
Query properties are refreshed after every render. They are snapshots of the current rendered DOM.
@watch
@watch("mode")
private modeChanged(
oldValue: string | null,
newValue: string | null
) {
console.log(
oldValue,
newValue
);
}
10 · Styling
Constructable stylesheets
Define styles as a CSS string, a CSSStyleSheet, or an array containing both.
protected static override styles = `
:host {
display: block;
}
button {
border-radius: 0.5rem;
}
`;
11 · Small tools
Cache and once
@cacheCache a getter or zero-argument method on first evaluation. Parameterized methods are rejected.
@cache
get expensiveValue() {
return compute();
}
@onceRun a method at most once for each instance. Later calls are skipped.
@once
initialize() {
connect();
}
12 · Extension
Plugins
Plugins are optional. Import the plugin entry point only when the application needs the plugin runtime.
import {
Plugin
} from "@gottheflag/ion/plugin";
class MetricsPlugin
extends Plugin<
Panel,
{
label: string;
}
> {
get label() {
return this.options.label;
}
protected afterRender() {
console.log(
"rendered",
this.host
);
}
}
Panel.use(
MetricsPlugin,
{
label: "dashboard"
}
);
Read a plugin API from an instance with this.plugin(MetricsPlugin). Plugin lifecycle callbacks cover creation, ready, render boundaries, attribute changes, adoption and removal.
13 · Instrumentation
Hooks
Hooks observe lifecycle phases globally or for one component class. They are best suited to instrumentation and cross-cutting extensions.
import {
hok
} from "@gottheflag/ion/hook";
const handle =
hok.afterFor(
Panel,
"render",
host => {
console.log(
"rendered",
host
);
}
);
// later
handle.off();
Phases include define, init, create, render, ready, remove, attribute change, adoption, event emission, and plugin installation.
14 · Component API
Helpers
| API | Purpose |
|---|---|
this.$("#id") | First matching element inside the shadow root. |
this.$$(".row") | All matching elements inside the shadow root. |
this.attr("name") | Read an attribute. |
this.attr("name", value) | Write, toggle, or remove an attribute. |
this.requestRender() | Queue one connection-safe render. |
this.on(...) | Register an owned event listener. |
this.emit(...) | Dispatch a custom event. |
15 · Reference
Import map
| Entry point | Main exports |
|---|---|
@gottheflag/ion | Component, Ion, utility helpers |
@gottheflag/ion/template | html, template types |
@gottheflag/ion/decorators | state, property, query, watch, on, cache, once |
@gottheflag/ion/plugin | Plugin, withDefaults |
@gottheflag/ion/hook | hok, hook utilities and types |
A component owns its state, renders a template, reacts to platform events, and releases lifetime-owned work when it truly leaves the DOM. Reach for plugins or hooks when a concern really crosses component boundaries.
Back to the first component