HTML Next · Usage

Use HTML Next components

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

The native build creates ordinary DOM elements.

Configure Vite

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

export default defineConfig({
  plugins: [htmlNext({ entries: ["src/app.html"] })],
});

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.html
<link rel="component" href="./counter.html">

<template component="x-app">
  <main>
    <h1>Workshop check-in</h1>
    <p>Count guests as they arrive. Reset for the next session.</p>
    <x-counter></x-counter>
  </main>
</template>

Attach the app to the page:

src/main.js
import { createXApp } from "virtual:html-next/components";

document.getElementById("app").append(createXApp());
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.js"></script>
  </body>
</html>

Run your project's usual npm run dev command. Click the counter and Reset. Vite builds the HTML definition into JavaScript that creates native DOM elements; a production build does not parse component definitions in the browser. Each component becomes a cloned template plus the exact DOM updates its features need, with the same behavior as the browser runtime. How builds compile components explains more.

Typecheck

Run html-next-check to check components without building. It reports invalid declarations, constraints, and component links with their file and line. Pass the same entries as your Vite configuration, then write the typed controller hosts and check controllers and other application code with your project's usual tools:

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

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

Add htmlNext() to your Vite plugins, using the same import shown above. Installed libraries are discovered automatically; you do not need to list their HTML files in entries.

Import the factory named in the library's README:

import { createUiButton } from "your-library";

document.getElementById("app").append(createUiButton());

Here your-library and createUiButton are examples; use the package and component names your library documents.

Use it without a build step

Save the app.html and counter.html files from above beside this page. The module script loads the app and its counter, then renders the <x-app> instance:

index.html — browser runtime
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Workshop check-in</title>
    <script type="module" src="https://cdn.jsdelivr.net/npm/@nextwebwg/html-next/dist/browser.js"></script>
    <link rel="component" href="./app.html">
  </head>
  <body><x-app></x-app></body>
</html>

Serve these files over HTTP. The runtime handles updates and components added to the page later. With a bundler, importing @nextwebwg/html-next/browser starts the same runtime.

Browser support

The live runtime needs native CSS @scope: Chrome 118, Safari 17.4, Firefox 146, or later. Vite output uses attribute-based style scoping for older browsers.

Loading and trust

Same-origin component links work directly. Loading a component root from another origin requires an import-map entry, or a <meta name="component-src"> list that names its source. Controllers are ordinary trusted JavaScript; browser CORS and CSP rules apply. See the proposal's resource loading rules.

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:

A consumer component answers in its controller:

src/notes.js
export default function controller(host) {
  host.on("mention-query", (event) => {
    event.respondWith(searchPeople(event.detail.query, { signal: event.signal }));
  });
}

A page script answers with RequestEvent.respond, which takes a function that returns the answer:

src/setup.js
import { RequestEvent } from "@nextwebwg/html-next";

const { respond } = RequestEvent;
respond(document, "mention-query", ({ query }, { signal }) => searchPeople(query, { signal }));

respond, request and hasResponder are static methods of RequestEvent that never read this, so they work destructured.

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.