HTML Next · Usage

Use components with Vite or the browser runtime

Choose a framework in any section. Every selector on this page follows your choice, so the setup and examples stay together.

Install

Start with a Vite project and use Node 22.22.2+ or 24.15+. The plugin supports Vite 8.

npm install --save-dev @nextwebwg/html-next-unplugin

Keep your project's usual React plugin. The current target is React 19.3.

Configure Vite

vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import htmlNext from "@nextwebwg/html-next-unplugin/vite";

export default defineConfig({
  plugins: [htmlNext({ target: "react" }), react()],
});

Use your component

Build a small workshop check-in app: count guests as they arrive, then reset for the next session. Each example uses the same counter inside an App component and shows how to attach that app to the page. In an existing framework project, keep its mounting code and add the counter to its App component.

Save the counter you built as src/counter.html. Use it inside the app:

src/App.tsx
import { XCounter } from "./counter.html";

export default function App() {
  return (
    <main>
      <h1>Workshop check-in</h1>
      <p>Count guests as they arrive. Reset for the next session.</p>
      <XCounter />
    </main>
  );
}

Attach App to the page:

src/main.tsx
import { createRoot } from "react-dom/client";
import App from "./App";

createRoot(document.getElementById("app")!).render(<App />);
index.html
<!doctype html>
<html lang="en">
  <head><meta charset="utf-8"><title>Workshop check-in</title></head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

Run your project's usual npm run dev command. Click the counter and Reset.

The result uses React and native DOM roots. Generated components import the helpers their features need and have no HTML Next runtime dependency. Editing the HTML definition updates the component in your app.

Typecheck

Enable allowArbitraryExtensions in your TypeScript configuration. The adapter writes adjacent .d.html.ts declarations for local imports. Check components with html-next-check, then generate declarations before a standalone typecheck:

package.json
{
  "scripts": {
    "typecheck": "html-next-check --target react src/counter.html && html-next-sync && tsc --noEmit"
  }
}

If a component needs declarations before its first import, add entries: ["src/counter.html"] to htmlNext({ target: "react" }).

Type a controller

Each component that declares a controller gets a typed host: <tag>.host.d.ts, beside its definition. It exports Host, the host the controller receives, and Controller, the type of the controller module's default export. The Vite plugin writes it when a build or dev server starts, html-next-sync writes it for a typecheck that runs first, and html-next build and html-next-convert write it too, as does assembleComponentPackage with hostTypes: true. Each run rewrites it from the definition, so you can list *.host.d.ts in .gitignore. A file of that name that HTML Next didn't write stays as it is, and the run stops with an error naming it.

The host types what <defs> declares:

  • props: each prop's accepted value type, with its input and validity.
  • state: writable state, readonly computed values, and context values, typed from their provider.
  • data: each resource's .value, from its type, with .pending, .ok and .error.
  • refs: the element type of each referenced element, and a list for a ref inside $each. A ref inside $if may be undefined.
  • slots and scripts: the declared names, with each script's ready, load() and status.
  • request(name, detail, options) and hasResponder(name): the declared request names, each detail and answer type, from type and returns.
  • dispatch(name, detail): the declared event names and detail types.
  • on(type, callback): the component's own events and requests, those of the components its template uses, connect and disconnect, and DOM events.
src/checkout-form.html
<template component="checkout-form" controller="./checkout-form.ts">
  <defs>
    <prop name="pk" type="string" required>The Stripe publishable key.</prop>
    <state name="status" type="keyword" values="idle, paying, paid" value="idle"></state>
    <event name="paid" type="object({ id: string })"></event>
    <request name="card-token" type="object({ amount: number })" returns="string"></request>
    <script name="stripe" src="https://js.stripe.com/v3/" loading="manual"></script>
  </defs>
  <form>
    <div $ref="card"></div>
    <button>Pay</button>
  </form>
</template>
src/checkout-form.ts
import type { Host } from "./checkout-form.host.js";

export default function controller(host: Host) {
  host.on("submit", async (event) => {
    event.preventDefault();
    host.state.status = "paying";
    await host.scripts.stripe.load();
    const token = await host.request("card-token", { amount: 1200 });
    if (token === null) { host.state.status = "idle"; return; }
    host.state.status = "paid";
    host.dispatch("paid", { id: token });
  });
}

A misspelled name, a wrong detail, or a name <defs> doesn't declare is a type error, such as host.state.stauts (Property 'stauts' does not exist … Did you mean 'status'?) or host.request("card-token", { amount: "12" }) (Type 'string' is not assignable to type 'number').

A JavaScript controller takes the same types through JSDoc, checked when checkJs is on:

src/checkout-form.js
/** @type {import("./checkout-form.host.js").Controller} */
export default function controller(host) {
  host.refs.card.dataset.key = host.props.pk.value;
}

Use a library

With the Vite plugin configured, install the library named in its README:

npm install your-library
import { UiButton } from "your-library";

Use those exports like other React components. The names come from the library's README. Include the generated src/html-next.d.ts in your TypeScript project to get its component types.

Use it without a build step

For React, use the Vite adapter above. Choose HTML Next in this section for the direct browser-runtime setup.

Contain a wrapped component's events

A component that wraps another one sees the inner component's events bubble through its own root. To raise its own event in their place, its controller listens on its root in the capture phase and stops the inner event there:

src/picker.js
export default function initialize(host) {
  host.on("connect", () => {
    const contain = (event) => {
      event.stopImmediatePropagation();
      host.dispatch("pick", event.detail);
    };
    host.root.addEventListener("select", contain, { capture: true });
    return () => host.root.removeEventListener("select", contain, { capture: true });
  });
}

The DOM runs capture listeners on an element before the event reaches a descendant, and before that element's own non-capture listeners when the element is the event's target, as it is for a component whose root is the wrapped component. So the consumer's non-capture select listeners on the root and above it never see the event, whenever they were added. A capture listener on an ancestor of the root still runs first, as does one the consumer added to the root before the controller connected. The pattern works the same in the browser runtime and in converted framework components.

Ask the consumer for behavior

Some components need behavior from whoever uses them: an editor asks for mention suggestions, an image field asks where to store a file. The component declares a request in <defs>, a named question with a typed detail and a typed answer. Its controller asks with host.request, and the consumer answers in its own JavaScript. The requests section of the proposal defines the behavior.

src/editor.html
<template component="ui-editor" controller="./editor.js">
  <defs>
    <request name="mention-query" type="object({ query: string })"
      returns="list(object({ id: string, label: string }))">
      People matching the text after @.
    </request>
  </defs>
  <div class="editor"></div>
</template>

A component that declares a request has host.request(name, detail, { signal }), which resolves to the answer, or to null when nobody answers. Each keystroke aborts the request before it, so a slow answer to @ad never replaces the answer to @ada:

src/editor.js
export default function controller(host) {
  let pending;
  host.root.addEventListener("input", () => {
    pending?.abort();
    pending = new AbortController();
    host.request("mention-query", { query: currentQuery() }, { signal: pending.signal })
      .then((people) => showMentions(people ?? []), () => {}); // rejected: superseded or disconnected
  });
}

The answer is checked against returns, and a null answer is always accepted. The request travels as a RequestEvent that bubbles from the editor's root, and the nearest listener answers with respondWith:

Pass a function prop named after the request in camelCase, or listen with the event form:

src/App.tsx
<UiEditor mentionQuery={({ query }, { signal }) => searchPeople(query, { signal })} />
<UiEditor onMentionQuery={(event) => event.respondWith(searchPeople(event.detail.query))} />

The component reads the function prop when each request arrives, so a new function takes effect on the next request. The generated types give the prop the request's detail and answer types. A framework prop and a request whose camelCase names match fail the build.

Report progress

A long answer, such as an upload, reports progress while it is pending. The asker passes a progress callback, and the responder calls event.progress(loaded, total):

src/image-field.html
<template component="ui-image-field" controller="./image-field.js">
  <defs>
    <request name="image-upload" type="object">
      <prop name="file" type="unknown" required>The file the author dropped.</prop>
      <returns type="object"><prop name="src" type="string" required></prop></returns>
    </request>
  </defs>
  <figure><img $ref="preview" alt=""><progress $ref="bar" value="0"></progress></figure>
</template>
src/image-field.js
export default function controller(host) {
  host.root.addEventListener("drop", (event) => {
    event.preventDefault();
    const file = event.dataTransfer.files[0];
    host.request("image-upload", { file }, {
      progress: (loaded, total) => { host.refs.bar.value = total === 0 ? 0 : loaded / total; },
    }).then((image) => { host.refs.preview.src = image?.src ?? URL.createObjectURL(file); });
  });
}
src/upload-setup.js
import { RequestEvent } from "@nextwebwg/html-next";

const { respond } = RequestEvent;
respond(document, "image-upload", ({ file }, { signal, progress }) => uploadImage(file, {
  signal,
  onprogress: (loaded, total) => progress(loaded, total), // XMLHttpRequest upload progress
}));

With no uploader on the page, the request resolves null and the field keeps a local preview.

Late responders

A request reaches the listeners that exist when it is asked. A request from inside a consumer component waits until that consumer's controller has run, so the two controllers load in either order. A page script that loads later, or a framework prop set after the component mounted, is a late responder: it announces itself, and each component whose request went unanswered receives a responder event and decides whether to ask again:

src/editor.js
export default function controller(host) {
  const build = () => host.request("extensions").then((extensions) => createSession(extensions ?? []));
  build();
  host.on("responder", (event) => { if (event.detail.name === "extensions") build(); });
}

respond announces its responder when it adds its listener. A page script that listens itself announces with a bubbling event:

element.addEventListener("extensions", (event) => event.respondWith([Highlight]));
element.dispatchEvent(new CustomEvent("responder", { bubbles: true, detail: { name: "extensions" } }));

A consumer controller's later host.on("extensions", …), and a function prop or event listener a framework sets after mounting, reach the waiting component the same way without dispatching an announcement.

Show a feature only when someone answers

host.hasResponder(name) is true while a responder that HTML Next knows about answers a declared request: one added with RequestEvent.respond, a consumer controller's host.on(name, …), or a framework function prop or event listener. It is a reactive read, so host.effect and host.computed run again when a responder arrives or leaves. An editor shows its "Insert image" button only while something stores images:

src/editor.html
<template component="ui-editor" controller="./editor.js">
  <defs>
    <request name="image-upload" type="object">…</request>
    <state name="canUpload" type="boolean" value="false"></state>
  </defs>
  <div>
    <button type="button" from:hidden="!$canUpload">Insert image</button>
    <div class="editor"></div>
  </div>
</template>
src/editor.js
export default function controller(host) {
  host.effect(() => { host.state.canUpload = host.hasResponder("image-upload"); });
}

The button shows when respond(document, "image-upload", …) ran before the editor mounted, and appears when it runs later. Calling the function respond returned hides it again.

It looks for responders on the component's root, its ancestors, the document and the window: a responder in a sibling subtree doesn't count, and neither does the component's own controller listening on its own root. A plain addEventListener still answers requests, but the DOM can't list an element's listeners, so hasResponder returns false for it. Register with respond to be found.

Ask from a page script

Code without a host, such as a hand-written custom element, asks with RequestEvent.request(target, name, detail, { signal, progress }). The request is the same RequestEvent, so every kind of responder answers it, the signal aborts it and the progress callback hears it. Nothing declares it, so neither the detail nor the answer is checked; type the answer with the generic:

src/photo-field.ts
import { RequestEvent } from "@nextwebwg/html-next";

const { request, hasResponder } = RequestEvent;
const image = await request<{ src: string }>(this, "image-upload", { file }, { signal });
const canUpload = hasResponder(this, "image-upload");   // now, not reactive

An unanswered request resolves null and isn't remembered for late responders, which only a component's controller hears.

When you need more

HTML Next. MIT-licensed tools for universal HTML components. Implementations of the HTML Next proposals, published from nextwebwg/html-next. Prerelease: the proposals are at Stage 0 and may change.