Skip to content

gracile-web/babel-plugin-jsx-to-literals

Repository files navigation

JSX to HTML literals as a Babel transformer

A <JSX> to html`<...>` Babel plugin.

@gracile-labs/babel-plugin-jsx-to-literals is a well-tested, low level library than you can borrow for your custom build pipeline.



🧪 Specs — JSX → HTML tagged template compiler

Note

This is not a JSX runtime. It compiles JSX statically to html`...` tagged templates.
Think of it as "JSX as syntax only", a bit like Solid, but targeting Lit, µhtml or any compatible runtime implementation, even your own.

It follows native HTML semantics and Lit's binding/helpers conventions.
It does not invent abstractions but translate them.
It's possible to remap helpers with your own import paths if you're not using Lit.

This project started as a convenience transformer for personal needs, starting from mid-2024.
I added parity and DX perks with Lit while building the Gracile framework website entirely with JSX. It's pretty opinionated and arguably rigid.
This transformer comes with a sample JSX namespace and a few helper components to bootstrap quickly and adapt later (forking is encouraged).

It became a live spec/implementation over time, while proofing all these ideas. However, I'm working on a successor for this project, which is already much more powerful, comes with a bunch of little optimizations and things that are impossible to achieve with Babel. All that with a reduced footprint. But before, I had to release this first stepstone before ramping up. Even for pure archiving reason.
Stay tuned.


✅ JSX Syntax Support

Feature Status Example
Basic elements <div>Hello</div>html`<div>Hello</div>`
Fragments <>Hi</>html`Hi`
Expressions <span>{name}</span>html`<span>${name}</span>`
Component call <Comp foo="bar" />${Comp({ foo: "bar" })}
Component children <Comp>Hi</Comp>${Comp({ children: html`Hi` })}
Comments // line /* block */ → stripped
Escaped text <div>`Hi`</div>
XML-like voids <br /><br>
Auto-closing <my-el /><my-el></my-el>
JSX inside tagged templates html`<main>${<Comp />}</main>`
Tagged templates inside JSX <Comp>{html`<main></main>`}</Comp>

🧱 Attribute & Prop Binding

Syntax Output Notes
title="Hi" title="Hi" Static
title={"Hi"} title=${"Hi"} Interpolated
prop:checked={x} .checked=${x} Lit-style property
bool:required={x} ?required=${x} Lit-style boolean
on:click={fn} @click=${fn} DOM event
on:change={fn} @change=${fn} Custom event
class:map={{...}} class=${classMap(...)} Needs classMap
class:list={[...]} class=${clsx(...)} Needs clsx
style:map={{...}} style=${styleMap(...)} Needs styleMap
ref={fnOrRef} ${ref(fnOrRef)} Directives
attr:data={obj} data=${JSON.stringify(obj)} Attr. serialization (SSR)
if:foo={x} foo=${ifDefined(x)} Lit directive
<i unsafe:html=${'<br>'} /> <i>${unsafeHTML('<br>')}</i> Lit directive

🧩 Component Model

Feature Status Example
Function components const A = () => <div />; <A />
Children as slots <A>Hello</A> or children: () => "Hello"
Namespaced components <ns.Comp />
Attribute namespaces prop:, on:, class:, etc.
JSX in html html`<div>${<X />}</div>`
html in JSX <Comp>{html`<p />`}</Comp>

🔁 Control Helpers

It's mostly sugar, inspired by Solid, that wraps Lit helpers nicely.

Helper Status Notes
<For each={arr} key={({ id }) => id} ... /> Uses repeat under the hood (Lit idiom)
<Show when={x}>...</Show> Uses when directive internally

⚙️ Directives (html flavors)

Input Effect
"use html-server" Uses @lit-labs/ssr
"use html-signal" Uses @lit-labs/signals
(no directive) Defaults to import { html } from "lit"

🧩 The directive (e.g. "use html-signal") tells the plugin which flavor of Lit’s html to import: regular, SSR, or Signals.
✅ All runtime imports (e.g. html, ref, classMap) are auto-injected only if used, and can be hot-swapped with user-defined imports via plugin options; giving full control.


⚔️ Deviations & Design Principles

Design Supported Notes
Component namespaces (<Foo.Bar />) Like React
Attribute namespaces (prop:, etc.) Like Solid, not React
Custom serialization (e.g. attr:data) Declarative extension
Spread props Not supported
JSX runtime None. Syntax-only (compile step only)
VDOM / reactivity No VDOM. Relies on user brought reactive system
HTML-first All syntax is spec-aligned and unopinionated

🔀 Output Format

All JSX compiles to hydrated Lit templates with part markers:

<!--lit-part ...-->
<div>Hello</div>
<!--/lit-part-->

  • SSR and client output match
  • Hydration-safe
  • No runtime wrappers
  • DOM tree matches what you'd write by hand

🔍 Real-world Example JSX:

<input
  class="toggle"
  type="checkbox"
  prop:checked={todo.completed}
  on:input={(e) => store.toggle(todo.id, e.currentTarget.checked)}
/>

Output:

html`<input
  class="toggle"
  type="checkbox"
  .checked=${todo.completed}
  @input=${(e) => store.toggle(todo.id, e.currentTarget.checked)}
/>`;

🔒 Type Safety & TS Integration

babel-plugin-jsx-to-literals provides first-class type safety for DOM elements, namespaced attributes, and custom elements, without any JSX runtime.

While it's a low-level transformer, these satellite files are here to kickstart your own pipeline, and are meant to be adapted to your needs, given it's JSX typings and runtime helpers are out-of-scope for this project.


✅ Native DOM + Namespaced Attributes

Attributes are prefixed using JSX namespaced names (e.g. prop:*, on:*, if:*), and tied to the correct DOM element type via HTMLElementTagNameMap.

You get full IntelliSense for:

  • <input prop:checked={true} />
  • <div if:visible={condition} />
  • <my-el on:custom={() => ...} />

This ensures proper typing for:

  • Event handlers
  • Boolean vs string attributes
  • Refs (e.g. ref={(el: HTMLInputElement) => ...})

🛠 Enable in tsconfig.json

{
  "compilerOptions": {
    "jsx": "preserve",
    "jsxImportSource": "@gracile-labs/babel-plugin-jsx-to-literals",
  },
}

This tells TypeScript to use babel-plugin-jsx-to-literals sample JSX typings (based on the DOM spec, extended for namespace handling).


🧩 Adding Custom Elements

To type custom elements, declare an ambient module:

// ambient.d.ts
import type { CustomElements } from './jsx.js';

declare global {
  namespace JSX {
    interface IntrinsicElements extends CustomElements {}
  }
}

Then map each tag:

// jsx.js
export type CustomElements = {
  'my-el': JSX.HTMLAttributes<MyElementPropsAndAttrs>;
};

This gives you:

  • Autocompletion for custom tag names
  • Namespaced attribute support (prop:, on:)
  • Correct DOM types for ref and event bindings

🧠 Behind the scenes, the JSX namespace maps each tag to its correct DOM type via HTMLElementTagNameMap and prefixes all props with allowed namespaces, like Solid, but more aligned with HTML spec.

🤔 Why

During early research, I found that besides Stencil JS (a closed system) and a few abandoned projects, there wasn’t much in terms of agnostic JSX implementations that could work with any Custom Element or plain functional templates (à la React).
Solid made me realize that using JSX doesn’t force you to follow React’s traits, all the way down to the runtime.
Then I discovered Andrea Giammarchi’s article, Bringing JSX to Template Literals.
At that point, I had no practical experience with Babel plugins or JSX runtimes. I just knew from theory (blogs…) and build output analysis that it all goes through a factory function (the famous “h”), and in the end, whether you use Vue, React, Preact, or Solid, you end up with deeply nested function calls, each node representing a component or intrinsic HTML element.

My first attempt was to adapt his work, jsx2tag, to allow injection of useful directives (styleMap, ref…).

But I hit a first obstacle: Trusted Types.

jsx2tag generates React-like classic JSX output with a factory function, a "pragma"… Then, at runtime, the deeply nested function calls (one for each element) are reassembled, and a template function signature literal is faked (pardon me if I’ve forgotten some nuances, it’s been a while).
This approach has limits:

  • Function calls have a runtime cost: one that Lit and other literal-based template systems aim to avoid.
  • The built output is verbose and less human-readable (more machine-oriented than HTML-like).
  • Faking template literals raises Trusted Types errors with Lit. You can shim this by circumventing Lit’s Trusted Type polyfill check step.

But the real blocker for me was the sheer number of unnecessary function calls.
There is another plugin (by the same author) to “static-ify” those calls, but that wasn’t a path I wanted to pursue.

Note that JavaScript had some interesting proposals that could alleviate this problem: performance optimizations for partially static object.

Still, it already felt too far removed from Lit’s core simplicity (which is designed to disappear and let the platform work).

Eventually, after grinding my teeth on that PoC, I gained practical knowledge of Babel and learned how to debug its AST...

That’s when I built the first version of a "jsx2lit-like" transformer, but this time with a different approach: convert JSX... literally.
I didn’t want a DSL-specific runtime, so I asked: what if we use tagged templates as build output, not as authoring format?
Tagged templates are already performant, concise, minifiable, easier to debug. They might become a Web Standard one day.
Also, JSX and HTML literals should be interoperable. In short: stay very close to how you’d write HTML by hand.

The first version was very hacky.
I took inspiration from asyncLiz’s minify-html-literals, which uses @placeholders for interpolations. It performs a first pass to separate static and dynamic parts, then reassembles them after processing.
I followed a similar idea, but quickly realized that manipulating JS outside of Babel’s AST visitor logic was a mess; it breaks source maps and confuses the Babel pipeline.
I didn’t want to fall into the same category as Svelte/Astro/Vue templates with MagicStrings and manual source maps reimplementation.

Instead, JSX provides a clean, standardized AST.
The build output is also TS, so we can map things 1:1.

There was also the perspective to preserve (much) of user formatting, even more after I discovered recast, a tool made for this endeavor.

After much fiddling and learning with Babel, I found my way for processing JSX with pure AST transforms. No hacks, no string-patching, accurate source maps, predictable behavior.

From there, I started exploring DX improvements:

  • Type safety
  • Directives
  • Configurable auto-imports
  • Global JSX typing for custom elements

Essentially, a full abstraction layer that respects Lit conventions but lets you bring your own tools.

For example, class:list={...}class=${clsx(...)} is configurable (you can swap clsx with cn or whatever you prefer). Same for the html tag function.

One of the latest additions was the "use html-*" directive, allowing you to switch between Lit’s three html flavors (regular, SSR, signals).


Following Lit’s template philosophy, babel-plugin-jsx-to-literals is more explicit than typical JSX, and this can be a good thing.
You’re not relying on opaque framework heuristics.

Think of this transformer as some sort of XHTML-in-JS: stricter than HTML, but free from React-style constraints like className and htmlFor, all artifacts of JavaScript semantics. These constraints often feel awkward in templates. They can confuse users when they see, in devtools, that props and attributes are handled "magically" by React.

For example, did you know that React renames some native DOM events? <input onFocusOut> becomes onBlur, diverging from standard behavior.
Similarly, with React, events are wrapped, and delegated in SyntheticEvents. Or you wil encounter the concept of "controlled/uncontrolled" forms (defaultValue versus value with onChange)…
and we could go on.

React will also complain loudly if you diverge from its opinionated ways.

From a technical perspective, these choices are understandable: they reflect React’s tightly coupled rendering model. It’s also a philosophical stance: React aims to smooth over inconsistencies between JavaScript and the DOM. This works well in cross-platform contexts like React Native. But on the web, it introduces significant opacity around how HTML and the browser actually work.


🫡 Going further

Meta-JSX

This package can be seen as a Meta-JSX dialect PoC. A strict subset with conventions aligned to web platform specs (with sprinkled Lit's idioms, as a canary).
This concept could fuel the idea of an intermediary JSX format, compatible across libraries.

In fact, I have some experiment currently tested with JSFE.
The goal is to offer a transformer that takes a Meta JSX format and compiles it into runtime-compatible JSX (React, Vue, Preact, Solid…), to be processed by each ecosystem’s toolchain (via official Vite plugins, etc.).

This layer is purely static (no runtime) and ensures that vocabularies (props, attrs, events, etc.) are correctly mapped with their quirks.

Surprisingly, this separate PoC already works well, and bridging the gaps wasn’t that hard.
If you follow a pure top-down data flow model (via props), it’s straightforward to build generic JSX components that work across runtimes.

If TC39’s Signal proposal lands and is widely implemented, we’ll have the last piece of the puzzle: a truly runtime-agnostic JSX authoring format + reactivity system backbone.

Note that, for now, this PoC evolves as a separate project.

Compiler optimizations

Using literals as an output we can benefit from the official Lit compiler that comes as a TypeScript transform plugin.

You also already benefit from the JSX parser that already collapses whitespace for us and applies very light space compression, preserving authoring intent when tasteful.
That also prevent typical foot-guns with whitespace aware HTML tags (textarea, pre…), especially in combination with Prettier auto-formatting that can introduce unwanted bugs with literals. They are literally literal, remember, as-is, nothing will save you from yourself after you hit Ctrl+S.

Tip

Comments are removed, which greatly reduces the need for a minifier (as long as you’re not embedding lots of inline CSS/scripts, etc.).

Syntax errors are caught early: TS compilation will fail outright, whereas the Lit Plugin (backed by Lit Analyzer) only works in the IDE.
Unfortunately, it can’t go as deep as the TypeScript compiler and its JSX parser, by design.

One other use case that could be explored but requires significant effort is properties spreading auto-mapping, at compile time (versus current runtime based solutions for Lit). It would require firing up a TypeScript compiler to introspect the types, or harder, rewriting the whole Babel plugin as a TS transform plugin (less commonly supported and understood in the wild).

DX comparison between JSX and tagged templates

As mentioned earlier, JSX brings several advantages, including better TypeScript support, making the dev loop tighter and less error-prone.

Also, having used both systems extensively, I can assert having ~25%+ faster editing speed with JSX.

Where tagged templates shine

They’re ultra flexible.

For low-level code (in cores, vendor libraries…), where you don’t want a toolchain, they offer maximum robustness and predictability.
On contrary, when working with high frequency changes app code, you want a more comfortable authoring format, with guard rails, for easier refactoring. JSX fits the bill here.

They’re interoperable.

You can copy-paste from CodePen, live-edit the build output for debugging, live-code from an LLM assistance…

It’s orders of magnitude easier to debug when your authoring format matches your runtime. The easy win for literals here.

They’re well supported in modern tooling.

Editor support is solid: Zed (built-in), JetBrains, VS Code (via plugins).
Lit Analyzer, TS plugins. All work quite reasonably.

But if you want thorough strictness, static string templates aren’t the best foundation.

For example, the most complete TS + HTML Literals yet, Rune Mehlsen's Lit Analyzer, adds an extra HTML parser on top of the TS AST.
JSX sticks with a single, monolithic AST.
Also, TS JSX lives in one language server, no necessary bridging (unlike Svelte or Astro).

Note

Embedding HTML/CSS LSPs does offer some advantages over TS-only. Generally better docs or medium-tied editing features. Think Vue language tools and other Volar-based solutions.

Styling.

React’s CSS-in-JS world already experienced heavily tagged templates for CSS.

JSX doesn’t support CSS syntax highlighting or formatting inside <style> tags, but tagged templates do.
And you even get LSP support for “CSS inside HTML inside JS template literals” (yep).

That said, you can still use css or html tagged templates inside JSX when needed. Best of both worlds.

Where JSX shines

Commenting in/out.

Daily, that’s huge.

With tagged templates, commenting out code is a mess.
You’ll run into composite file limitations, malformed interpolations, and the impossibility of empty expressions like ${/* BOOM */} vs {/* fine :) */}.

Formatting.

JSX formatting is generally cleaner with Prettier.
With html literals, you’ll hit quirks: orphaned opening tags, auto-closing <br>, etc.
It’s 90% there, but the friction builds up. Prettier has to satisfies HTML whitespace pitfalls, which makes its work harder.

Note that Prettier’s JSX formatting isn’t totally perfect either.

Type safety.

Even with advanced devtools, tagged templates won't allow inferring event types for @event callbacks, or any other expressions. They are not context aware in the eyes of TypeScript.
You’ll have to annotate function parameters manually.
It’s a weak link: it's tough for TS/Lit Analyzer to infer intent from static strings.
Also, refactoring and other advanced language features are not supported with the current devtools landscape for HTML literals.
You cannot just rename symbols like that and expect the changes to propagate in the whole codebase.
That means go back to the good old Cmd+Shift+F. No fun.

But this could change over time, hopefully.

Still,once you get your hand on a well-typed JSX project, going back to pure literals feels like a downgrade. Not painful, but less safe, less expressive.

Visuals

JSX is easier on the eye. No $ or html prefixes everywhere, more concise comments and functions can just become <Components />, versus ${Component({ children: … })}, with default children slot.
Makes composition much more obvious, especially when we know that many Lit users are using functional, stateless templates everywhere.

Conclusion

This comparison isn’t meant to convince you to abandon the build-less ethos (which is niche in practice) by adding yet another layer of syntax.
Some readers may be allergic to React idioms and seek a more platform-aligned ecosystem.
But even Lit is experimenting with compilers, as React and Svelte did before.

That’s because DX benefits from pre-runtime enhancements, and today’s missing standards force us to reinvent templating in userland.

JS and template strings alone can’t capture the full richness of modern UI abstraction.
The bar has been raised.

These trends don’t diminish the power of tagged templates (for CSS, SQL, GraphQL, etc.).
This transformer simply aims to reconcile both worlds, which is bringing JSX’s expressiveness to a literal-based foundation. Their signature and their physical representation.

🧭 Looking Toward Web Platform Standardization

The html tagged template literal syntax, popularized by libraries like Lit, htm, hyperHTML, might become a native feature of the web platform.

There are ongoing discussions and proposals such as HTMLElement.html and DOM Parts / Template Expressions, which explore ways to make declarative, dynamic HTML templating a standard, with no need for virtual DOMs or custom compilation steps.

babel-plugin-jsx-to-literals was designed with this forecast in mind. It compiles JSX into plain, standard html literals that align closely with how native templating could work, preparing the ground for a runtime-less, natively composable UI model.

This also resonates with work by Andrea Giammarchi, who proposed an esx syntax and runtime that brings template expressions closer to JavaScript’s core. His vision outlines a web-native DSL for templates, which, if standardized, could eventually eliminate the need for Babel plugins like this one, though that reality is still years away and subject to consensus.

Until then, userland solutions like babel-plugin-jsx-to-literals are providing practical bridges between today’s JSX ergonomics and tomorrow’s native web rendering.


I’ve used this plugin in a handful of production projects before open-sourcing it, with plenty of tests and docs.
As I'm working on its successor, feel free to fork it if you want to make it more tailored to your needs.


© 2024 — Julian Cataldo ([email protected]) — License ISC

About

A JSX to tagged template literals Babel plugin, with a few helper components and a mapped JSX namespace.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published