Elements
JSX compiles to real DOM elements. Reactive props are effects under the hood.
An element is a real DOM node. JSX in ElementsKit compiles directly to document.createElement β there is no virtual DOM, no diffing, no reconciliation. Every expression produces an actual node you can hold in a variable, append to the DOM, or pass to a function.
import { computed } from "elements-kit/signals";
import { text, color, bold } from "./state";
export class App {
render() {
return (
<section style="padding: 1.5rem; font-family: sans-serif">
<h2>Reactive Props</h2>
{/* Live text binding */}
<p
style:color={color}
style:font-weight={computed(() => (bold() ? "bold" : "normal"))}
>
{text}
</p>
{/* Controls */}
<label style="display: block; margin: 8px 0">
Text:{" "}
<input
value={text}
on:input={(e: Event) => text((e.target as HTMLInputElement).value)}
/>
</label>
<label style="display: block; margin: 8px 0">
Color:{" "}
<input
type="color"
value={color}
on:input={(e: Event) => color((e.target as HTMLInputElement).value)}
/>
</label>
<label style="display: block; margin: 8px 0">
<input
type="checkbox"
checked={bold}
on:change={() => bold(!bold())}
/>{" "}
Bold
</label>
</section>
);
}
}
Configure JSX once in tsconfig.json:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "elements-kit"
}
}Or use a per-file pragma instead:
/* @jsxImportSource elements-kit */JSX = createElement
Every JSX tag is a createElement call. Props become attributes or event listeners, children become appended nodes.
// This JSX:
const el = (
<button class="primary" on:click={() => submit()}>
Save
</button>
);
// Compiles to exactly this:
const el = document.createElement("button");
el.setAttribute("class", "primary");
el.addEventListener("click", () => submit());
el.append("Save");No framework runtime is involved. The element is a plain HTMLButtonElement the moment the expression evaluates.
Reactive props = effects
When you pass a signal or a () => T function as a prop, ElementsKit creates an effect behind the scenes that keeps the DOM in sync. Every time the signal changes, the effect re-runs and re-assigns the property β just like writing it manually with effect.
import { , } from "elements-kit/signals";
const = ("Save");
const = (false);
// This reactive JSX:
const = < ={}>{}</>;
// Is equivalent to:
const = .("button");
(() => { .disabled = (); });
(() => { /* text node updated in place */ });
// label signal drives a live text node β same principlePassing a plain value (not a signal or function) sets the prop once and never updates it β no effect is created.
Plain () => T works for children, not attributes
Children accept Signal<T>, Computed<T>, or a plain () => T thunk. Attributes accept only Signal / Computed β a plain arrow falls through to the static path and never updates. Wrap the expression in computed(...) or pass a signal directly.
// Wrong β hidden is set once, never re-evaluated
<nav hidden={() => !isHome()} />
// Right β pass a Signal or Computed
<nav hidden={computed(() => !isHome())} />// Static β set once at render time
<input placeholder="Enter text" maxLength={100} />
// Reactive β updates on every change
<input placeholder={hint} maxLength={computed(() => limit())} />// Static prop β set once, no tracking
const el = <input placeholder="Searchβ¦" />;
// Equivalent:
const el = document.createElement("input");
el.setAttribute("placeholder", "Searchβ¦");
Children
Any of the following are valid element children:
| Type | Behaviour |
|---|---|
string / number | Static text node, set once |
Signal<T> | Live text node β updates in place when signal changes |
() => T | Live thunk β re-evaluated on every dep change |
Element / Node | Appended directly |
null / false / undefined | Nothing rendered |
Array | Each item rendered in order |
const show = signal(true);
const count = signal(0);
<div>
Static text
{count} // live β updates in place
{() => count() * 2} // thunk β also live
{() => show() && <strong>Hi</strong>} // conditional element
{[1, 2, 3].map((n) => <li>{n}</li>)} // array of elements
</div>Prop namespaces
ElementsKit extends standard HTML props with namespaced prefixes for common reactive patterns:
| Syntax | Effect | Equivalent |
|---|---|---|
on:click={fn} | Event listener (case-preserving) | el.addEventListener("click", fn) |
class:active={bool} | Reactive class toggle | effect(() => el.classList.toggle("active", bool())) |
style:color={value} | Reactive inline style | effect(() => el.style.color = value()) |
prop:foo={val} | Force property assignment | el.foo = val (skips setAttribute) |
ref={fn} | Ref callback | fn(el) after element is created |
const active = signal(false);
const color = signal("blue");
<button
class:active={active}
style:color={color}
on:click={() => active(!active())}
ref={(el) => console.log("Button element:", el)}
>
Toggle
</button>Reactive styles: prefer style:foo
When a style property changes reactively, style:foo={signal} writes only that property. The bulk form style={{ color: c(), background: b() }} re-runs Object.assign on every effect tick, rewriting every key β even unchanged ones. For a single static object, style={obj} is fine; for any value driven by a signal, reach for style:foo.
// Slow on tick β Object.assign rewrites both keys every time c() changes
<div style={{ color: c(), background: "white" }} />
// Fast β only the color property is written when c() changes
<div style:color={c} style:background="white" />Large lists: consider event delegation
on:click={fn} calls addEventListener on the element. For lists or tables with hundreds of rows where every row carries the same handler, that's N listeners β measurable mount cost. For those cases, attach a single listener to the parent and look up the row from a data source via data-id. See the event delegation recipe.
Attributes from JSX
When the JSX runtime encounters a prop on a custom element it resolves it in this order:
value is reactive (Signal / Computed / () => T)?
yes β effect(() => resolve(el, key, value())) // live, re-runs on change
no β resolve(el, key, value) // set once
resolve(el, key, value):
key in el (property exists β e.g. @reactive getter/setter)?
yes β el[key] = value // property assignment
no β el.setAttribute(key, String(value)) // HTML attributeIn practice, given @reactive() count = 0 on the element:
// 1. Reactive β effect(() => el.count = countSignal())
// @reactive setter fires on every change β DOM updates
<x-counter count={countSignal} />
// 2. Static + property exists (count is @reactive) β el.count = 5
// Property assignment, NOT setAttribute
<x-counter count={5} />
// 3. Static + no property on element β setAttribute("data-id", "abc")
// Falls back to setAttribute because "data-id" is not in el
<x-counter data-id="abc" />If the element has no count property at all (not @reactive, not defined), JSX would call setAttribute("count", "5") β which triggers attributeChangedCallback if count is listed in observedAttributes.
This means you can expose an attribute-only API (no JS property) and still receive values from JSX:
@attributes
class BadgeElement extends HTMLElement {
static [attr] = {
// No @reactive property β value arrives only via setAttribute / HTML
label(this: BadgeElement, value: string | null) {
this.querySelector("span")!.textContent = value ?? "";
},
};
// No "label" property defined β JSX always uses setAttribute
}<x-badge label="New" /> // setAttribute("label", "New")
<x-badge label={title} /> // effect(() => el.setAttribute("label", title()))