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.

main.tsxstate.ts
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 principle

Passing 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…");
1 / 4

Children

Any of the following are valid element children:

TypeBehaviour
string / numberStatic text node, set once
Signal<T>Live text node β€” updates in place when signal changes
() => TLive thunk β€” re-evaluated on every dep change
Element / NodeAppended directly
null / false / undefinedNothing rendered
ArrayEach 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:

SyntaxEffectEquivalent
on:click={fn}Event listener (case-preserving)el.addEventListener("click", fn)
class:active={bool}Reactive class toggleeffect(() => el.classList.toggle("active", bool()))
style:color={value}Reactive inline styleeffect(() => el.style.color = value())
prop:foo={val}Force property assignmentel.foo = val (skips setAttribute)
ref={fn}Ref callbackfn(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 attribute

In 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()))

See also