JSX Custom Element Types

When a custom element class is available, use the class directly in JSX and declare its public props with the JSX_PROPS symbol.

Using The Class

import type { JSX_PROPS } from '@ecopages/jsx';
 
type UserCardProps = {
	name: string;
	isAdmin: boolean;
};
 
class UserCard extends HTMLElement {
	declare readonly [JSX_PROPS]?: UserCardProps;
}
 
customElements.define('user-card', UserCard);
 
<UserCard name="Ada" isAdmin={false} />;

JSX_PROPS declares unprefixed public props. Required fields remain required. Explicit prop:* bindings use the custom element instance type; attr:* bindings use DOM attribute types.

prop:* accepts any writable, non-method instance property. Readonly fields and getters without a setter are rejected, because assigning them throws or replaces state the element owns. Every public field is writable this way, including Radiant @state fields: decorators do not change a field's type, so JSX cannot tell @prop from @state. Mark internal state protected or private to keep it out of prop:*.

A subclass can widen the props by redeclaring declare readonly [JSX_PROPS]?: WiderProps (no override; declare override does not compile). A class meant to be extended can instead take its public props as a type parameter:

class UserCard<Props extends UserCardProps = UserCardProps> extends RadiantElement {
	declare readonly [JSX_PROPS]?: Props;
	@prop({ type: String }) name = '';
}
 
class AdminCard extends UserCard<UserCardProps & { level: string }> {
	@prop({ type: String }) level = '';
}

Typing By Tag Name

If JSX uses a tag name without importing the class, add that tag to HTMLElementTagNameMap. The class's JSX_PROPS declaration supplies the same public prop contract as the class form.

import type { JSX_PROPS } from '@ecopages/jsx';
 
type UserCardProps = {
	name: string;
	isAdmin: boolean;
};
 
class UserCard extends HTMLElement {
	declare readonly [JSX_PROPS]?: UserCardProps;
}
 
declare global {
	interface HTMLElementTagNameMap {
		'user-card': UserCard;
	}
}
 
customElements.define('user-card', UserCard);
 
<user-card name="Ada" isAdmin={false} />;

Unprefixed props come from JSX_PROPS; prop:* bindings are typed from the element instance. Existing JsxCustomIntrinsicElements augmentations remain available as a supported compatibility path.

Binding Defaults For Custom Elements

Unprefixed names on custom elements default to property bindings. A fixed set of names keeps attribute semantics because they describe obvious HTML markup:

class, dir, hidden, id, lang, part, role, slot, style, tabindex, title — plus every data-* and aria-* name.

This split is deliberate: markup names serialize so SSR output stays meaningful, while everything else passes real values as properties. Use attr:* to force serialization of any other name, and prop:* to override the attribute defaults explicitly.

Worked Example

<UserGrid id="people" class="panel" items={rows} selection={currentRow} attr:status="ready" prop:api={gridApi} />
import type { JSX_PROPS } from '@ecopages/jsx';
 
type UserGridRow = {
	id: string;
};
 
type UserGridProps = {
	items: UserGridRow[];
	selection?: UserGridRow;
};
 
type UserGridApi = {
	focusRow(id: string): void;
};
 
class UserGrid extends HTMLElement {
	declare readonly [JSX_PROPS]?: UserGridProps;
	api?: UserGridApi;
}
 
const rows: UserGridRow[] = [{ id: '1' }];
const currentRow = rows[0];
const gridApi: UserGridApi = {
	focusRow: (_id) => undefined,
};
 
customElements.define('user-grid', UserGrid);
 
<UserGrid items={rows} selection={currentRow} prop:api={gridApi} />;

In that example:

  • items and selection are typed from UserGridProps (defaults to property binding on the instance)
  • id and class are typed and serialized as attributes
  • attr:status serializes to markup even though status is not an attribute default
  • prop:api is typed from UserGrid.api and never serializes to HTML

Design Note: Custom Elements Versus Radiant Hosts

JSX itself has two escape seams for custom elements, not Radiant-specific branches:

  • Generic SSR contract. Any registered tag containing - whose element implements renderHostToString(options?) can be serialized directly by @ecopages/jsx/server. This contract is for third-party custom elements that opt into JSX SSR directly.
  • Framework render hook. RadiantElement hosts do not expose a durable instance method named renderHostToString(). Radiant installs a server custom-element render hook and serializes hosts through its own pipeline.

See JSX SSR for the serialization rules and scope helpers, and Component SSR for the Radiant adapter path.