Radiant
Radiant is a light-DOM platform for custom elements and DOM-attached controllers.
It keeps browser primitives visible instead of wrapping them in a synthetic component model. You work with real custom elements, real DOM events, real attributes, and real light-DOM children. Use RadiantElement when you want reactive fields, JSX-backed rendering, SSR host serialization, and hydration on a real custom element. Use RadiantController when you want behavior attached to existing DOM instead of defining a custom element.
Radiant deliberately renders into light DOM only; it has no shadow render mode. That keeps styling, DOM inspection, authored child content, and ARIA id references between host-owned and authored nodes simple, while giving up the encapsulation that conventional custom-element guidance usually prefers.
For the full docs site, see radiant.ecopages.app.
Installation
@ecopages/radiant is the core package. @ecopages/jsx provides TSX rendering, and @ecopages/signals provides the renderer-agnostic reactive layer underneath Radiant.
@ecopages/radiant depends on @ecopages/signals directly and declares @ecopages/jsx as a peer dependency. Installing radiant brings signals transitively, which also satisfies jsx's peer dependency on signals.
For the standard Radiant setup, install radiant and jsx:
bun add @ecopages/radiant @ecopages/jsx
Application code does not need to import JSX helpers or Signals primitives directly for every feature, but the current published Radiant surface expects @ecopages/jsx to be installed alongside it. Install @ecopages/signals explicitly only when you want the standalone reactive layer outside Radiant, or when using @ecopages/jsx without radiant.
RadiantElement Mental Model
RadiantElement is the structured base class for reactive custom elements.
@customElement(name, options) registers autonomous custom elements only. Passing a defined options.extends value to define a customized built-in throws a TypeError at class definition time, and extends is excluded from the TypeScript options type.
options.hostAttributes gives a RadiantElement host fixed attributes, such as { role: 'complementary' }. Server rendering writes them while it prepares the host, so they reach server output, and the client writes them at the start of every connect, before onConnected(). An attribute already on the host is never overwritten. A subclass inherits them and overrides them by name. State-dependent attributes stay with @bindTo and @onUpdated.
render()returns the current JSX view.- First connect automatically chooses between hydration and a fresh client render.
update()runs the update cycle now: batched@onUpdatedcallbacks, the render when the host overridesrender(), thenupdated(). It works on every host, so it also flushes@onUpdatedsynchronously on hosts withoutrender().requestUpdate()schedules that cycle in a microtask and coalesces repeated requests.updateCompleteresolves when the cycle finishes and rejects when an@onUpdatedcallback, render, orupdated()in it throws. A failed cycle discards its remaining queued work; a later write starts a fresh cycle.- Hosts observe their reactive members only while connected. Changes made before connect or while detached are reported on the next connect, so a shared
@signalsource never keeps a removed host alive. Server rendering never connects hosts; it drains pending@onUpdatedcallbacks during host preparation and never callsupdated(). @prop(...),@state, and@signal(...)define reactive members. A@propattribute defaults to the kebab-case property name (minWidthmaps tomin-width);@attruses the same conversion for its source attribute, with consecutive underscores collapsing to one hyphen.attributeon@propandsourceon@attroverride the default and preserve the explicit name. JSX continues to use the camelCase property. HTML lowercases attribute names, so choose a lowercase explicit name when consumers author it in HTML. Passtransformon@propto override default converters (type: Arrayis JSON unless you supplyfromAttribute/toAttribute).fromPropertynormalizes JS/JSX writes.toAttributereturningnullor''omits the reflected attribute without changing the assigned property.- The declared field type is the source of truth for
@prop(...),@attr(...),@signal(...),@state,@bindTo(...),@query(...),@event(...)and the context decorators: options that carry a value (defaultValue,initial,transform,converter,type, andsourceon@signal) must agree with it, and under standard decorators a mismatch is a compile error on the decorator.@prop({ type: Number, defaultValue: 0 }) label: stringdoes not compile, and@attr({ defaultValue: 'a' }) mode: 'a' | 'b'does. A single-element@query(...)field isT | null(T[]withall: true), so guard each use. Write a decorated field with an initializer,?or!:declareandaccessorfields do not compile. Legacy decorators (experimentalDecorators: true) never receive the field type, so none of these checks run there and a callback such as@bindTo'smapseesunknownunless you pass the whole field type,undefinedincluded, as a type argument (@bindTo<number>(...),@bindTo<WritableSignal<number>>(...)on a@signalfield). The rules per decorator, thetypeconstant and parsers are in Typing Decorated Fields. - An authored attribute stays on the host after upgrade, reflected or not:
reflectonly controls writing property changes back, so a non-reflected attribute is read at upgrade and not kept in sync afterwards. - Upgrade safety: a value assigned to a host before its tag is defined becomes an own property of the not-yet-upgraded element.
@propmembers adopt it, and it wins over an authored attribute, so consumers never need to defer an element definition to avoid losing input.@stateand@signalare internal members, not public input: they keep their initializer and discard such a value. In development, a host that upgrades with one warns once per tag and member, naming the member and pointing to@prop; production builds drop the warning andsetDevWarningsEnabled(false)from@ecopages/jsx/jsx-dev-runtimesilences it. A pre-upgrade assignment also shadows a class accessor (hand-writtenget/set, anaccessorfield, or a library accessor decorator such as MobX@observable accessor), so the accessor never sees it; that warns once per tag and member the same way; to recover after upgrade, save the value, delete the own property and assign it again so it goes through the setter. Keep library state in a store passed through@propor context, not in accessor members on the host. @prop({ attribute: false })makes a prop property-only: no observed attribute, an authored attribute of that name is ignored, it is never reflected and never serialized by server rendering. Use it for live objects such as class instances, which would otherwise leak their fields into the markup, lose their methods and, if cyclic, throw during SSR;reflect: truewith it throws. Serialized props are public markup, so keep private or large data out of a prop that keeps its attribute. In development, server rendering warns once per tag and prop when a prop that keeps its attribute holds a class instance, function or cycle.- Reflected properties serialize the current value on each write. A synchronous callback (
@bindTo,registerUpdateCallback) that normalizes the assignment is reflected too. @bindTo(...)copies a reactive field onto existing DOM when the host does not own arender()tree.@onUpdated(...)runs once for members changed in the same batch, before the render commits. If its callback changes a watched member, it can run again in the same cycle until state settles. Use it for procedures and for asking a render-owning host toupdate()/requestUpdate()when the view structure must change.updated()is for work that needs the committed DOM.this.bindings.key,this.$.key, andthis.bind('key')expose stable JSX bindings for reactive members.- If
render()is omitted, authored light-DOM children pass through unchanged. Copy fields onto that DOM with@bindTo(...). registerConnectedCallback()is a pre-sync hook: it runs synchronously when connection starts, before first-connect attribute catch-up. Use it only for setup that does not read authored property values.onConnected()runs after every connection, once attribute catch-up and (whenrender()is overridden) the initial hydrate/update have finished; use it instead ofconnectedCallback+queueMicrotask(sync)when work needs the synchronized host.FormAssociatedElementextendsRadiantElementfor native form controls. Each concrete subclass inherits the static FACE flag and owns oneElementInternals; implementformValue()andrestoreFormState(). OverrideformState()when submission can benullbefore a name is assigned or uses multi-entryFormData, so reset keeps the authored value. The first form value sync, normally in the first connect cycle, records that reset state.{this.slotted}places a render-owning host's children, and{this.slotted ?? fallback}gives a fallback, since it isnullwhen there are none. A view that renders<x-card>{children}</x-card>hands a Children Value to the host, and only the host's renderer touches the nodes. Named regions are properties (<x-card header={<h2 />}>rendered with{this.header}). Authored Children are captured once, at first render; nodes appended later are not slotted.this.slottedElementsreads the top-level elements the content rendered to.<slot>is an ordinary element. See Slots.
The key distinction is this:
- Use
@bindTowhen the host keeps authored DOM and a field should appear as an attribute, property, or text. - Use bindings such as
this.$.countwhen the host ownsrender(), the shape of the view stays the same, and only child values need to change. - Use
update()orrequestUpdate()when a reactive change affects the structure of the JSX tree.
Signal and store reads performed directly inside render() also participate in rerender invalidation, so shared reactive data can drive RadiantElement views without an extra wrapper layer.
Counter Example
This counter shows the intended RadiantElement style for stable templates: public props stay explicit, internal state stays local, and bound child values update without forcing a full rerender of the whole template.
/** @jsxImportSource @ecopages/jsx */
import type { JSX_PROPS } from '@ecopages/jsx';
import { RadiantElement, customElement, prop, state } from '@ecopages/radiant';
type CounterCardBindings = {
count: number;
label: string;
};
type CounterCardProps = {
label?: string;
};
@customElement('counter-card')
export class CounterCard extends RadiantElement<CounterCardBindings> {
declare readonly [JSX_PROPS]?: CounterCardProps;
@prop({ type: String, defaultValue: 'Clicks' }) label!: string;
@state count = 0;
private readonly increment = () => {
this.count += 1;
};
override render() {
return (
<section>
<h2>{this.$.label}</h2>
<p>Count: {this.$.count}</p>
<button type="button" on:click={this.increment}>
Increment
</button>
</section>
);
}
}
<CounterCard label="Clicks" />;
How this works:
@prop(...)makeslabelpart of the component's public reactive property and attribute surface.@stateis internal mutable state.this.$.labelandthis.$.countare cached JSX bindings, so those text positions can update directly when the underlying reactive members change.- This example does not call
update()because the view structure is stable. Only the bound child values change. JSX_PROPSdeclares a public type that is narrower than the internal binding shape. That is usually the right split.
If the same type truly represents both internal bindings and public JSX attributes, you can reuse it for both. Otherwise, keep those contracts separate.
When To Call update()
Call update() or requestUpdate() when a reactive change affects which elements should exist, not just their child values.
import { RadiantElement, onUpdated, state } from '@ecopages/radiant';
export class ResultsPanel extends RadiantElement<{ expanded: boolean }> {
@state expanded = false;
@onUpdated('expanded')
protected rerenderView(): void {
this.requestUpdate();
}
}
Use update() when you want the rerender immediately. Use requestUpdate() when several changes may happen in the same turn and one final rerender is enough.
Reactive JSX Bindings
Bindings are the non-obvious part of the API, and they are worth being explicit about.
RadiantElement<Bindings> takes a dedicated binding shape, not the full class type. That keeps the binding namespace limited to the reactive props or fields you want JSX to consume.
These three forms are equivalent:
this.bindings.countthis.$.countthis.bind('count')
They all resolve through the same cached binding object.
Use bindings when:
- the rendered element structure stays the same
- only text, attribute, or child-value positions need to change
- you want the renderer to patch the owned child range instead of rerunning the whole component view
Use @onUpdated(...) with update() or requestUpdate() when the reactive change should rebuild the JSX tree.
SSR And Hydration
Prefer the server pipeline for host HTML:
- import
@ecopages/radiant/server/install-ssr-runtimeonce at server boot (shim + ALS scope adapters) renderComponent(...)/renderComponentToString(...)from@ecopages/radiant/server/render-componentfor adapters and fragmentsrenderRadiantElementHostToString(...)from@ecopages/radiant/server/radiant-element-ssrfor lower-level host serializationrenderRadiantElementViewToString(host, options)from@ecopages/radiant/server/radiant-element-ssrserializes only the JSX view
There is no durable Element Host instance API named renderHostToString(). renderComponent() serializes each host once and wraps that markup as preview; it does not serialize the host a second time.
mode: 'hydrate' adds hydration markers for the component view and stamps each serialized host with a data-radiant-ssr attribute. A host reads that stamp once, at first connect, into isServerRendered. Use it, not hasHydrationMarkers, to tell whether a host's content came from the server: markers exist only where a view has bindings, and JSX children applied to a custom element as light DOM carry none. Plain-mode hosts are not stamped and report false; renderRadiantElementViewToString(host, options) returns the prepared view only, so a caller that writes the host tag itself must add SERVER_RENDERED_HOST_ATTRIBUTE (exported from @ecopages/radiant/server/radiant-element-ssr). The host removes the stamp once it has read it, as hydration markers are removed, so a connected host no longer carries it. A host inside server-rendered slotted content keeps the server's node, or comes from the slotted template with its stamp, so it reports true. A render-owning host in another host's view keeps its server node, state and focus when that parent hydrates, so it reports true too; one the parent's view rebuilds (its tag was defined only after the parent hydrated, or the server markup does not match the view) reports false. A page root with no binding of its own can still reconnect after such a nested host has hydrated or rendered itself, even if it updated since, when the page's static markup matches. First-connect hydration is explicit: SSR pages should import @ecopages/radiant/client/install-hydrator before loading component modules, or call installRadiantHydrator() from @ecopages/radiant/client/hydrator before custom elements upgrade. Without that client hydrator gate, SSR hosts fall back to a fresh client render on first connect.
Because Radiant's SSR environment shims window and document, global checks like typeof window !== 'undefined' or typeof document !== 'undefined' evaluate to true on the server. Guard top-level event listeners on document or window with !isServer from @ecopages/radiant/is-server so browser navigation and event listeners do not attach during SSR. This flag follows the package export condition, so it reads true under Node, Deno, Bun, Cloudflare Workers (workerd) and Vercel Edge (edge-light) even when a DOM shim is installed, and false in a browser bundle (the browser condition) and under any runtime that sets none of those conditions. Use a DOM capability check for code that depends on specific DOM methods.
createResource(...) starts from the Element Host client connect pass, after authored attributes synchronize. SSR serialization never starts resource fetches, regardless of the active package export condition. For server-rendered data, fetch before rendering and provide the value through initialize, prepareHost, or host attributes.
Server runtime setup, fragment rendering helpers, and SSR-specific import guidance live in src/server/README.md.
For the client lifecycle and hydration flow diagram, see src/core/README.md.
Event Handling
Radiant does not invent a synthetic event layer. JSX handlers and decorators work with the native browser event object directly.
| Use this | When you want | Runtime shape |
|---|---|---|
on:* in JSX | The normal event API | Auto-delegates a fixed allowlist of bubbling events and falls back to direct listeners otherwise |
on-native:* in JSX | Exact element-level browser listener semantics | Always calls addEventListener(...) on that element |
@onEvent(...) | Class-level listening from RadiantElement | Supports selector, ref, window, and document targets |
@event(...) | A typed custom event emitter owned by the component | Dispatches a real CustomEvent from the host element |
How JSX Event Binding Works
on:* is the default JSX event API. For a fixed allowlist of bubbling events, Radiant attaches one listener per event type on the render root and dispatches to matched elements. For all other events, on:* attaches directly on the element.
Today the delegated allowlist is:
beforeinputclickcontextmenudblclickfocusinfocusoutinputkeydownkeyupmousedownmouseoutmouseovermouseuppointerdownpointeroutpointeroverpointeruptouchendtouchmovetouchstart
Use on-native:* when exact attachment semantics matter, such as when an ancestor may stop propagation before the delegated root listener runs or when you want to opt out of delegation for a supported bubbling event.
Events outside the allowlist, such as focus, blur, scroll, load, invalid, or dragstart, already attach directly when authored with on:*.
@onEvent(...)
At the class level, @onEvent(...) is the main decorator for incoming DOM events. It can listen to:
- descendants that match a CSS selector
- descendants marked with
data-ref - the host itself (
host: true), including non-bubbling events such aspointerenterandpointerleave - global
windowevents - global
documentevents
/** @jsxImportSource @ecopages/jsx */
import { RadiantElement, customElement, onEvent, state } from '@ecopages/radiant';
@customElement('keyboard-panel')
export class KeyboardPanel extends RadiantElement<{ lastKey: string }> {
@state lastKey = 'none';
@onEvent({ document: true, type: 'keydown' })
protected onKeydown(event: KeyboardEvent): void {
this.lastKey = event.key;
}
override render() {
return <p>Last key: {this.$.lastKey}</p>;
}
}
Selector- and ref-based @onEvent(...) handlers, and subscribeEvent(...) / subscribeEvents(...), match with Element.closest(...) from the event target, so a click on a nested icon still reaches a wrapping button[data-ref] or selector match. They still rely on bubbling, so use focusin and focusout instead of focus and blur for that pattern.
Each subscribeEvent(...) registration is independent, including duplicate type and selector pairs. Unsubscribing one listener does not remove another, and disconnect removes every remaining registration.
@event(...)
Outgoing component events use @event(...), which gives the class a typed EventEmitter. Calling .emit(detail) dispatches a real CustomEvent from the host element.
import type { EventEmitter } from '@ecopages/radiant/tools/event-emitter';
import { RadiantElement, customElement, event } from '@ecopages/radiant';
type SaveDetail = {
id: string;
};
@customElement('save-button')
export class SaveButton extends RadiantElement {
@event({ name: 'save-requested', bubbles: true, composed: true })
saveRequested!: EventEmitter<SaveDetail>;
requestSave(): void {
this.saveRequested.emit({ id: 'draft-1' });
}
}
The mental model stays simple:
- DOM events come in through JSX handlers or
@onEvent(...) - component events go out through
@event(...) - both are ordinary browser events at runtime
Development Warnings
Radiant logs development warnings when a delegated @onEvent(...) listener uses a non-bubbling event type, such as focus, and when a tag is defined after a view rendered it. The warnings require both the Radiant process.env.NODE_ENV development gate and the shared @ecopages/jsx switch: setDevWarningsEnabled(false) silences both packages, while true or undefined enables them again. The published files keep the process.env.NODE_ENV check that gates them, so the app's build decides, not Radiant's: the warnings show when the bundler sets process.env.NODE_ENV to anything other than "production", and a production build removes them along with their text. What counts as a production build depends on the bundler: esbuild sets "production" for a browser-platform build with minification on, Vite for vite build unless NODE_ENV says otherwise, webpack for mode: "production", and Bun for --production. A page that loads the files without a bundler, where process does not exist, gets no warnings and no error.
The JSX hydration and DOM anchor warnings come from @ecopages/jsx and follow its own switch; see "Dev Warnings" in the JSX README.
Contributors: wrap a development warning in try { if (process.env.NODE_ENV !== 'production' && areJsxDevWarningsEnabled()) { ... } } catch {}. The try keeps a page without process from throwing, and keeping both checks inside it lets every bundler drop the block in production; see warnNonBubblingDelegatedEvent in src/helpers/create-event-listener.ts and setChildrenValue in src/core/render-runtime.ts. test/build/dist-dev-warnings.test.ts fails when a built dist file reads process.env.NODE_ENV outside that shape, and bundles an app against the dist for development, for production, and with the check left unreplaced. test/build/dist-dev-warnings.browser.test.ts runs a development and a production bundle of an app in Chromium and checks which warnings reach console.warn. Both run in their own node-dist and browser-dist vitest projects, whose globalSetup (test/build/dist-global-setup.ts) runs only when a test/build file is in the run. It rebuilds dist with build:lib when the content hash of build.ts, package.json, the two tsconfig files, scripts and src differs from the one recorded at the last build (node_modules/.cache/dist-source-hash), so a whitespace edit needs no manual build. Under CI it never rebuilds and only requires dist to exist; a failed rebuild points at pnpm --filter @ecopages/radiant build:lib.
Public Entry Points
These are the documented public import paths exposed by the package.
| Path | Use for |
|---|---|
@ecopages/radiant | Main client entrypoint. Re-exports RadiantElement, RadiantController, reactive JSX bindings, common decorators, and controller-registry helpers such as startControllers(...) |
@ecopages/radiant/context | Context-related exports as a grouped entrypoint |
@ecopages/radiant/context/create-context | Creating context keys |
@ecopages/radiant/context/context-provider | Low-level context provider class |
@ecopages/radiant/context/consume-context | @consumeContext(...) decorator |
@ecopages/radiant/context/provide-context | @provideContext(...) decorator |
@ecopages/radiant/context/context-selector | @contextSelector(...) decorator — bind a field to context |
@ecopages/radiant/context/on-context-update | @onContextUpdate(...) decorator — run a method on context change |
@ecopages/radiant/context/events | Context request / subscription event types |
@ecopages/radiant/controller-registry | Controller registration and activation helpers |
@ecopages/radiant/core/radiant-element | Non-JSX reactive custom-element base |
@ecopages/radiant/core/radiant-controller | DOM-attached controller base |
@ecopages/radiant/form-associated-element | FormAssociatedElement base for hosts that list on native FormData |
@ecopages/radiant/helpers/create-query | Low-level query helper for component fields |
@ecopages/radiant/helpers/create-event | Low-level typed custom-event helper |
@ecopages/radiant/helpers/create-event-listener | Low-level DOM event-listener helper |
@ecopages/radiant/helpers/debounce | Debounce helper without the decorator surface |
@ecopages/radiant/is-server | Boolean flag (isServer) for branching between server and browser environments |
@ecopages/radiant/client/hydrator | Explicit client hydrator installer and status helpers for SSR pages |
@ecopages/radiant/client/install-hydrator | Side-effect entrypoint that enables first-connect hydration before component modules load |
@ecopages/radiant/client/app-bootstrap | prepareRadiantApp(...) app-shell bootstrap helpers |
@ecopages/radiant/signals/host-resource | Low-level HostResource, createHostResource(...), and createResource(...) helpers |
@ecopages/radiant/server/install-ssr-runtime | Side-effect server boot entry: light-DOM shim plus JSX SSR scope adapters |
@ecopages/radiant/server/install-light-dom-shim | Side-effect shim install only (prefer install-ssr-runtime for full Radiant SSR) |
@ecopages/radiant/server/light-dom-shim | Minimal SSR window and host-preparation helpers |
@ecopages/radiant/server/radiant-element-ssr | Lower-level host serialization (renderRadiantElementHostToString defaults to plain mode; see src/server/README.md for the full export tiers) |
@ecopages/radiant/server/render-component | Canonical component SSR helpers; default hydrate mode |
@ecopages/radiant/server/render-controller | Controller-host SSR helpers and controller-specific host option types |
@ecopages/radiant/server/project-root | Project-root resolution helper for server adapters |
@ecopages/radiant/decorators/attr | @attr(...) |
@ecopages/radiant/decorators/bind-to | @bindTo(...) |
@ecopages/radiant/decorators/bound | @bound |
@ecopages/radiant/decorators/controller | @controller(...) |
@ecopages/radiant/decorators/custom-element | @customElement(...) |
@ecopages/radiant/decorators/debounce | @debounce(...) |
@ecopages/radiant/decorators/event | @event(...) |
@ecopages/radiant/decorators/on-event | @onEvent(...) |
@ecopages/radiant/decorators/on-updated | @onUpdated(...) |
@ecopages/radiant/decorators/prop | @prop(...) |
@ecopages/radiant/decorators/query | @query(...) |
@ecopages/radiant/decorators/signal | @signal(...) |
@ecopages/radiant/decorators/state | @state |
@ecopages/radiant/tools/escape-script-json | Safe JSON-for-script serialization helper |
@ecopages/radiant/tools/event-emitter | Low-level EventEmitter helper |
ValueFieldDecorator, BoundedFieldDecorator and the host types they name are exported from @ecopages/radiant (ContextHostLike and ContextUpdateMethod from @ecopages/radiant/context), so a library that exports a shared decorator, such as export const count = prop({ type: Number }), can emit declarations.
Prefer focused public subpaths when bundle size matters or when a module only needs one narrow runtime surface. The root entrypoint remains the ergonomic default for common app code, but context, registry, helper, server, signal-resource, and low-level tool imports are better taken from their explicit public subpaths.
For controller authoring, prefer a single import surface per module. If a file mixes RadiantController, @controller(...), @state, @attr(...), or startControllers(...), default to @ecopages/radiant so controller registration and startup come from the same entrypoint.
Important: context APIs, controller registry helpers, helper factories, server APIs, and low-level tools have explicit public subpaths. Prefer those focused paths for library and adapter code, or when a module intentionally wants only the registry runtime without the broader client surface; the root entrypoint remains the ergonomic app entrypoint for the common client surface.
For SSR-specific guidance and examples, see src/server/README.md.