A small, dependency-free JSX runtime for vanilla TypeScript and JavaScript DOM projects.
npm install @react5/dom-jsxFor TypeScript automatic JSX, set these compiler options:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@react5/dom-jsx"
}
}The runtime also works with Vite. Vite discovers the package's ESM jsx-runtime and jsx-dev-runtime exports automatically when compiling TSX.
Function components may return DOM nodes with an attached API:
type ModalApi = { open(): void; close(): void }
function Modal() {
const element = <dialog /> as HTMLDialogElement
const api: ModalApi = {
open: () => element.showModal(),
close: () => element.close()
}
return Object.assign(element, { api }) satisfies
HTMLDialogElement & { api: ModalApi }
}
const modal = <Modal />
modal.api?.open()TypeScript represents all JSX expressions with one JSX.Element type, so it
cannot preserve the exact intersection type of an individual component in
TSX. The library's JSX element type therefore allows an optional api
property, typed loosely as Record<string, any>, on every JSX result.
Accessing any other property directly on the node (e.g. a typo'd DOM
property) is still checked normally. api is optional because plain
intrinsic elements don't have one, so calling through it via <Tag /> JSX
syntax needs ?.. To get the component's exact, non-optional api
type instead, call the component directly or call jsx(Modal, null) /
jsxDEV(Modal, null).
JSX.Element is the DOM Element type plus the optional api, so element
methods such as hasAttribute and toggleAttribute are available on any JSX
result. Fragments and text nodes are typed the same way even though they are
not elements, so narrow before calling element methods on a value that may be
one. Calling jsx('input', props) directly returns the specific element type
(HTMLInputElement) with no cast.
Components used as <Comp /> must return JSX.Element, so a component typed
as returning Node or DocumentFragment does not type-check.
isElement(node) is a type guard for "is this a real Element, not a
fragment or text node":
import { isElement } from '@react5/dom-jsx'
const node = <>{maybeContent}</>
if (isElement(node)) node.toggleAttribute('hidden', true)expectElement(node, Constructor) narrows to a specific element class and
throws a TypeError (Expected HTMLInputElement, got DIV) on mismatch. Use it
instead of an unchecked as cast when JSX gives you a generic JSX.Element:
import { expectElement } from '@react5/dom-jsx'
const input = expectElement(<input value="Ada" />, HTMLInputElement)
input.select() // typed as HTMLInputElement
const canvas = expectElement(document.querySelector('#chart')!, HTMLCanvasElement)Use cases:
- Getting a specifically-typed element from a JSX expression without
as. - Validating nodes from outside the runtime (
querySelector, event targets, third-party components) before using element-specific APIs. - Failing fast with a readable message if a component unexpectedly returns a fragment or text node.
instanceof checks are per-realm, so nodes from another window or iframe fail
expectElement.
Intrinsic JSX tags become DOM elements. Prefer className over class, while both supported. class sets className, style accepts a style object, DOM properties are assigned when available, boolean attributes use presence semantics, and onClick-style function props become event listeners. Text, nested nodes, arrays, fragments, and function components are supported; null, undefined, and boolean children are ignored.
Compound event props require camel casing, such as onMouseEnter, onMouseLeave,
onFocusIn, onFocusOut, onKeyDown, and onAnimationEnd.
Handlers receive the DOM event with currentTarget typed as the element.
The public entry points are:
import { createRef, createContext, useContext, onCleanup, onDispose, dispose, expectElement, isElement, svge, jsx, jsxs, Fragment } from '@react5/dom-jsx'@react5/dom-jsx/jsx-runtime also re-exports the same names and is what the TypeScript/Vite JSX transform imports automatically; @react5/dom-jsx is the shorter path for importing helpers like createRef directly in your own code.
Use svge() function to load raw svg. With vite use raw import:
import iconSvg from "./assets/icon.svg?raw"
const icon = svge(iconSvg)
<div>{icon}</div>Capture the created DOM node without a later querySelector call using createRef or a callback ref:
import { createRef } from '@react5/dom-jsx'
const inputRef = createRef<HTMLInputElement>()
const form = (
<form>
<input className="action-form__input" ref={inputRef} />
</form>
)
inputRef.current // HTMLInputElementA callback ref (ref={(el) => ...}) is also supported and is invoked with the element once it's created.
Pass values down to nested components without threading props through every level:
import { createContext, useContext } from '@react5/dom-jsx'
const ThemeContext = createContext({ color: 'black' })
function Title(props: { text: string }) {
const theme = useContext(ThemeContext)
return <h1 style={{ color: theme.color }}>{props.text}</h1>
}
const page = (
<ThemeContext.Provider value={{ color: 'red' }}>
{() => <Title text="Hello" />}
</ThemeContext.Provider>
)JSX in this runtime is evaluated eagerly from the inside out: children are
created before their parent component runs. A Provider's child must
therefore be a render function ({() => ...}); the Provider calls it while
its value is active, so every component rendered inside sees that value.
Providers can be nested, and the innermost value wins.
useContext returns the nearest Provider's value only while rendering is in
progress. Call it synchronously in a component body and keep the result.
Called later, for example from an event handler or a setTimeout, it
returns the context's default value.
Release subscriptions, timers, and other resources when content is discarded.
Register cleanups with onCleanup in a component body, or with
onDispose(node, fn) anywhere else:
import { dispose, onCleanup, onDispose } from '@react5/dom-jsx'
function Clock() {
const el = <time />
const id = setInterval(() => { el.textContent = new Date().toLocaleTimeString() }, 1000)
onCleanup(() => clearInterval(id))
return el
}
const node = <Clock />
onDispose(node, () => console.log('disposed'))
// Later, where content is swapped:
old.replaceWith(next)
dispose(old)The DOM gives no synchronous signal when a node is removed, so dispose must
be called explicitly. It runs the cleanups of the node and of every node inside
it, descendants first, each node's cleanups in reverse registration order.
Disposal follows the DOM tree, so components passed as children are covered
even though they render before their parent. Each cleanup runs at most once;
if some throw, the rest still run and the error (or an AggregateError) is
rethrown afterwards.
A component's cleanups are attached to the node it returns. When it returns a fragment, they are attached to the fragment's top-level children and run once all of them are disposed, so dispose every one of them, or a common ancestor. An empty fragment gets an empty comment node to carry its cleanups.
If a component throws while rendering, the cleanups it already registered run immediately. So do those of components it rendered and of JSX passed to it in props, unless those nodes are already in the document.
onCleanup throws when called outside a component render, e.g. from an event
handler or after an await; use onDispose(node, fn) there. Moving a node
never disposes it. A node that is discarded without dispose keeps its
cleanups until it is garbage-collected, and they never run.
The package ships an agent skill with usage guidance for this runtime (nodes, refs, element typing, context, cleanup) at node_modules/@react5/dom-jsx/skills/jsx-dom/SKILL.md. To use it, reference it from your project's AGENTS.md or CLAUDE.md:
When writing or editing TSX that uses @react5/dom-jsx, follow
node_modules/@react5/dom-jsx/skills/jsx-dom/SKILL.md.For Claude Code, you can instead copy or symlink the jsx-dom folder into .claude/skills/.
npm test # Run Vitest in jsdom
npm run typecheck
npm run build # Build JavaScript and declarations into dist/