Typing Custom Elements in JSX and TSX: Per-Runtime Intrinsic Element Augmentation

HTMLElementTagNameMap makes document.querySelector('wfc-banner') type correctly and does nothing at all for <wfc-banner tone="warning" /> in a TSX file. JSX resolves unknown lowercase tags through an entirely separate interface — JSX.IntrinsicElements — and every JSX runtime declares its own. React’s is not Preact’s, Preact’s is not Solid’s, and React 19 changed the shape of its own again.

The result is a package that must augment several unrelated interfaces to be usable across the ecosystem, while shipping none of them unconditionally, because declaring an interface belonging to a framework the consumer has not installed is itself a compile error. This deep dive belongs to Types & the Custom Elements Manifest inside Distribution, Testing & Tooling.

Problem Statement: The Tag JSX Has Never Heard Of

import '@wfc/components/banner';

export function Toolbar() {
  return (
    <wfc-banner tone="warning" dismissible>
      {/*  ~~~~~~~~~~ Property 'wfc-banner' does not exist on type 'JSX.IntrinsicElements'. */}
      Deployment is degraded.
    </wfc-banner>
  );
}

The package already augments HTMLElementTagNameMap, so the same component types perfectly in a .ts file. In .tsx it does not compile at all. The workaround that spreads through codebases is a cast to any:

const Banner = 'wfc-banner' as any;
return <Banner tone="warning">Deployment is degraded.</Banner>;

which compiles, discards every attribute check, and hides the second, subtler problem: even when the tag is accepted, JSX sets attributes, not properties, so an object-valued prop is stringified to [object Object] and an onBannerDismiss prop is not wired to anything.

Two separate lookups: DOM query typing and JSX intrinsic typing A tag name map augmentation serves querySelector and createElement, while JSX resolves the same tag through a runtime-specific intrinsic elements interface that must be augmented separately. tag: wfc-banner one component, two type paths DOM path (.ts) HTMLElementTagNameMap one global augmentation works for every consumer ship it unconditionally JSX path (.tsx) JSX.IntrinsicElements one interface per runtime React, Preact, Solid all differ ship behind an opt-in entry point

Root-Cause Analysis

TypeScript type-checks a JSX element whose tag is a lowercase identifier by looking it up as a property of JSX.IntrinsicElements. That interface is not part of the DOM library; it is contributed by whichever JSX runtime the project configured through jsxImportSource or the legacy JSX global namespace. React declares it in react/jsx-runtime, Preact in preact/jsx-runtime, Solid in solid-js, and each declares a different property type for the same element.

Three consequences follow.

There is no universal augmentation. A package cannot declare “this tag exists in JSX” once. It must declare it per runtime, because the interface it needs to merge into lives inside that runtime’s own module namespace.

An unconditional declaration breaks non-users. Writing declare module 'react' { … } in a file that every consumer loads makes TypeScript try to resolve react for a consumer who has never installed it, producing an error in a project that does not use React at all. The conventional shape is therefore an optional entry point — @wfc/components/react — that only React consumers import.

Attributes are not properties. JSX in React 18 and earlier sets unknown props as attributes via setAttribute, so a prop whose value is an object, array, or function is stringified. React 19 changed this: it now sets a property when one exists on the element instance and falls back to an attribute otherwise, and it passes functions for on* props to addEventListener when the prop name does not match a known React event. That difference means the same JSX types can be correct on one React major and misleading on the other.

Debugging Pitfall — typed props that silently stringify. Declaring items?: Item[] in an intrinsic-element augmentation makes <wfc-list items={rows} /> compile, and on React 18 the element receives the attribute items="[object Object]". The type system says the code is correct and the runtime disagrees. On React 18, type only the props that are genuinely attributes — strings, numbers, and booleans — and pass anything richer through a ref, or ship a generated wrapper component that assigns properties.

The event side has the same split. React 18 has no mechanism for arbitrary custom events, so a onBannerDismiss prop does nothing; the correct pattern is a useEffect with addEventListener, which is the adapter described in bridging custom events to React. React 19 wires unknown on* props to addEventListener, using a lowercase-after-on convention, so onbanner-dismiss works and onBannerDismiss does not.

How each JSX runtime handles props and events on a custom element React 18 sets attributes only and ignores custom event props, React 19 prefers properties and wires unknown on-props to addEventListener, and Preact and Solid have set properties and events for longer. Same JSX, four different runtime behaviours React 18 attributes only objects stringify custom events ignored type only string, number and boolean use refs for the rest React 19 property if it exists objects pass through on* → addEventListener lowercase after "on": onbanner-dismiss rich props are safe Preact property if it exists on* listeners work augment preact/jsx-runtime longest-standing custom element support Solid prop: and attr: on: for any event explicit namespaces remove the ambiguity augment JSX in solid-js

Production-Safe Pattern

Ship one optional entry point per runtime. Each declares only the props that runtime can actually deliver, so the type system and the runtime agree.

// src/jsx/react.d.ts — published as "@wfc/components/react"
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
import type { WfcBanner, BannerDismissDetail } from '../banner.js';

/** Only attribute-safe values: React 18 stringifies anything else. */
interface WfcBannerAttributes {
  tone?: 'info' | 'warning' | 'danger';
  dismissible?: boolean | '';
  /** React 19 wires unknown on* props to addEventListener. Lowercase after "on". */
  'onbanner-dismiss'?: (event: CustomEvent<BannerDismissDetail>) => void;
  ref?: React.Ref<WfcBanner>;
}

declare module 'react' {
  namespace JSX {
    interface IntrinsicElements {
      'wfc-banner': DetailedHTMLProps<HTMLAttributes<WfcBanner>, WfcBanner> & WfcBannerAttributes;
    }
  }
}
// src/jsx/preact.d.ts — published as "@wfc/components/preact"
import type { JSX as PreactJSX } from 'preact';
import type { WfcBanner, BannerDismissDetail } from '../banner.js';

declare module 'preact' {
  namespace JSX {
    interface IntrinsicElements {
      'wfc-banner': PreactJSX.HTMLAttributes<WfcBanner> & {
        // Preact assigns properties when they exist, so richer values are safe.
        tone?: 'info' | 'warning' | 'danger';
        dismissible?: boolean;
        onbanner-dismiss?: (event: CustomEvent<BannerDismissDetail>) => void;
      };
    }
  }
}
{
  "name": "@wfc/components",
  "type": "module",
  "exports": {
    ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
    "./banner": { "types": "./dist/banner.d.ts", "default": "./dist/banner.js" },
    "./react": { "types": "./dist/jsx/react.d.ts" },
    "./preact": { "types": "./dist/jsx/preact.d.ts" },
    "./solid": { "types": "./dist/jsx/solid.d.ts" }
  },
  "peerDependenciesMeta": {
    "react": { "optional": true },
    "preact": { "optional": true }
  }
}
// Consumer, React 19.
import '@wfc/components/banner';
import '@wfc/components/react';        // types only — no runtime cost

export function Toolbar() {
  return (
    <wfc-banner
      tone="warning"
      dismissible
      onbanner-dismiss={(event) => console.log(event.detail.reason)}
    >
      Deployment is degraded.
    </wfc-banner>
  );
}
// Consumer, React 18 — attributes only, so events and rich data go through a ref.
import { useEffect, useRef } from 'react';
import '@wfc/components/banner';
import '@wfc/components/react';
import type { WfcBanner } from '@wfc/components/banner';

export function Toolbar() {
  const ref = useRef<WfcBanner>(null);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    const controller = new AbortController();
    element.addEventListener(
      'banner-dismiss',
      (event) => console.log(event.detail.reason),
      { signal: controller.signal }
    );
    return () => controller.abort();
  }, []);

  return <wfc-banner ref={ref} tone="warning" dismissible>Deployment is degraded.</wfc-banner>;
}

Three properties make this shape work. The entry points are types-only — no default condition — so importing one adds nothing to the bundle. The peer dependencies are marked optional, so a Vue consumer installing the package is not told to install React. And each augmentation types only what its runtime can deliver, so @ts-expect-error in a type test genuinely catches a mismatch rather than being papered over by an over-permissive declaration.

Verification

// type-tests/react19.tsx — compiled with tsc --noEmit against the packed tarball.
import '@wfc/components/banner';
import '@wfc/components/react';
import type { WfcBanner } from '@wfc/components/banner';

export const ok = (
  <wfc-banner tone="warning" dismissible onbanner-dismiss={(e) => {
    const reason: 'user' | 'timeout' = e.detail.reason;   // detail is typed
    void reason;
  }} />
);

// The union is enforced.
// @ts-expect-error tone only accepts the documented values
export const bad = <wfc-banner tone="purple" />;

// A ref resolves to the element class, not HTMLElement.
export function WithRef() {
  const ref = React.useRef<WfcBanner>(null);
  React.useEffect(() => { ref.current?.dismiss('timeout'); }, []);
  return <wfc-banner ref={ref} />;
}

Run the same file against each supported runtime in CI, each in its own scratch project installed from the packed tarball. Two failures matter and both are silent otherwise: a runtime whose augmentation was never exported (the tag stops compiling) and a runtime whose augmentation is too permissive (the @ts-expect-error line starts compiling and the build fails, which is the point).

A runtime check is worth pairing with the type check for React 18, because the type system cannot express “this stringifies”. Render the component in a test, then assert element.getAttribute('items') === null and element.items is the array you passed — if the attribute is [object Object], the prop was typed as something JSX cannot deliver.

When to Use vs When to Avoid

Consumer setup Approach
React 19 Intrinsic augmentation with on* event props and rich values
React 18 Intrinsic augmentation with attribute-safe props only; ref for the rest
Preact Intrinsic augmentation; properties and events both work
Solid Intrinsic augmentation; prop: and on: namespaces remove ambiguity
Vue No JSX augmentation needed for templates; configure isCustomElement
Angular No JSX; generate wrapper directives from the manifest
Plain TypeScript, no JSX HTMLElementTagNameMap alone is sufficient
Many runtimes to support Generate the augmentations from the manifest

The last row is where this stops being hand-written work. Every field in these declarations — tag name, attribute names and unions, event names and detail types — already exists in the custom elements manifest. Generating the per-runtime .d.ts files from it means adding a component updates every runtime’s typings in one step, and no augmentation can drift from the component it describes.

Generating every runtime's typings from one manifest A single manifest feeds a generator that emits per-runtime declaration files, so adding a component updates all of them in one step and none can drift. custom-elements.json tags, attributes, events with detail types generator one template per runtime dist/jsx/react.d.ts dist/jsx/preact.d.ts dist/jsx/solid.d.ts dist/jsx/vue.d.ts one source of truth no hand-written drift

Frequently Asked Questions

Why does HTMLElementTagNameMap not fix JSX?

Because JSX resolves lowercase tags through JSX.IntrinsicElements, an interface contributed by the JSX runtime, not through the DOM library’s tag name map. They are unrelated lookups and both need augmenting.

Can I ship one augmentation for every framework?

No. Each runtime declares its own interface inside its own module, so the declarations must live in separate, optional entry points that only consumers of that runtime import — otherwise a project without React gets an unresolvable module error.

Why does my object prop arrive as [object Object]?

On React 18, JSX sets unknown props as attributes, and attributes are strings. Type only attribute-safe values for that runtime and assign richer values through a ref, or ship a generated wrapper component that sets properties.

What is the correct event prop name in React 19?

Lowercase after on, matching the dispatched event name: onbanner-dismiss for an event named banner-dismiss. Camel-cased names are treated as ordinary props and are not wired to addEventListener.