HTMS: Writing JavaScript With HTML Tags, On Purpose
I’ve got a side project where you write JavaScript with HTML tags, and it’s faster than React, Vue, Svelte 5 and Solid.
It’s called HTMS, short for Hyper Text Media Script. It’s been on my Shipped page for years and I’ve never written a word about it.
I ran the js-framework-benchmark operations (create 1,000 rows, swap two of them, clear the lot and so on) against that project’s official Svelte 5, Solid, Vue, React and vanilla JS implementations. Every app went through the same Vite production build and got timed in Chromium from the click to the next paint, 15 runs each. Across the nine operations, HTMS takes 1.06× the time of hand-written vanilla JavaScript. Solid lands at 1.08×, Svelte 5 at 1.14×, Vue at 1.28× and React at 1.67×.
| HTMS | Solid | Svelte 5 | Vue | React | |
|---|---|---|---|---|---|
| overall time vs vanilla JS | 1.06× | 1.08× | 1.14× | 1.28× | 1.67× |
| bundle (min + gzip) | 4.5 KB | 5.7 KB | 13.3 KB | 24.7 KB | 67.0 KB |
| JS heap with 1,000 rows | 2.33 MB | 2.96 MB | 3.24 MB | 4.10 MB | 4.52 MB |
| script time during page load | 7.6 ms | 8.0 ms | 9.4 ms | 9.7 ms | 17.1 ms |
HTMS was fastest on six of the nine operations, and it spent the least time in JavaScript on all nine (Solid ties it on one). It loses the partial update, select row and remove row operations to Solid, each by half a millisecond or less.
The whole benchmark app is 4.5 KB gzipped, against 13.3 KB for Svelte and 67 KB for React, because there’s no framework runtime to ship. You get the compiled component and a handful of helpers.
It’s my own harness rather than the official runner, so the numbers line up against each other and shouldn’t be held up next to the published js-framework-benchmark tables. The other apps are copied straight out of that repo without changes, and the full results and method are in the HTMS repo.
So yes, it lets you write JavaScript using HTML tags. I know how that sounds.
Here’s a counter component written in HTMS:
<component name="click-counter">
<var name="count" value="0" mutable="true"></var>
<button id="increment">Clicked {count} times</button>
<event target="#increment" type="click">
<set name="count" op="++"></set>
</event>
</component>
State is a tag. The event handler is a tag. Incrementing a number is a tag. Anyone who’s seen ColdFusion will get a weird twinge of recognition, because that’s exactly where the idea comes from. ColdFusion had <cfset>, <cfloop> and <cfif> back in the nineties, and for all the jokes people make about it, there was something appealing about logic that sat in the same shape as the markup around it.
Run that file through the HTMS compiler and you get a plain JavaScript custom element. Shadow DOM, DOM events, no eval, no Function constructor and no runtime framework you have to learn. Drop <click-counter> on a page and it works.
If you don’t want to go full ColdFusion, the same counter fits on one line:
<button on:click="count++">Clicked {count} times</button>
That count is the component’s field. You name state directly and the compiler works out it means this.count, while loop variables, event and globals like Math keep their usual meaning. When a handler grows past a one-liner, you move it into the tag form or a method and nothing else changes.
I started HTMS in August 2023 as a joke I couldn’t stop thinking about. Then it started working. Every time I pushed it a bit further it kept holding up, and somewhere along the way it stopped being a joke and became a tool I’d reach for. Recently I shipped a client project with it.
A paying client is running components I wrote in HTML tags, in production, and I still think that’s nuts.
It’s not as reckless as it sounds. The compiler turns <component> blocks into web components with their own state, their own shadow root and a proper render cycle, so as far as the client’s site is concerned they’re custom elements like any other.
It’s also safe to point at a real site. A compiler that turns markup into executable JavaScript is a lovely way to ship an XSS hole, so there’s a security pass that rejects eval, inline onclick-style attributes, raw innerHTML and path traversal before any code gets generated. URLs get checked at runtime as well, so an href="{link}" that turns out to be javascript: renders as about:blank.
Props are declared on the component and typed with a suffix:
<component name="user-badge" props="labelText, count:number, active:boolean">
<span>{labelText} ({count})</span>
</component>
labelText reads from a label-text attribute, numbers get parsed, booleans follow attribute presence. Changing a prop or any state declared with <var> schedules a render, and multiple changes in the same tick get batched into one. There’s an updateComplete promise if you need to wait for it, the same trick Lit uses.
This is where the benchmark numbers come from. There’s no virtual DOM. The compiler turns each template into code that writes to exactly the nodes whose values changed, and it works out ahead of time which fields every binding reads, so changing count skips everything that doesn’t touch count.
Updating nodes in place also means your focus, text selection and half-typed form values survive a re-render. Re-rendering a shadow root is easy. Re-rendering it without kicking the cursor out of the input someone’s typing in is a different job entirely.
Markup takes inline directives too, and if they look like Svelte, that’s on purpose. Here’s most of the todo demo:
<component name="todo-box">
<var name="todos" value="[]" mutable="true"></var>
<var name="draft" value="''" mutable="true"></var>
<derive name="remaining" expr="todos.filter((todo) => !todo.done).length"></derive>
<function name="add">
const text = draft.trim();
if (!text) return;
todos.push({ id: Date.now(), text, done: false });
draft = '';
</function>
<form on:submit.prevent="add()">
<input type="text" placeholder="Add a task" bind:value="draft" />
<button type="submit" disabled="{!draft.trim()}">Add</button>
</form>
<ul>
<repeat variable="todos" item="todo" index="i">
<li class:done="todo.done">
<input type="checkbox" bind:checked="todo.done" />
<span>{todo.text}</span>
<button type="button" on:click="todos.splice(i, 1)">✕</button>
</li>
</repeat>
</ul>
<p if="todos.length">{remaining} of {todos.length} left</p>
</component>
bind:value keeps the input and draft in sync. class:done toggles a class, if renders an element only while its expression is truthy, and anything in braces is an expression, so {!draft.trim()} switches the button’s disabled attribute on and off. A <function> inside a component becomes a class method, and the component re-renders once it returns.
Events take the modifiers you’d expect, either as dotted suffixes like on:keydown.enter and on:input.debounce-250 or as attributes on an <event> tag. Lists use <keyedlist> with a key expression, so rows move around without being recreated.
There’s server rendering too. htms render outputs Declarative Shadow DOM plus a hydration manifest, and calling hydrate() in the browser adopts the server tree instead of throwing it away. Every compile also writes a .d.ts file, so a component dropped into a TypeScript project gets its props and events typed.
The CLI ties it together. htms create click-counter gives you a starter file, and htms dev click-counter.html compiles it, serves a preview and reloads on save. If you misspell a tag or leave out an attribute, you get the error in both the browser and the terminal, with the source line and a suggested fix. For a language made of HTML tags, decent error messages are the difference between fun and miserable.
I won’t pretend it’s perfect. Expressions are still JavaScript inside a string, so your editor treats todos.filter((todo) => !todo.done).length as an attribute value and won’t help you with it. The alternative was inventing my own expression language, which would’ve been a fun way to lose a year.
HTML also lowercases attribute names, which you can’t do anything about. on:itemSelected arrives as on:itemselected, so custom events you listen to inline have to be kebab-case.
It’s not on npm either. Someone registered the htms package name back in 2017, so for now you clone the repo and build it. There are 292 Jest tests passing, including ones that compile every example in the README, and a Playwright suite that runs compiled components in a real browser, which is more than I can say for some libraries I’ve installed from npm.
Is anyone going to rewrite their React app in it? I hope not, benchmark or no benchmark. That’s not what it’s for, even if it’s now good enough for a client.
HTMS is for people who think in markup, and for small interactive bits that don’t need a whole framework dragged in behind them. A counter, a form with validation, a list that filters, a card that fetches some data. You write HTML, you get a web component, and the output is plain JavaScript you can read.
Writing a compiler is a hobby I’d recommend to any developer who’s never done it, and doing it for a language this silly takes all the pressure off.
The live demos are on GitHub Pages and the code’s at github.com/Vheissu/htms. Click a few buttons, read the HTML behind them, and tell me it doesn’t make you want to go and write a <cfloop>.
Comments