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-unpluginThe native build creates ordinary DOM elements.
Keep your project's usual Vue plugin. The current target is Vue 3.5.
Keep your project's usual React plugin. The current target is React 19.3.
Keep your project's usual Preact plugin. The current target is Preact 11.1.0.
Keep your project's usual Svelte plugin. The current target is Svelte 5.57.1. The adapter uses the standard, unpatched framework; see the known keyed-focus limitation.
Use Angular 21.2.24 or later with matching Angular packages, @angular/compiler-cli, and a compatible TypeScript version. Keep the compiler pipeline already used by your Angular application.
Configure Vite
import { defineConfig } from "vite";
import htmlNext from "@nextwebwg/html-next-unplugin/vite";
export default defineConfig({
plugins: [htmlNext({ entries: ["src/app.html"] })],
});import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import htmlNext from "@nextwebwg/html-next-unplugin/vite";
export default defineConfig({
plugins: [htmlNext({ target: "vue" }), vue()],
});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()],
});import { defineConfig } from "vite";
import preact from "@preact/preset-vite";
import htmlNext from "@nextwebwg/html-next-unplugin/vite";
export default defineConfig({
plugins: [htmlNext({ target: "preact" }), preact()],
});import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import htmlNext from "@nextwebwg/html-next-unplugin/vite";
export default defineConfig({
plugins: [htmlNext({ target: "svelte" }), svelte()],
});Add the adapter to your existing Angular AOT Vite pipeline:
import htmlNext from "@nextwebwg/html-next-unplugin/vite";
const htmlComponents = htmlNext({
target: "angular",
entries: ["src/counter.html"],
});Include htmlComponents in your existing plugins array alongside your Angular compiler plugin. The adapter compiles imported HTML components and links Angular dependencies; your application pipeline compiles its own Angular templates.
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:
<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:
import { createXApp } from "virtual:html-next/components";
document.getElementById("app").append(createXApp());<!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.
Save the counter you built as src/counter.html. Use it inside the app:
<script setup lang="ts">
import { XCounter } from "./counter.html";
</script>
<template>
<main>
<h1>Workshop check-in</h1>
<p>Count guests as they arrive. Reset for the next session.</p>
<XCounter />
</main>
</template>Attach App to the page:
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");<!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.ts"></script>
</body>
</html>Run your project's usual npm run dev command. Click the counter and Reset.
The result uses Vue 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.
Save the counter you built as src/counter.html. Use it inside the app:
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:
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("app")!).render(<App />);<!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.
Save the counter you built as src/counter.html. Use it inside the app:
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:
import { render } from "preact";
import App from "./App";
render(<App />, document.getElementById("app")!);<!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 Preact 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.
Save the counter you built as src/counter.html. Use it inside the app:
<script lang="ts">
import { XCounter } from "./counter.html";
</script>
<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:
import { mount } from "svelte";
import App from "./App.svelte";
mount(App, { target: document.getElementById("app")! });<!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.ts"></script>
</body>
</html>Run your project's usual npm run dev command. Click the counter and Reset.
The result uses Svelte 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.
Save the counter you built as src/counter.html. Its declared root is a div, so use that element with the generated x-counter attribute selector:
import { Component } from "@angular/core";
import { XCounter } from "./counter.html";
@Component({
selector: "app-root",
standalone: true,
imports: [XCounter],
template: `
<main>
<h1>Workshop check-in</h1>
<p>Count guests as they arrive. Reset for the next session.</p>
<div x-counter></div>
</main>
`,
})
export class App {}Keep your application's normal bootstrap. For a standalone application:
import { bootstrapApplication } from "@angular/platform-browser";
import { App } from "./app";
bootstrapApplication(App);Angular retains that native host through rendering and hydration. A delegated component keeps its identities as attributes on the same element. Components whose root switches element type use an ng-template attribute selector so their rendered root can change. See the Converter guide.
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:
{
"scripts": {
"typecheck": "html-next-check src/app.html && html-next-sync && tsc --noEmit"
}
}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:
{
"scripts": {
"typecheck": "html-next-check --target vue src/counter.html && html-next-sync && vue-tsc --noEmit"
}
}If a component needs declarations before its first import, add entries: ["src/counter.html"] to htmlNext({ target: "vue" }).
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:
{
"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" }).
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:
{
"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: "preact" }).
Set jsx to react-jsx and jsxImportSource to preact in your TypeScript configuration.
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:
{
"scripts": {
"typecheck": "html-next-check --target svelte src/counter.html && html-next-sync && svelte-check"
}
}If a component needs declarations before its first import, add entries: ["src/counter.html"] to htmlNext({ target: "svelte" }).
Generate Angular metadata before checking templates. Extend the generated configuration from your application tsconfig, preserving your project's compiler options:
{
"extends": "./src/html-next.angular.json",
"compilerOptions": { "allowArbitraryExtensions": true },
"angularCompilerOptions": { "strictTemplates": true },
"include": ["src/**/*.ts", "src/html-next.d.ts"]
}{
"scripts": {
"typecheck": "html-next-check --target angular src/counter.html && html-next-sync && ngc --noEmit"
}
}The generated config maps HTML and library imports to their Angular class metadata. If your application declares its own TypeScript paths, merge the generated mappings: TypeScript replaces inherited paths rather than merging them.
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 itstype, with.pending,.okand.error.refs: the element type of each referenced element, and a list for a ref inside$each. A ref inside$ifmay beundefined.slotsandscripts: the declared names, with each script'sready,load()and status.request(name, detail, options)andhasResponder(name): the declared request names, each detail and answer type, fromtypeandreturns.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,connectanddisconnect, and DOM events.
<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>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:
/** @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-libraryAdd 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.
import { UiButton } from "your-library";Use those exports like other Vue 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.
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.
import { UiButton } from "your-library";Use those exports like other Preact 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.
import { UiButton } from "your-library";Use those exports like other Svelte 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.
Import the library component into your standalone component's imports. Use the native root documented by the library with its attribute selector. For a component whose native root is button:
import { Component } from "@angular/core";
import { UiButton } from "your-library";
@Component({
selector: "app-actions",
standalone: true,
imports: [UiButton],
template: `<button ui-button>Save</button>`,
})
export class Actions {}For a library component whose root switches between elements, use its generated attribute on ng-template. For example, Looma’s Button selects a native button or link from its href input:
<ng-template ui-button href="/settings">Settings</ng-template>The template renders the selected native element in place. Its inputs and controller remain available when that element changes.
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:
<!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.
For Vue, use the Vite adapter above. Choose HTML Next in this section for the direct browser-runtime setup.
For React, use the Vite adapter above. Choose HTML Next in this section for the direct browser-runtime setup.
For Preact, use the Vite adapter above. Choose HTML Next in this section for the direct browser-runtime setup.
For Svelte, use the Vite adapter above. Choose HTML Next in this section for the direct browser-runtime setup.
Use your Angular AOT build with the 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:
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.
<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:
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:
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:
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.
Pass a function prop named after the request in camelCase, or listen with the event form:
<UiEditor :mention-query="({ query }, { signal }) => searchPeople(query, { signal })" />
<UiEditor @mention-query="(event) => event.respondWith(searchPeople(event.detail.query))" />Pass a function prop named after the request in camelCase, or listen with the event form:
<UiEditor mentionQuery={({ query }, { signal }) => searchPeople(query, { signal })} />
<UiEditor onMentionQuery={(event) => event.respondWith(searchPeople(event.detail.query))} />Pass a function prop named after the request in camelCase, or listen with the event form:
<UiEditor mentionQuery={({ query }, { signal }) => searchPeople(query, { signal })} />
<UiEditor onMentionQuery={(event) => event.respondWith(searchPeople(event.detail.query))} />Pass a function prop named after the request in camelCase, or listen with the event prop:
<UiEditor mentionQuery={({ query }, { signal }) => searchPeople(query, { signal })} />
<UiEditor onmention-query={(event) => event.respondWith(searchPeople(event.detail.query))} />Pass the request's camelCase input a function on your Angular component. Angular templates refer to that function:
<div ui-editor [mentionQuery]="findMentions"></div>readonly findMentions = (
{ query }: { query: string },
{ signal }: { signal: AbortSignal },
) => searchPeople(query, { signal });Import the converted UiEditor into the component's imports. The input is checked against the request's declared detail and answer types.
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):
<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>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); });
});
}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:
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:
<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>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:
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 reactiveAn unanswered request resolves null and isn't remembered for late responders, which only a component's controller hears.
Add to the document head
A component declares what it adds to document.head in its <defs>: a <title>, <meta> entries, <link> resource hints and metadata links, and JSON-LD data blocks. While an instance is connected, the runtime adds them to the head; when it disconnects, they leave. The head declarations section of the proposal defines the behavior.
<template component="page-article">
<defs>
<prop name="slug" type="string" required>The article's URL slug.</prop>
<data name="article" src="/api/articles/{slug}"
type="object({ title: string, summary: string, schema: object })">
<param name="slug" from:value="$slug">
</data>
<title>{$article.value.title} · Field Notes</title>
<meta name="description" from:content="$article.value.summary">
<link rel="preconnect" href="https://images.example">
<script type="application/ld+json" from:value="$article.value.schema"></script>
</defs>
<article><h1>{$article.value.title}</h1></article>
</template>A <title> takes inline text expressions, a <meta> binds from:content, and a <link> binds from:href. A JSON-LD block holds literal JSON, copied unchanged, or binds its whole value with from:value: the runtime writes the object or list as JSON with every < escaped as <, so a value can never close the element, and a null value leaves the block out, as here while the article loads. A <link> uses preconnect, dns-prefetch, preload, modulepreload, prefetch, canonical, alternate, icon, manifest, author, license, help, privacy-policy or terms-of-service.
Every element the runtime manages carries data-component-head. When several connected components declare the same thing:
- One title, one description. A
<title>, a canonical link, and each<meta>name or property go to the deepest component, and between two at the same depth, to the one that connected last. A page inside its layout sets the title over the layout's, and when the page leaves, the layout's title returns, in the same element. - Shared links. Two cards that preconnect to the same host share one element, which stays until the last of them disconnects.
- JSON-LD. Each JSON-LD declaration of each connected instance is its own block, so two products on a page write two blocks even when their data is identical. A bound value change updates the block's text in place, as the same node.
- Lists stay whole. A component's repeated
<meta property="og:image">entries are one list, andog:image:widthand other sub-properties stay with the image their component declared. The deepest component's list replaces another's whole. - The page's own head returns. A title or description the document itself has is set aside, unchanged, while a component replaces it, and put back as the same element afterwards. A link the document already has, such as its own preconnect, satisfies a component's declaration of it.
A page change, such as a router replacing one page with another inside the same layout, changes the head once: the title goes straight from one page's to the next, and links both pages declare stay in place. Inside a view transition, the change happens in the transition's update.
Some entries affect the whole page, its identity for search engines and sharing, and page-wide behavior: the canonical, alternate, icon and manifest links, the robots, referrer, viewport, color-scheme, theme-color and component-src metadata, and og:url. Only an application component sets these page-wide head entries: one in the application's own source, written in the page, or loaded by URL from the page's origin or a source the page lists in component-src, which takes a CSP source list:
<meta name="component-src" content="'self' https://cdn.acme.example/ui/">A component reached through a bare specifier, such as an installed package or an import-map entry, is an imported component, and so is everything it links. Its page-wide head entries are ignored with an HL011 warning; its other head declarations apply.
A definition rejects <meta http-equiv>, <meta charset>, <base>, <link rel="stylesheet">, inline scripts and import maps, the blocking and nonce attributes, and event-handler attributes, with an HC025 error. Invalid JSON-LD, or a block with both literal content and from:value, is HC026, and two titles or canonical links in one definition are HC027. A <title>, <meta>, or <link> placed directly in a <template component>, outside <defs>, is HS002. Head declarations behave the same in the browser runtime, Vite output, and converted framework components, which share one head manager on the page.
Load a third-party script
A component that needs a third-party SDK declares its script in <defs>. The runtime inserts an ordinary <script> into document.head, once per URL and type, and never removes it, so the browser fetches and runs it once under the page's Content Security Policy, however many components declare it. The declared scripts section of the proposal defines the behavior.
<template component="checkout-form" controller="./checkout-form.js">
<defs>
<prop name="pk" type="string" required>The Stripe publishable key.</prop>
<event name="paymentready"></event>
<handler name="announceReady"><dispatch event="paymentready"></dispatch></handler>
<link rel="preconnect" href="https://js.stripe.com">
<script name="stripe" src="https://js.stripe.com/v3/" loading="lazy" on:load="announceReady"></script>
</defs>
<form>
<div $ref="card"></div>
<p $if="$stripe.error" role="alert">Payments are unavailable right now.</p>
<p $if="$stripe.pending">Loading the secure payment form…</p>
<button>Pay</button>
</form>
</template>export default function controller(host) {
let card;
host.on("connect", () => {
host.scripts.stripe.ready.then(() => {
if (!host.root.isConnected) return;
card = Stripe(host.props.pk.value).elements().create("card");
card.mount(host.refs.card);
}, () => {});
});
host.on("disconnect", () => { card?.destroy(); card = undefined; });
}loading="eager", the default, inserts the script when an instance connects;lazywhen its root comes within 1,250 pixels of the viewport;manualwhen the controller callshost.scripts.name.load().$stripe.pending,.okand.errorread the script's status, andhost.scripts.stripeaddsready, a promise that settles once with the script, andload().on:loadandon:errorrun a handler once for each connection, as soon as the script settles: when it firesloadorerror, or straight away when it already has. A connection that ends first gets neither, and a reconnected form announces again.type="module",integrity,crossorigin,referrerpolicyandfetchpriorityare copied onto the inserted element. Declarations of one script that disagree onintegrity,crossoriginorreferrerpolicyfail withHR011for the later one. A baresrc, such as@acme/maps/v2.js, resolves through the page's import map (HR012when it maps nothing).- A
<script>the page already has with the same URL is used instead of inserting another. - A relative
src, such as./vendor/sdk.js, is relative to the component's file. A Vite build serves and emits it as an asset; the converter resolves it againstpublicRootURL. - Under a nonce-based policy, the runtime puts the nonce of the
<link rel="component">or inline<template component>that imported the definition on the scripts it inserts. Under'strict-dynamic'or an origin allowlist such asscript-src 'self' https://js.stripe.com, nothing more is needed.
Server rendering with renderComponents(html, { definitions, head }) returns the page's head as well: the application's own head elements, the components' contributions reconciled into it, each replaced element in a <template data-component-head="set-aside">, and a <link rel="preload" as="script">, or modulepreload, for each eager script. It never writes an executing <script>. Hydration adopts those elements, restores the set-aside ones as the same nodes when no component sets them, and inserts each eager script once, served from its preload.
Converted components render the head on the server through their framework. Each target's head helper collects the head declarations the render reached, and writeCollectedHead writes them into a document holding the application's own head, with the same set-aside templates and preload links:
Render the page with renderComponents, including the application's head in the input document. Its serialized result includes resolved head declarations and eager script preloads. See the Node rendering guide for the page renderer and hydration setup.
import { renderToString } from "@vue/server-renderer";
import { createHeadCollector, HEAD_CONTEXT, writeCollectedHead } from "./generated/vue/head";
const collector = createHeadCollector();
const body = await renderToString(app, { [HEAD_CONTEXT]: collector });
writeCollectedHead(document, collector); // a server document, such as jsdom's, with the page's own <head>import { renderToString } from "react-dom/server";
import { collectHead, createHeadCollector, writeCollectedHead } from "./generated/react/head";
const collector = createHeadCollector();
const body = collectHead(collector, () => renderToString(<App />));
writeCollectedHead(document, collector); // a server document, such as jsdom's, with the page's own <head>import renderToString from "preact-render-to-string";
import { collectHead, createHeadCollector, writeCollectedHead } from "./generated/preact/head";
const collector = createHeadCollector();
const body = collectHead(collector, () => renderToString(<App />));
writeCollectedHead(document, collector); // a server document, such as jsdom's, with the page's own <head>import { render } from "svelte/server";
import { createHeadCollector, HEAD_CONTEXT, writeCollectedHead } from "./generated/svelte/head.svelte";
const collector = createHeadCollector();
const { body } = render(App, { context: new Map([[HEAD_CONTEXT, collector]]) });
writeCollectedHead(document, collector); // a server document, such as jsdom's, with the page's own <head>Angular's generated head directive writes into the document supplied by Angular server rendering. Render the application normally; head contributions are included in the serialized document:
import { renderApplication, provideServerRendering } from "@angular/platform-server";
import { bootstrapApplication } from "@angular/platform-browser";
import { App } from "./app";
const html = await renderApplication(
context => bootstrapApplication(App, {
providers: [provideServerRendering()],
}, context),
{ document: pageTemplate, url: requestURL },
);Use your server's page template and request URL. Browser hydration adopts the same managed head elements.
Serialize document.head into the page. The client hydrates as usual and adopts the head the server wrote.
When you need more
- Ship a library when other projects need your components.
- Use the CLI reference to check definitions or build without Vite.
- See the Vite plugin reference for libraries, externally defined custom elements, and build limits.