§5 · Declarative HTML Components Level 1
Components & Composition
A component is typed markup that lowers to a real native element, defined as data. This is HTML Next's answer to Web Components: native semantics stay the browser's, the interface is inspectable markup, and the same source compiles to idiomatic React, Vue, and Svelte.
Definitions, native roots, slots, and external composition: Level 1Definition: <template component>
A component is defined by a native <template component="tag">. The <template> is inert (browsers parse its contents into a DocumentFragment and render nothing), so a definition degrades to inert markup with no runtime today, and could be consumed natively if the shape were adopted, the way <template shadowrootmode> went from inert to browser-native for Declarative Shadow DOM.4 Inertness is the transition guarantee; native consumption is the end state. template[component] is also a cheap selector for the polyfill. Its direct children are an optional <defs> region (the interface, behavior, and head declarations), one markup root, and an optional <style>.
button.html
<template component="x-button" status="early"
summary="A native button with custom presentation.">
<!-- <defs>: renders nothing. interface + behavior + data live here -->
<defs>
<prop name="variant" type="keyword" values="outline, solid, destructive, ghost"
default="outline">Visual treatment.</prop>
<event name="press"></event>
<handler name="press"><dispatch event="press"></dispatch></handler>
</defs>
<!-- the visible markup: one native root, referencing the defs by name -->
<button on:click="press"><slot></slot></button>
<!-- styles are automatically scoped to this component (see Style scoping) -->
<style>:host { box-sizing: border-box; }</style>
</template>
The interface is declarative HTML. A <prop> states only what markup cannot already say: type, default, requiredness, description; its target is read from the from:attribute/.property binding and the native element from the markup root, so neither is restated. The invocation tag comes from the component attribute; a single hyphen keeps it collision-safe against native elements. A compiler lowers all of this to a normalized JSON contract as build output (CSP-safe, inspectable by docs and tooling); JSON is the compiled artifact, and HTML is the authoring form.
Prop declarations follow platform precedentsvalues follows JSON Schema's finite-value constraint1; default follows XML Schema's default attribute2; required is the HTML boolean attribute of the same name3; and a prop's description is its element text, as with <option>.
Two regions: <defs> and the markup
A definition has two visibly separate parts, so a reader can tell at a glance what renders and what only describes behavior. The <prop>, <event>, <request>, and <slot> interface declarations, reactive <state> and <computed>, read and write <data> resources, and <handler> blocks live inside a single <defs> region as flat siblings, together with the head declarations: the title, metadata, links, and scripts the component adds to the document head while an instance is connected. A <slot> in <defs> is a declaration of that slot, its props or the children it accepts, and renders nothing; a <slot> in the markup is an outlet, where projected content renders. The visible markup is the one native root and its <slot> outlets. The content stays pure markup that points at behavior by name; the behavior stays a small labeled list above it.
This mirrors the document's own <head>/<body> split, declarations and resources versus rendered content, applied fractally to a component. The name is borrowed from SVG, where <defs> already means exactly this: definitions that render nothing and are referenced by name from elsewhere.5
<defs> survives fragment parsing<defs> is an ordinary element in HTML content, so it round-trips intact through outerHTML and carries the intended definition-only meaning. The HTML fragment parser instead discards <head> and <body> wrappers inside a <template> and hoists their children out (verified against the reference implementation's parse5-based build). Head elements need no wrapper: parse5, and Chromium's DOMParser and innerHTML, keep <title>, <meta>, <link>, and both <script> forms inside <defs>, where they are neither hoisted out nor executed.
Requests: what a component asks its consumer
Some components need behavior from whoever uses them: a rich-text editor asks for mention suggestions, an image field asks where to store a file. A prop cannot carry that behavior, because every prop has an HTML attribute form and a function has none. So the definition declares a request in <defs>, a named question with a typed detail and a typed answer. Its controller asks it with host.request, and the consumer answers in its own JavaScript.
ui-editor.html
<defs>
<request name="mention-query" type="object({ query: string })"
returns="list(object({ id: string, label: string }))">People matching the text after @.</request>
<request name="image-upload" type="object">
<prop name="file" type="unknown" required>The File the author dropped or pasted.</prop>
<returns type="object">
<prop name="src" type="string" required></prop>
<prop name="width" type="integer"></prop>
<prop name="height" type="integer"></prop>
</returns>
</request>
</defs>
| Attribute or child | Meaning |
|---|
name | The request's event type: lowercase kebab case, like an <event> name. A definition must not declare an <event> and a <request> with the same name, or either one named responder, the host event for late responders. A build tool reports either as an error. The live parser warns and ignores the declaration: for a shared name it keeps the <event> and ignores the <request>. |
type | The type of the request's detail, written as for an <event>. Omitted, the detail is null. |
returns | The type of the answer. Omitted, the answer is unknown. |
<prop> children | The detail's fields, when type="object", as for an expanded event declaration. |
one <returns type> child | The answer's type in expanded form, holding its own <prop> children. Use it in place of the returns attribute. |
| text content | The request's description, as for a <prop>. |
A request lives in JavaScript alone: reflection, the rendered form, and hydration data all leave it out. Its detail and answer are JavaScript values, so they use the request value types. A generated framework component exposes each request as a function prop.
Requests and events
A request and an event travel as the same native event, but a component declares them apart, because they tell the consumer different things:
- The contract lists what a component asks for apart from what it announces. An unanswered request degrades the component quietly, so a consumer must be able to see it in generated documentation, types, and editor completions.
- Framework output maps mechanically. An
<event> becomes an onX handler, and a <request> becomes a function prop that returns the answer. - Each method has one return type.
host.dispatch always returns a boolean, and host.request always returns a promise. - Events keep their behavior. Every listener receives an event, while the first responder stops a request.
<dispatch> stays fire-and-forget.
The second declaration costs little, since both run on the same RequestEvent machinery.
Head declarations: what a component adds to the document
Many components need something in the document head while they are on the page. A checkout form needs its payment provider's script. A product card describes itself to search engines with JSON-LD. A video embed opens a connection to its host early. A page sets its title and description. A component declares these in <defs>, beside its props and state, and the runtime adds them to document.head while an instance is connected.
checkout-form.html
<template component="checkout-form" controller="./checkout-form.js">
<defs>
<prop name="pk" type="string" required>The Stripe publishable key.</prop>
<event name="paymentready"></event>
<event name="paymentunavailable"></event>
<handler name="announceReady"><dispatch event="paymentready"></dispatch></handler>
<handler name="announceUnavailable"><dispatch event="paymentunavailable"></dispatch></handler>
<!-- Head declarations: in document.head while a checkout form is connected. -->
<link rel="preconnect" href="https://js.stripe.com">
<script name="stripe" src="https://js.stripe.com/v3/" loading="lazy"
on:load="announceReady" on:error="announceUnavailable"></script>
</defs>
<form>
<div $ref="card"></div>
<template $match>
<p $when="$stripe.error" role="alert">Payments are unavailable right now.</p>
<p $when="$stripe.pending">Loading the secure payment form…</p>
</template>
<button>Pay</button>
</form>
</template>
checkout-form.js
export default function controller(host) {
let card;
host.on("connect", () => {
// Settles on the inserted <script> element's own load or error event.
host.scripts.stripe.ready.then(() => {
if (!host.root.isConnected) return;
card = Stripe(host.props.pk.value).elements().create("card"); // Stripe.js's global
card.mount(host.refs.card);
}, () => {}); // the form already shows $stripe.error
});
host.on("disconnect", () => { card?.destroy(); card = undefined; });
}
The site adds Stripe's origins to its Content Security Policy (CSP), as Stripe's own instructions say. No other setup is needed:
HTTP response header
Content-Security-Policy:
script-src 'self' https://js.stripe.com;
frame-src https://js.stripe.com https://hooks.stripe.com;
connect-src 'self' https://api.stripe.com
When a checkout form connects, the runtime adds the preconnect link to the head. When the form comes near the viewport, the runtime inserts an ordinary <script src="https://js.stripe.com/v3/"> into document.head, and the browser fetches and runs it under the page's CSP, as it would a hand-written tag. $stripe.pending is true until that element fires load or error. The same event runs the form's on:load or on:error handler, which tells the page whether it can take a card, and settles host.scripts.stripe.ready, which the controller waits on to mount Stripe's card field. When the last checkout form disconnects, the runtime removes the preconnect link. The script stays loaded, because a browser cannot unload a script that has run.
Stripe recommends loading Stripe.js on every page so it can collect fraud signals. To do that, the application's layout declares the same script with the default loading="eager". Both declarations name one URL, so the browser loads it once, as soon as the layout connects, and the checkout form's ready settles from that one load.
The design keeps three rules from the rest of this chapter, explained in Security: a definition never contains executable code, only visible references to it; importing a definition runs nothing; and importing a component does not give it control of the application's document.
What a component can declare
Declaration in <defs> | Adds to document.head | Bindings |
|---|
<title> | The document title. | Inline text expressions in its text. |
<meta name content> or <meta property content> | A named metadata entry, such as description or Open Graph's og:title. | from:content |
<link rel href> with a keyword listed below | A resource hint or a link to related metadata. | from:href, under the URL attribute policy. |
<script type="application/ld+json"> | A JSON data block, such as schema.org structured data. | from:value for the whole value. See Data blocks and bound metadata. |
<script src> | An external script. See Declared scripts. | on:load and on:error only. Its other attributes are literal, so a build can list them. |
A <link> head declaration uses one of these rel keywords: the resource hints preconnect, dns-prefetch, preload, modulepreload, and prefetch; the metadata links canonical, alternate, icon, and manifest; or the annotations author, license, help, privacy-policy, and terms-of-service. A head element may carry the attributes HTML defines for it, such as hreflang, sizes, media, as, and crossorigin on a link, and no others.
A component's CSS is its scoped <style>, including shared CSS through @import. An unscoped stylesheet link would style the whole document, bypassing scoping and giving a component a second CSS path, so <link rel="stylesheet"> is invalid in a definition.
These are invalid anywhere in a definition: an inline executable <script> (one with no src that is not a JSON-LD data block), any other script type (including importmap and speculationrules), <meta http-equiv>, <meta charset>, <base>, <link rel="stylesheet">, <link rel="import">, the blocking and nonce attributes, and event-handler attributes. A build reports each one as an error, and the live loader rejects the definition with a diagnostic.
When a declaration takes effect
Head declarations follow the instance's connection, the same lifetime that runs controllers:
- Import. Fetching, parsing, and registering a definition changes nothing in the document.
- Connect. The instance's declarations become contributions to the document head. The runtime inserts an element for each one, or shares an element that is already there.
- Update. While the instance is connected, a bound title,
content, href, or data block updates in place, like any other binding. A JSON-LD block is its own instance's: a value change updates its text as the same node in the same position, a null value removes it, and a later value inserts it again. - Disconnect. The instance's contributions leave. An element stays while another connected instance still contributes it; a title or description falls back to the next contributor, and then to the application's own element, restored as it was.
- Scripts. A declared script is inserted at most once per document and is never removed. Removing a
<script> element does not undo code that has run, and inserting a second one would run that code again. - One change per update. The runtime applies head changes once per update, after that update's connections and disconnections, so a page change is a single head change.
Every change is an ordinary DOM insertion, removal, or attribute or text change in document.head, made to elements that carry the data-component-head marker. The application's own head elements are never changed: while a contribution overrides one, it is set aside and restored. Head management gives the exact steps.
Declared scripts
A declared script is an ordinary <script> element. The declaration gives it a name and says when to insert it. After the runtime inserts it, it is a script inserted through the DOM, and everything that follows is HTML's existing script behavior: classic or module evaluation, fetching, integrity, CORS, CSP, and the load and error events.
<script name="maps" src="https://maps.example/sdk/v2.js"
integrity="sha384-…" crossorigin="anonymous" loading="manual"></script>
The declaration's attributes fall into two groups. The runtime reads the declaration attributes and does not copy them. It copies the standard attributes onto the element it inserts, where they mean exactly what they mean on any <script>.
| Attribute | Group | Meaning |
|---|
name | declaration | Names the script's status: $stripe in expressions and host.scripts.stripe in a controller. It shares the component's flat declaration namespace. Optional, except that loading="manual" requires it. |
loading | declaration | When the runtime inserts the element: eager (the default), lazy, or manual. |
on:load, on:error | declaration | Event bindings that run a <handler> when the script becomes available to this instance, or fails. See below. |
src | standard | Required. The script's URL or a bare name, resolved as below. |
type | standard | Omitted for a classic script, or module for a module script. |
integrity, crossorigin, referrerpolicy, fetchpriority | standard | Copied unchanged. |
No other attribute is accepted. async and defer add nothing, because a script inserted through the DOM already runs as soon as it arrives. The nonce comes from the application, never from a definition (see CSP).
type="module" is accepted because it is an ordinary script type with no extra rules: the browser's module map already evaluates each module URL once. A controller is still the way to give a component its own imperative behavior. A module script suits an SDK that is published as a module.
When it loads. The loading keyword comes from <img> and <iframe>, which use it for the same choice. On a declared script it means only when the runtime inserts the element:
eager: when an instance connects.lazy: when a connected instance's root first comes near the viewport, by the rule the browser applies to <img loading="lazy">.manual: when the controller calls host.scripts.name.load().
Shared by every declaration. The head holds one element per script URL and type. Every instance that declares that script reads the same status and ready from that one element, and runs its own on:load or on:error each time it connects. The element is never removed, so a script never runs twice, however many components declare it and however often they connect. Declarations of one script agree on integrity, crossorigin, and referrerpolicy, because those change the fetch; a disagreement is an error. fetchpriority comes from the declaration that inserts the element. A <script> the application authored with the same URL is shared the same way: the runtime uses it instead of inserting another. Head management gives the exact rules, including how the runtime reads the status of a script that may have loaded before the runtime looked.
Status. Expressions read a script's status like a <data> resource:
| Path | Meaning |
|---|
stripe.pending | The script element is inserted and has fired neither load nor error. |
stripe.ok | The script fired load. |
stripe.error | The error event the script fired, or null. |
Before insertion, pending and ok are false and error is null. A controller reads the same status through host.scripts, together with ready, a promise about the script that settles once: it resolves on load and rejects with the error event, and load(), which inserts the element if it is not inserted yet and returns ready.
on:load and on:error. These bindings mean "this script is available to this connection of the instance" and "this script failed". They call a <handler> like any element's on: binding, with the element's load or error event as $$event.
- Each time an instance connects, exactly one of the two runs, as soon as the script settles: when its
load or error event fires, or straight away if it has already settled, through another component's declaration or an authored script. - Nothing runs while the instance is disconnected. A connection that ends before the script settles gets neither handler, and a later connection gets its own. Each connection is a fresh setup, so work done in
on:load, such as mounting a card field, runs again after a reconnect. - Neither runs before the script is inserted, for example before a
lazy script's instance comes near the viewport, or until a manual script's load() is called, unless another declaration has already inserted it.
Here two components on one page both need Stripe.js:
<!-- saved-cards.html: needs Stripe as soon as it shows. -->
<template component="saved-cards">
<defs>
<state name="ready" type="boolean" value="false">
<handler name="enable"><set name="ready" value="true"></set></handler>
<script name="stripe" src="https://js.stripe.com/v3/" on:load="enable"></script>
</defs>
<ul from:aria-busy="not $ready">…</ul>
</template>
<!-- On the page: both declare https://js.stripe.com/v3/. -->
<saved-cards></saved-cards>
<checkout-form pk="pk_live_…"></checkout-form>
document.head, once both have connected
<link rel="preconnect" href="https://js.stripe.com" data-component-head>
<script src="https://js.stripe.com/v3/" data-component-head></script>
The saved-cards list is eager, so it inserts the script when it connects. The checkout form is lazy, but the script it would have waited for is already loading, so it shares that element. When the element fires load, the browser has run Stripe.js once, and each instance's on:load handler runs: enable for the list and announceReady for the form. If the form is removed and added again, its announceReady runs again on that connection, straight away, because the script has already loaded.
Resolution. A script's src resolves by the controller's rules:
- A URL-like value (relative, root-relative, or absolute) resolves against the definition's final response URL.
https://js.stripe.com/v3/ is absolute, so it stays as written. - A bare value, such as
src="@acme/maps-sdk", resolves through the application's import map with the module-specifier resolution algorithm, as a bare component reference does. The application then chooses the URL, and the definition stays the same in every deployment. - Any component, whether from the application, an installed package, or a live import, may name any URL, including an unpinned third-party one like Stripe's. Only the document's CSP decides whether it loads, and builds and developer tools list it for review.
- A controller keeps its live-scope rule, because a controller is the component's own code from its publisher, while a declared script is typically a third-party SDK from another origin.
Declared scripts under CSP
The inserted element is checked like any script:
- Under an origin allowlist, such as
script-src 'self' https://js.stripe.com, the browser checks the script's URL against the list. - Under
'strict-dynamic', a script that a trusted script inserts is trusted. The polyfill runtime is a script the page trusts, so the scripts it inserts load. This is the pattern 'strict-dynamic' exists for. - Under a nonce, a native implementation must copy the
nonce of the application element that imported the definition, the <link rel="component"> or the inline <template component>, onto each script that definition's graph inserts, including definitions reached from it. The application grants trust by putting its nonce on the import, the same act as putting it on a <script>. The polyfill does the same under a nonce-only policy. Without a nonce on the import, a nonce policy blocks the script, and error reports it.
integrity and crossorigin are supported and recommended for any versioned URL. A build lists every declared script URL beside the controllers, with the component that declares it, so the application can review its scripts and generate its script-src. A definition that declares a script is trusted application code, exactly like one with a controller.
Data blocks and bound metadata
Head declarations use the bindings a component already has. Here an article page builds its title, description, and structured data from a <data> resource:
pages/article.html
<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">
<script type="application/ld+json" from:value="$article.value.schema"></script>
</defs>
<article><h1>{$article.value.title}</h1>…</article>
</template>
<title> text uses inline text expressions, and <meta> binds from:content. A <link> may bind from:href, which applies the URL attribute policy.- A JSON-LD block with literal content is inert text: the runtime copies it unchanged, and braces in it are literal, because inline text expressions apply to template text, not script content.
from:value on a JSON-LD block binds the whole value. The value is an object or a list. The runtime serializes it as JSON and writes every < as \u003c, so the text can never close the element or open a comment when the page is serialized as HTML. A null result omits the element, as it does here while the article loads. Any other result is an invalid reactive result and keeps the last value written.- A JSON-LD block has literal content or
from:value, not both.
When two components declare the same thing
A page and the layout around it may both set a title. Two cards on one page may both preconnect to the same image host. Each head declaration has a key, and its key's kind decides what happens when several connected instances contribute it:
| Kind | Declarations | What the head holds |
|---|
| Competing | <title>, <link rel="canonical">, and each <meta> by its name or property | The entries of one component: the one with the highest precedence. When it disconnects, the next contributor's entries apply, and with none left, the application's own element returns. |
| Shared | Every other <link>, keyed by rel and href; alternate links also include hreflang | One element per key, kept while any connected instance contributes it. The element is reference-counted: it is removed when the count reaches zero. |
| Owned | Each JSON-LD block | One element for each declaration of each connected instance, never shared: two products on a page are two blocks, even with identical text. |
| Script | <script src>, keyed by its resolved URL and type | One element per key, for the document's lifetime. See Declared scripts. |
Precedence: the deepest component wins. Among the contributors to a competing key, the instance deepest in the logical component tree has the highest precedence. A page renders inside its layout, so it is deeper, and its title replaces the layout's. Between instances at the same depth, the one that connected later wins.
A key's value can be a list. A <meta> key competes by its name or property, because two values for one name conflict: a document has one description. A component may still repeat an entry, such as two <meta property="og:image"> elements; its entries for a key, in declared order, are that key's value. Lists from different components never mix: the winner's list replaces the previous one whole. A definition must not declare two <title> elements, two canonical links, or two links with the same shared key. For alternate links, the hreflang value is part of that key, so a language and x-default may point to the same URL:
<link rel="alternate" hreflang="en" href="https://docs.example/">
<link rel="alternate" hreflang="x-default" href="https://docs.example/">
Sub-properties follow their parent. A <meta> name or property with three or more colon-separated parts, such as og:image:width, is a sub-property of the key without its last part, og:image. It does not compete on its own. It applies only while the same component wins its parent key, and it stays beside the parent entries it was declared with. So a page's og:image:width can never describe a layout's og:image. The rule is about the shape of the name, so it covers og:image:*, og:video:*, and conventions not yet written.
The application's own head is set aside, never changed. An element the application authored in its document head carries no marker. While a contribution wins a key the authored head also has, the runtime sets the authored element aside, detached but unmodified, and puts the managed element in its place. When no contributor remains, the runtime removes the managed element and puts the same authored node back where it was. The restore is exact because it is the same node. An authored shared element, such as a preconnect the application already has, satisfies a contribution with the same key, and the runtime inserts nothing; the authored element is never removed.
Joining a list across componentsA component may one day need to add to a list another component owns, for example a second og:image from an embedded card. An opt-in additive attribute on a head declaration, which joins the key's list instead of competing for it, is a possible future addition if a real case appears. It is not part of this proposal.
Head management
This section is normative. Polyfills, converters, server renderers, and native implementations must produce the head these steps produce, so that output from one implementation hydrates under another.
The marker attribute. Every element in document.head that the component runtime manages must carry the attribute data-component-head, spelled exactly so in ASCII lowercase. On a contribution's element its value is the empty string. On a <template> that server output uses to hold set-aside elements, its value is set-aside. Its presence means "the component runtime manages this element"; it carries no other information. An element in document.head without the attribute is authored.
Authored elements are never changed: while a contribution overrides one, it is set aside and restored. An implementation must not modify an authored element's attributes or content, must not adopt one as a contribution's element, and must not remove one except to set it aside under step 2 below. A declared script may share an authored <script> element under step S2, without modifying it. Server rendering must write the marker on every element it writes for a head declaration, and hydration must adopt only elements that carry it. A native implementation may track ownership internally, but must write and read this attribute wherever ownership passes from server output to a client.
Terms.
- A contribution is one head declaration of one connected instance, with its bindings evaluated. A page-wide head entry from an imported component is not a contribution; the implementation must report a diagnostic for it. A JSON-LD declaration whose bound value is
null is not a contribution while its value is null. - Each contribution has a key:
title for <title>; canonical for <link rel="canonical">; name and the ASCII-lowercased name for <meta name>; property and the property value for <meta property>; the ASCII-lowercased rel, the resolved href, and, for alternate links, the trimmed ASCII-lowercased hreflang value, with an absent or empty hreflang treated as the empty string, for any other <link>; and the resolved URL with classic or module for a declared script. The first four are competing keys, the next is a shared key, and the last is a script key. A JSON-LD contribution has no key: it is owned, identified by its instance and its declaration, and never shared with another contribution. - A
<meta> key whose name or property has three or more colon-separated parts is a sub-property of the key formed by removing its last part and colon, with the same attribute. A key that is not a sub-property is a top-level key. A top-level competing key's family is the key and every key that is, directly or through another, its sub-property. - Instance A has higher precedence than instance B when A is deeper in the logical component tree, or at equal depth when A connected after B.
- An update is one set of connections, disconnections, and binding changes that an implementation processes together: one batch of mutation records for the polyfill, one commit for a framework adapter, one navigation for an application router.
Reconciliation. After each update, once that update's connections and disconnections have happened and before its rendering is presented, the implementation must reconcile document.head once:
- Compute the target state. For each top-level competing key with at least one contribution, the winner is the contributing instance with the highest precedence, and the target is the winner's contributions to the key's family, in the winner's declared order. A contribution to a sub-property key whose winner's family does not include it is not part of any target. For each shared key with at least one contribution, the target is one element. A key with no contribution has no target.
- For each top-level competing key with a target, if marked elements for its family are in
document.head, update them to the target: set attributes and text, changing only values that differ, and insert or remove marked elements after the last one so their number and order equal the target's. Otherwise, if authored elements for the family are in document.head, record them in order as the family's set-aside elements, insert the target's elements, newly created and marked, before the first of them, and remove them from document.head. Otherwise append the target's elements, newly created and marked, to document.head. - For each top-level competing key without a target, if it has set-aside elements, insert them in their recorded order before the first marked element of its family and forget the record. Then remove the family's marked elements.
- For each shared key with a target, if
document.head holds a marked or authored element with that key, leave it as it is. Otherwise create the element, mark it, and append it to document.head. - For each shared key without a target, remove its marked element. A marked element that a script entry owns under step S4 is never removed.
- For each owned contribution, if its instance and declaration have an element, update its text, changing it only if it differs. Otherwise create the element, mark it, append it to
document.head, and record it as that instance and declaration's element. Then remove the element of each instance and declaration that has no contribution, because the instance disconnected or the value is null, and forget the record. - Create new elements in tree order of their instances, then declared order. Leave every element whose key has an unchanged target, and every owned contribution's element, in place: an element present before and after reconciliation must not be removed and reinserted.
An implementation must create every element it inserts for a contribution as a new element of the document. It must not insert, move, or clone a definition's own nodes.
Scripts.
- S1. Bind. When an instance connects and its definition declares a script, the implementation must bind the instance to the document's script entry for that script's key, creating the entry under S2 if there is none.
- S2. Create an entry. If the document contains an authored
<script> element whose src resolves to the entry's URL and whose type matches, the entry uses that element and must not modify it. Otherwise the entry has no element yet. - S3. Check agreement. When an instance binds to an entry whose first declaration, or authored element, has a different
integrity, crossorigin, or referrerpolicy, the implementation must report an error diagnostic and treat the script as failed for that instance: its ready rejects with a TypeError that describes the conflict, and on:error runs for it under S8. A different fetchpriority is not an error; the element keeps the value of the declaration that inserted it. - S4. Insert. When a bound instance calls for the script and the entry has no element, the implementation must create one
script element in the document, set its src to the resolved URL, copy the standard attributes from that instance's declaration, apply the nonce rule, mark it with data-component-head, append it to document.head, and observe its load and error events. An instance calls for the script when it connects, for loading="eager"; when its root first comes within the distance of the viewport at which the user agent fetches an <img loading="lazy">, for lazy; and when its controller calls load(), for manual. A marked <link rel="preload"> or <link rel="modulepreload"> whose href is the entry's URL belongs to the entry. - S5. Keep. The entry, its element, and any preload link it owns last for the document's lifetime. The implementation must not remove, reinsert, or re-run them.
- S6. Settle. The entry settles as loaded when its element fires
load, and as failed when it fires error. An entry that uses an authored element that has not fired either event by the time the entry is created settles on the first one it fires, except that the implementation must treat it as loaded at creation when it is a parser-inserted classic script without async or defer, or when the document's load event has already fired. - S7. Settle
ready. Each entry has one ready promise, created with the entry. When the entry settles, the implementation must resolve it when loaded, or reject it with the error event when failed, before it runs any handler under S8. It settles once and is shared by every instance bound to the entry, except that an instance in conflict under S3 has its own ready, rejected with the TypeError. - S8. Run one handler per connection. For each connection of a bound instance, the implementation must run exactly one of the declaration's
on:load and on:error handlers, once the entry has settled: when the entry settles, it queues one microtask for each bound instance that is connected and has not run a handler in its current connection; when an instance connects to an entry that has already settled, it queues that microtask at connection. The microtask runs on:load when the entry loaded and on:error when it failed, or on:error for an instance in conflict under S3. No handler runs while an instance is disconnected. A connection that ends before its microtask runs gets neither handler, and each later connection gets its own.
A script's status describes its entry: pending is true from the insertion of the entry's element until the entry settles, ok becomes true when it loads, and error becomes the error event when it fails. Before insertion, pending and ok are false and error is null. For an instance in conflict under S3, pending and ok are false and error is the TypeError.
Handlers run per connection because each connection is a fresh setup: work done in on:load, such as mounting a payment form's card field, has to run again after the instance reconnects. ready is a promise about the script, so it settles once.
What S6 cannot seeThe DOM records no "has loaded" flag on a script element, so S6 infers it. Its inference has two limits: an authored script that failed before the runtime looked reads as loaded, and so does one that other code inserted after the document's load event and that is still loading. Declaring the script, rather than authoring it, avoids both.
Page changes and client navigation
An update can disconnect some components and connect others in one step. The commonest is an application router's client navigation, which replaces one page component with another inside the same layout.
The implementation must apply such an update's head changes as one reconciliation, after the new instances connect. No intermediate head state is observable: no layout title appears between two page titles, and an entry both pages contribute stays in place. The reconciliation must run in the same update as the content change; inside a view transition, it runs within the transition's update callback. Back and forward navigation reconcile the same way. Declared scripts are never reinserted or re-run.
A single title change also means one screen-reader announcement of the new page, rather than an announcement of a transient title first.
Navigating from /docs/a/ to /docs/b/ inside a docs-layout that sets the title Docs · Acme, where both pages preconnect to https://search.acme.example:
| Moment | Instances | document.head |
|---|
| Before | docs-layout, page-a | <title data-component-head>Install · Docs</title>, <link rel="preconnect" href="https://search.acme.example" data-component-head> |
The router disconnects page-a and connects page-b | docs-layout, page-b | Unchanged. Reconciliation has not run. |
| Reconciliation | docs-layout, page-b | The same <title> element's text becomes Configure · Docs. The preconnect link is the same node, untouched. |
The title never reads Docs · Acme, and the preconnect link is never removed and reinserted.
Page-wide head entries
Some head entries affect the whole page: its identity for search engines and sharing, and page-wide behavior. These are page-wide head entries, and only an application component can contribute them:
| Entry | Why the application owns it |
|---|
<link rel="canonical"> | Tells search engines which URL represents this document. |
<link rel="alternate"> | Declares other documents, such as translations and feeds, as versions of this one. |
<link rel="icon"> | The icon people recognize the site by in tabs and bookmarks. |
<link rel="manifest"> | Makes the document an installable application, with its own name, icons, and scope. |
<meta name="robots"> | Controls whether search engines index the document and follow its links. |
<meta name="referrer"> | Sets the referrer policy for every request the document makes. |
<meta name="viewport"> | Controls layout and zoom for the whole document, including whether people can zoom. |
<meta name="color-scheme"> | Selects light or dark rendering for the whole document. |
<meta name="theme-color"> | Colors the browser interface around the document. |
<meta property="og:url"> | The URL that link previews and social sharing attribute the document to. |
<meta name="component-src"> | Says which sources supply application components, below. |
Application components. A definition is an application component when it is one of these:
- part of the build's own source, in a build;
- written inline in the application document;
- loaded through URL-like references only, from the application document down, from a final response URL that matches the document's
component-src list.
Every other definition is an imported component. That includes any definition reached through a bare specifier, such as an installed package subpath or a mapped live import, any definition from an unlisted cross-origin source, and every definition reached from an imported one. A resource reached both ways is an imported component.
The source list uses the grammar of a CSP source list: space-separated source expressions such as 'self', origins, and URL path prefixes. A URL matches it by the CSP rules for matching a URL to a source list, with the application document's origin as 'self'.9 The application declares the list in its own authored head:
<meta name="component-src" content="'self' https://cdn.acme.example/ui/">
- The list always includes
'self', so same-origin definitions are application components even when the meta is absent. - A source the list names is also loadable: the loader must accept a cross-origin component root whose URL matches a listed source expression and is reached by a URL-like reference, such as
<link rel="component" href="https://cdn.acme.example/ui/dashboard.html">, as it accepts a same-origin one, without an import-map entry. The URL prefix that source expression names, or the root's origin when it names none, is the root's approved scope: its relative component and controller edges stay inside it, as they stay inside an application-mapped prefix. - The implementation must read the list only from the first
<meta name="component-src"> in the application document's authored head, as parsed. It must ignore this entry from any component, application or imported, and must report a diagnostic for a second authored one. - A build reads the list from the application's document template.
The runtime ignores an imported component's page-wide head entries and reports a diagnostic. Its other head declarations apply as usual.
The name and grammar of component-srcThe name follows CSP Level 3, whose fetch directives name a resource type plus -src: script-src, img-src, style-src, frame-src, connect-src, font-src, and manifest-src.9 component-src names where an application's own components come from. Its value reuses CSP's source-list syntax and source-expression matching exactly, 'self', origins, and path prefixes included, the same origin-list idea as a Permissions Policy allowlist.10 One difference is deliberate: a CSP *-src directive only permits loading, while component-src also marks matched components as the application's own, so they may set page-wide head entries. A browser could adopt the same name as a real CSP directive, delivered in the Content-Security-Policy header, such as component-src 'self' https://cdn.acme.example/ui/, with no change in meaning; the meta is then its in-document form.
In an application framework with file-based pages, pages and layouts live in the application's source, so they are application components. A component installed from a package is an imported component, even when an application page renders it.
app/pages/pricing.html
<template component="page-pricing">
<defs>
<title>Pricing · Acme</title>
<meta name="description" content="Plans for teams of every size.">
<link rel="canonical" href="https://acme.example/pricing">
<meta name="app:layout" content="marketing">
</defs>
<main>…</main>
</template>
A <meta> name that contains a colon, such as app:layout, belongs to the application build that defines it. This specification gives it no meaning: the runtime adds it to the head like any other <meta>, and a build that reads it at build time, here to choose the page's layout, may leave it out of its output.
Server rendering and hydration
A server-rendered page arrives with its head already written, so a crawler or a no-JavaScript reader sees the title, description, and structured data. Hydration then takes over the same elements. Nothing renders twice, and no script runs twice.
- H1. Write contributions. Server rendering must compute the result of reconciliation over the rendered tree, starting from the application document's authored head, and write that head, with each contribution's element marked
data-component-head. - H2. Write set-aside elements. For each family whose authored elements reconciliation sets aside, server rendering must write those elements, unmodified and in their recorded order, as the content of a
<template data-component-head="set-aside"> placed immediately after the family's last marked element. It must not write them anywhere else. - H3. Preload eager scripts. For each script that an
eager declaration in the rendered tree calls for, and that no authored script in the document satisfies under S2, server rendering must write one <link rel="preload" as="script">, or <link rel="modulepreload"> for type="module", with the resolved URL as its href, the declaration's crossorigin, integrity, referrerpolicy, and fetchpriority where present, and data-component-head. It must not write an executing <script> element for a declared script. - H4. Leave lazy and manual scripts out. Server rendering must not write anything for a
lazy or manual script. A component that wants an early connection declares its own <link rel="preconnect">, which H1 writes like any link. An automatic one would open connections for scripts that may never load. - H5. Adopt. Before its first reconciliation, the client runtime must adopt each marked element whose key has a target, recording the instances that contribute it, and must not insert a second element for an adopted key. For each
<template data-component-head="set-aside">, it must take the template's child elements, in order, as the set-aside elements of the family whose marked elements precede it, hold them detached, outside the template, and remove the template. Reconciliation then changes only values that differ. It must adopt the marked JSON-LD blocks, in document order, as the elements of the owned contributions taken in tree order of their instances, then declared order. Marked elements whose keys have no target, and marked JSON-LD blocks left over, are removed by that first reconciliation, except preload links that a script entry owns under S4. - H6. Insert eager scripts once. After adoption, each eager declaration calls for its script under S4. The runtime inserts the real
<script> exactly once; the browser serves it from the preload, runs it once, and its load or error event settles ready under S7 and runs each connected instance's on:load or on:error under S8.
H1 and H5 identify elements by the marker together with their keys, so server output needs no per-element ownership record. A JSON-LD block has no key, and its position identifies it instead: step 7 appends owned elements in tree order of their instances, then declared order, so H1 writes them in that order, and hydration starts from the tree and state the server rendered, so the client's owned contributions arrive in the same order. The marker separates managed elements from authored ones, and the set-aside template carries the authored elements the client must restore later, so restoring stays exact after hydration.
| Declaration | Server output in document.head | After hydration |
|---|
<title>, <meta> | The winner's entries, marked, in the place of any authored element with the same key. That authored element follows in a set-aside template. | The same elements, adopted. The authored element is held aside, out of the head, until no contributor remains. |
<link> other than a preload for a script | One marked element per key, unless an authored one has the key. | The same element, adopted and reference-counted. |
| JSON-LD | One marked block for each declaration of each instance whose value is not null. | The same element, adopted by its instance and declaration. |
<script src>, eager | A marked preload or modulepreload link. Never a <script>. | One marked <script>, inserted once and served from the preload. The link stays. |
<script src>, lazy or manual | Nothing. | Inserted under S4 when an instance calls for it. |
on:load, on:error | Nothing. | One of the two runs for each connection, once the script settles. |
For a checkout page inside a layout that loads Stripe.js eagerly, with an application document that authors <title>Acme</title>:
Server output
<head>
<title data-component-head>Checkout · Acme</title>
<template data-component-head="set-aside"><title>Acme</title></template>
<link rel="preconnect" href="https://js.stripe.com" data-component-head>
<link rel="preload" as="script" href="https://js.stripe.com/v3/" data-component-head>
</head>
After hydration
<head>
<title data-component-head>Checkout · Acme</title> <!-- adopted: the same node -->
<link rel="preconnect" href="https://js.stripe.com" data-component-head>
<link rel="preload" as="script" href="https://js.stripe.com/v3/" data-component-head>
<script src="https://js.stripe.com/v3/" data-component-head></script>
</head>
<!-- The authored <title>Acme</title> is held aside, unmodified, to be restored. -->
What a conformance test checks
// Hydration adopts the server's nodes in place.
assert.equal(document.querySelector("title"), serverTitle);
assert.equal(document.title, "Checkout · Acme");
// One Stripe.js element, run once.
assert.equal(document.querySelectorAll('script[src="https://js.stripe.com/v3/"]').length, 1);
assert.equal(onLoadRuns("checkout-form"), 1);
// Removing and re-adding the form runs on:load again for the new connection,
// without inserting or running the script again.
form.remove(); main.append(form); await microtasks();
assert.equal(onLoadRuns("checkout-form"), 2);
assert.equal(document.querySelectorAll('script[src="https://js.stripe.com/v3/"]').length, 1);
// When no component sets a title, the authored node returns: the same object.
await navigate("/no-title/");
assert.equal(document.querySelector("title"), authoredTitle);
Generated components
A generated React, Vue, or Svelte component must produce the same head contributions, at the same lifecycle points, with the same marker, keys, precedence, page-wide head entry rule, reconciliation, and script sharing, through its host adapter. An adapter may use its framework's own head mechanism when the observable result is the same.
Platform integrationA native implementation needs three things: DOM insertion, removal, and value changes in document.head when instances connect and disconnect; the reconciliation steps above; and HTML's existing <script> and <link rel="preload"> elements. It may track ownership internally, but it uses the data-component-head marker for the handoff from server output to the client, so output from any server hydrates under any client. Existing security primitives cover it: CSP, Subresource Integrity, CORS, and mixed-content blocking apply because each contribution is an ordinary element in the document. It is also much smaller than HTML Imports: it works within the one document, loads without blocking the parser, and keeps the existing script ordering. Each declared script is one script inserted through the DOM.
Native lowering and explicit root branches
A component should lower to the native element named by nativeElement: a button component is a real <button>, so form association, focus, and accessibility are the browser's. Undeclared invocation attributes pass through to that root according to attribute forwarding; owned template attributes and prop targets take precedence over automatic forwarding.
A polymorphic component declares as as a keyword prop constrained to its supported roots and selects between explicit native roots with $match. The prop selects a branch: the definition contains the actual <button> and <a> branches that it may render, each with its own tag. Like any $match, the selection follows the prop: when it changes, the newly selected branch's element takes the previous root's place, keeping the instance's state, the consumer's projected content, and the attributes the consumer supplied.
<template component="x-button">
<defs>
<prop name="as" type="keyword" values="button, a" default="button">Native root.</prop>
</defs>
<!-- Both possible native roots are visible in the definition. -->
<template $match>
<a $when="$as = 'a'"><slot></slot></a>
<button $else><slot></slot></button>
</template>
</template>
<x-button>Save</x-button> <!-- → <button> -->
<x-button as="a" href="/save">Save</x-button> <!-- → <a href> -->
ConformanceThe selected branch must produce exactly one significant root. Each branch carries its own native attribute surface, so generated framework types can narrow attributes from the as value: href belongs to the a branch, for example.
Attribute forwarding
By default, a component must apply its unconsumed invocation attributes as though a valueless $attributes directive appeared first on its rendered root, before every authored attribute and binding. This applies to a native root, a delegated component root, and the selected native arm of a root $match.
Only attributes that do not supply a declared prop belong to $attributes. A declared prop is consumed by its component's input contract, even when the supplied value is rejected; forwarding must not expose it on the root as an extra attribute. At a delegated component boundary, forwarded attributes pass through that receiving component's prop contract in the same way as authored invocation attributes.
<template component="x-button">
<button title="Template"><slot></slot></button>
</template>
<x-button id="save" title="Caller">Save</x-button>
<!-- The root receives id="save" and title="Caller" first.
Its authored title then replaces "Caller" with "Template". -->
The boolean noforward attribute on the defining <template component> disables automatic root forwarding. Its presence controls this behavior. With noforward, attributes are forwarded only where the author places $attributes; omitting $attributes leaves them unapplied. This setting controls invocation attributes independently of CSS inheritance.
<template component="x-field" noforward>
<div class="field">
<input $attributes>
</div>
</template>
<x-field id="email" aria-label="Email"></x-field>
<!-- id and aria-label reach the input. -->
An explicit $attributes directive must apply attributes at its authored position, in source order with the element's other attributes and bindings. Later writes to the same attribute take precedence. An explicit placement does not itself disable the default root placement; use noforward when directing attributes elsewhere.
<template component="x-caller-title" noforward>
<button title="Template" $attributes><slot></slot></button>
</template>
<!-- The caller's title wins. -->
<template component="x-template-title" noforward>
<button $attributes title="Template"><slot></slot></button>
</template>
<!-- The template's title wins. -->
Attribute namenoforward names automatic attribute forwarding without suggesting a change to CSS inheritance. Its spelling follows HTML's existing no attributes, such as novalidate and nomodule.
Lowering, provenance & hydration
Lowering is destructive and directional. The <template component> is the definition and renders nothing; <x-button> is the invocation the author writes; lowering replaces the invocation with the definition's native root. <x-button> becomes a real <button>. Children land where the <slot> was, declared props map to their targets, and unconsumed attributes follow the forwarding rules.
<!-- definition: written once, inert, renders nothing -->
<template component="x-button">
<button><slot></slot></button>
</template>
<!-- invocation: what you write on the page -->
<x-button variant="solid">Save</x-button>
<!-- output: identical whether lowered on the server or in the browser -->
<button component="x-button">Save</button>
Determinism: one DOM, either pathLowering must be a deterministic function of the invocation and the definition alone: no timestamps, generated ids, or client-only state. The converter running on the server and the polyfill running in the browser therefore produce byte-identical native DOM for the same source, so an SSR'd root and an in-browser-lowered root are indistinguishable. This is the equivalence contract applied to a single node.
Every lowered root carries one provenance attribute, component, injected by the implementation. Its value is a space-separated token list, outermost invocation first, exactly like class or rel: a component that lowers straight to a native element carries a single token (component="x-button"), and one whose root is another component carries the whole lineage (component="x-primary x-button"). Each token is a component tag, so it resolves through the ordinary component registry, the loaded <template component> definitions keyed by tag, exactly the way customElements resolves a custom-element tag to its definition. Because it is deterministic, the stamp is identical under SSR and in-browser, and it composes across nested components and imported partials. How server output records slot boundaries and content no slot renders yet, so hydration rebuilds the same instance, is defined in Rendered form & hydration. Content projected through a <slot> is the consumer's, so it keeps the provenance it already had.
Component identity and compatibility
component is a proposed HTML extension, not a currently standardized attribute. On a lowered native root it records the component identities that produced that element. The component attribute on a definition's <template> names the definition; on its rendered root it records provenance. A root's token list does not instantiate a component or attach independent behaviors: invocations remain component tags, and multiple tokens record delegation.
New serialized output must use component by default. A JavaScript polyfill or server renderer may offer an explicit legacy output mode that writes data-component instead. This fallback is a library convention for existing browsers and older tool output; it is not a second proposed native HTML attribute.
Polyfills must accept data-component when component is absent. When both attributes are present, component must supply the effective identity, including component="", which supplies an empty token list. Readers must not merge the two values. Removing component makes a remaining data-component value effective again. The same resolution applies to root discovery, styling and hydration, so an ignored fallback cannot introduce an extra owner or scope boundary.
Implementations must not synchronize the attributes automatically. Hydration adopts the existing root using its effective identity without rewriting its identity attribute. Serialization writes only the selected spelling with that effective token list. The fallback carries the same ordered tokens as the canonical spelling. Native implementations use component; the compatibility read rule is for polyfills.
The root's visible attributes come from the template, its bindings, and invocation attributes that pass through. A prop is input to that rendering; it reaches the root's attributes only through a binding the template writes. The reference runtime may carry prop data for hydration, in a storage format internal to the runtime. Recovering explicit prop values after server rendering remains a rendered-form question. See Types for how authored values are parsed.
A literal attribute on the invocation supplies the prop's initial configuration. Afterwards a prop changes through a parent template's from:name binding or the props a framework passes. Such a change updates bindings and state derived from that prop.
The stamp is also what makes hydration an adopt-in-place. An implementation lowers where it finds an <x-button> invocation, and adopts where it finds an already-lowered root carrying component (or the polyfill fallback): it binds reactivity and events onto the existing node. An SSR'd tree therefore hydrates in place, without flicker, and a client-only page lowers to the same result. The only difference is a pre-lowering moment that exists only client-side, where the unknown <x-button> shows its children inline; SSR skips it.
Slots
Content projection uses the native-shaped <slot>. Level 1 includes default, named, fallback, and scoped slots because all four are part of the baseline component contract.
A slot's name is a literal, written on both sides:
- An outlet's
name, or the $slot value of a <template $slot> outlet, is a literal identifier, with the spelling an expression name uses: letters, digits after the first character, and _, but no -. <slot name="labelAction"> names a slot; <slot name="label-action"> is an error. - A consumer's
slot attribute, on an element or on a <template slot>, is the same kind of literal identifier, naming the slot it fills. A value that is not an identifier, such as slot="$which", and a bound from:slot are errors. - An empty
slot="" assigns content to the default slot, as having no slot attribute does.
Fixed names keep a component's slots part of its public interface: tools list them, generated components type them, and a consumer's content stays in its slot as data changes. Content that varies by item is one named scoped slot that passes the item as a prop.
Named & fallback
<!-- definition -->
<template component="x-card"> …
<article>
<header><slot name="title">Untitled</slot></header> <!-- fallback content -->
<slot></slot> <!-- default slot -->
</article>
</template>
<!-- use -->
<x-card>
<h2 slot="title">Quarterly report</h2>
<p>Body content lands in the default slot.</p>
</x-card>
A slot shows its fallback while nothing is assigned to it, as a native <slot> shows its own children while it has no assigned nodes.11 Content the consumer writes under $if or $each is assigned while it renders. A slot whose only content is a false $if, or an $each with no rows, shows its fallback. When that content appears, the slot switches to it, and back to the fallback when it goes:
<!-- use: the header reads "Untitled" until $heading has a value -->
<x-card>
<h2 slot="title" $if="$heading">{$heading}</h2>
<p>Body content lands in the default slot.</p>
</x-card>
Conditional and repeated content goes to the slot its slot attribute names whenever it renders, on the first render or after a later change.
Content that renders on demand
Framework converters follow the conversion lifecycle contract: exact lifecycle and node identity are not requirements for conversion. They use their framework’s normal reconciliation and hydration while preserving the user experience. The ownership rules below define native projection behavior; they do not require converters to reproduce internal lifecycle mechanics with no effect on the user.
Projected elements belong to the consumer, so they exist as soon as the consumer renders. They load their resources and bind even while their slot is not rendered, for example under a false $if. Only a component invoked among them waits: it is set up when a slot first places it. To defer the elements themselves until their slot renders, wrap them in a <template slot="name">:
<!-- definition -->
<template component="x-disclosure"> …
<div>
<button type="button" on:click="toggle">More</button>
<section $if="$open"><slot name="details"></slot></section>
</div>
</template>
<!-- use: nothing inside the template exists until the section opens -->
<x-disclosure>
<template slot="details">
<img src="chart.png" alt="Sales by month">
<x-chart from:rows="$sales"></x-chart>
</template>
</x-disclosure>
- A slot renders the children of a consumer's
<template slot> only while the slot itself renders. Until then nothing inside the template is created, fetched, or bound, and no component inside it is set up. The slot that receives the template decides. If a component places that slot inside content it projects into another component, the slot renders, and so does the template, even while the other component's slot is hidden; there the rendered elements are plain projected content. - The children render in the consumer's scope with the consumer's bindings, like an
$if body. Each time the slot renders again they render afresh, so elements and component state inside them do not survive a close and reopen. - If a consumer supplies more than one
<template slot> for the same slot, the slot renders the first. Other content assigned to that slot is not rendered with it. - A slot without props accepts plain elements, and their projection stays eager as described above: the elements keep their identity while the slot closes and reopens, and a controller can read them through
host.slots while the slot is hidden. A listbox that reads its options while its panel is closed relies on this. - A slot that exposes props, a scoped slot (below), renders only a
<template slot>, because only a template can receive its props. Plain elements assigned to it without a template are an error, reported with a diagnostic; with nothing assigned, it shows its fallback. - Server output keeps the
<template slot> itself in the carrier, unrendered, whether or not its slot renders. Hydration renders it when its slot renders.
Use a template for content that is costly to create or load and only needed when shown. Project plain elements when the component must reach them while they are hidden.
Scoped slots
A slot may expose data to the content projected into it. The definition declares the slot's props in <defs> and gives them on each <slot> outlet; the consumer supplies a <template slot="name">, which renders on demand as above. Its scope adds those exposed props to the consumer's own: no new prefix, consistent with $with-style scoping.
<!-- definition: a disclosure that tells its summary its label and how many items it holds -->
<template component="x-disclosure">
<defs>
<prop name="items" type="list(string)" default="[]">The items.</prop>
<state type="boolean" name="open" value="false"></state>
<handler name="toggle"><set name="open" expr:value="not $open"></set></handler>
<slot name="summary">
<prop name="label" type="string" default="Details">The summary's text.</prop>
<prop name="count" type="number" required>How many items there are.</prop>
</slot>
</defs>
<div>
<button type="button" on:click="toggle">
<slot name="summary" label="Open" from:count="$items.length">Details</slot>
</button>
<ul $if="$open"><li $each="item of $items">{$item}</li></ul>
</div>
</template>
<!-- use: the template's scope is { label: string, count: number } -->
<x-disclosure from:items="['a', 'b']">
<template slot="summary">{$label} ({$count})</template>
</x-disclosure>
Declaring a slot's props
- A
<slot> inside <defs> is a declaration: it renders nothing. Its name names the slot it declares; without name it declares the default slot. A component declares each slot once. - Its children are
<prop> elements with exactly the vocabulary of a component's <prop>: name, type, values, default, required, and a description as element text. Each name must be a valid name in expression scope, unique within the slot, and other than name and of, which are reserved for the outlet. - The declared props are part of the component's public interface, beside its props, events, and requests: tools list them, and generated components type them.
Giving slot props on an outlet
A slot prop is given on the <slot> outlet the way a prop is given to a component invocation:
- Static. Every plain attribute on the outlet other than
name and of is a prop's input. It converts by the declared type exactly as an invocation attribute converts by a component <prop>: with type="number", count="3" gives $count the number 3. - Bound.
from:count="$items.length" gives $count the expression's value, checked as an accepted value of the declared type, and follows it as it changes. A value that is not present, null or undefined, takes the prop's default, or null without one. A value the declared type rejects does the same, and the implementation should warn once, naming the slot and the prop.
Static and bound props mix freely on one outlet. These are errors, each reported with a diagnostic that names the outlet:
- An outlet gives a prop its slot does not declare, or gives props to a slot with no declaration. The diagnostic names the
<slot> and <prop> declaration to add. - An outlet gives the same prop both ways (
label and from:label). - An outlet omits a
required prop. A prop with a default that an outlet omits takes its default; one without either is absent from the template's scope.
An outlet with any prop is a scoped slot: it renders only a <template slot>, as below.
A slot that owns iteration gives each row's props the same way. Here the outlet sits in a table row, so it is written as a <template $slot>, which the HTML parser keeps in place:
<!-- definition: a list that owns iteration, slots each row out -->
<defs>
<prop name="rows" type="list(object({ id: string, name: string }))" required>The rows.</prop>
<slot name="row">
<prop name="kind" type="keyword" values="person, team" default="person">What a row is.</prop>
<prop name="item" type="object({ id: string, name: string })" required>The row.</prop>
<prop name="index" type="number" required>The row's position.</prop>
</slot>
</defs>
<table><tr $each="row of $rows" $key="$row.id"><template $slot="row" kind="person" from:item="$row" from:index="$loop.index"></template></tr></table>
<!-- use: the template's scope is { kind, item, index } -->
<x-list from:rows="$people">
<template slot="row"><td>{$item.name}</td></template>
</x-list>
Vue gives a scoped slot's props the same two ways, a plain attribute on its <slot> and a bound :prop, with only name reserved, and types them with defineSlots.12
Outlets in tables: <template $slot>
A <slot> cannot sit directly in table structure. The HTML parser moves (foster-parents) a <slot> written directly in <table>, <thead>, <tbody>, <tfoot>, <tr>, or <colgroup> out to just before the <table>, so the rows or cells it renders would land outside the table.13 Write the outlet there as a <template> with $slot:
<!-- definition: header cells and rows slotted into table parts -->
<template component="x-table"> …
<table>
<thead><tr><template $slot><th>Name</th></template></tr></thead>
<tbody>
<template $slot="row" $each="row of $rows" $key="$row.id" from:item="$row">
<tr><td>{$row.name}</td></tr>
</template>
</tbody>
</table>
</template>
<!-- use: the consumer's table content is in <template slot> too -->
<x-table from:rows="$people">
<template slot=""><th>Person</th></template>
<template slot="row"><tr><td>{$item.name}</td></tr></template>
</x-table>
<template $slot> is the default slot's outlet, and <template $slot="row"> the outlet of the slot named row. The value follows the slot-name rules above; an empty value, like no value, means the default slot. name is not one of its attributes.- It is the same outlet as the matching
<slot>, written another way. It takes of (proposed) as <slot> does, so <template $slot="cell" of="$column"> places a child's slot in table structure. Every other plain attribute is a static slot prop and every from: attribute a bound one, its content is the fallback, it renders a consumer's <template slot> for a scoped slot, and it may carry $each or $if. Slot props are declared with a <slot> in <defs> either way. - It renders no
<template> element. Like a <slot> outlet, it is replaced by what it renders, and its rendered form is the same. - It is valid wherever a
<slot> outlet is. Table parts need it; elsewhere the two forms are interchangeable. - A build tool should report a
<slot> that the parser moved out of a table, and suggest <template $slot>.
A <template> is used for the same reason structural directives ride on one: the parser keeps it wherever it is written, including table parts, and parses its content in the context of its first element, so a <tr> inside it stays a <tr>. A consumer's table content goes in a <template slot> for that reason: inside <x-table>, the parser drops <tr>, <th>, and <td> tags it finds outside a table. The $ marks $slot as changing what the <template> does, as $each does, and keeps it apart from slot, which says which slot a consumer's content fills.
Component ancestry for shared state
Each nested component has a logical parent: the component whose markup or slot outlet renders it. For content a <slot of> outlet (proposed) places, it is the slot's owner. A component projected into a slot therefore sees the receiving component in its ancestry for shared state; if content passes through more slots, each receiving component is in that chain. Its ordinary expressions still use the consumer's lexical scope, and its projected nodes keep their existing provenance. Context lookup and expression scope answer different questions.
Moving a component's rendered nodes with <portal> keeps that logical ancestry. Moving the DOM nodes alone does not select a different state ancestor. The nearest ancestor with the requested state cell supplies it, whether or not its native root is a DOM ancestor.
Columns as markup
Level 1, proposed for reviewColumns as markup is part of the Level 1 component language, beside scoped slots. Its follow-ups are listed at the end.
A data table is easiest to read and change when its columns are written as markup: each column says what its header is and how its cells look, and the table renders a real row per item with one cell per column. PrimeVue's DataTable and Column popularized this shape.14 The table renders every row itself, because screen readers announce a table by its rows, and row selection, hover, and keyboard navigation target a row element.
The table reads its children as $columns, and <slot of> renders one column's slot wherever the table places it.
A slot's declaration and its outlet. A slot has two parts. Its declaration is a <slot> in <defs>: it describes the slot, with its props, the components it accepts, and the name of its list. Its outlet is where the slot renders: a <slot> in the markup, or a <template $slot> in table parts. With as, the declaration also gives the slot's children to the template as data, here $columns.
<defs><slot accepts="x-column" as="columns"></slot></defs> <!-- the declaration: describes the default slot -->
<thead><tr><template $slot></template></tr></thead> <!-- the outlet: the columns render here -->
A declaration's name says which slot it describes. With name omitted, it describes the default slot, just as an outlet with name omitted is the default slot's outlet.
Example: a data table
A column's root is the header cell it renders. It declares a cell slot, which its table places:
x-column.html
<template component="x-column" summary="One column of x-datatable.">
<th scope="col"><slot name="header">{$header}</slot></th>
<defs>
<prop name="field" type="string">The row field a cell shows when no cell content is given.</prop>
<prop name="header" type="string">The header text.</prop>
<slot name="header"></slot>
<slot name="cell"><prop name="row" type="object({ ... })" required>The row this cell shows.</prop></slot>
</defs>
</template>
The table places its columns in the header row, then renders one row per item and places each column's cell slot in it:
x-datatable.html
<link rel="component" href="./x-column.html">
<template component="x-datatable" summary="A table whose columns are its x-column children.">
<defs>
<prop name="rows" type="list(object({ ... }))" required>One entry per table row.</prop>
<slot accepts="x-column" as="columns"></slot>
</defs>
<table>
<thead><tr><template $slot></template></tr></thead>
<tbody>
<tr $each="row of $rows" $key="$row.id">
<td $each="column of $columns" $key="$column">
<slot of="$column" name="cell" from:row="$row">{$row[$column.field]}</slot>
</td>
</tr>
</tbody>
</table>
</template>
A page writes the columns as the table's children:
products.html
<link rel="component" href="./x-datatable.html">
<link rel="component" href="./x-column.html">
<template component="page-products">
<defs>
<state name="products" type="list(object({ id: number, name: string, price: number }))"
value="[{ id: 1, name: 'Desk lamp', price: 49 }, { id: 2, name: 'Notebook', price: 6.5 }]"></state>
</defs>
<main>
<x-datatable from:rows="$products">
<x-column field="name" header="Name"></x-column>
<x-column class="numeric">
<abbr slot="header" title="Price in US dollars">Price</abbr>
<template slot="cell">{format($row.price, 'currency', { currency: 'USD' })}</template>
</x-column>
</x-datatable>
</main>
</template>
Rendered output (hydration marks omitted)
<main component="page-products">
<table component="x-datatable">
<thead><tr>
<th scope="col" component="x-column">Name</th>
<th scope="col" class="numeric" component="x-column"><abbr slot="header" title="Price in US dollars">Price</abbr></th>
</tr></thead>
<tbody>
<tr><td>Desk lamp</td><td>$49.00</td></tr>
<tr><td>Notebook</td><td>$6.50</td></tr>
</tbody>
</table>
</main>
<template $slot> is the table's default slot outlet, in its table-part form. Each column renders its own <th> there.$columns lists the columns in order. $column.field reads one column's field prop.<slot of="$column" name="cell" from:row="$row"> is the column's own cell slot, placed by the table once per row. The Name column has only attributes, so its cells show the outlet's fallback, the row's name field.
An entry is the child's props$column holds the column's resolved props: $column.field and $column.header. The column's cell content renders where <slot of="$column" name="cell"> places it.
$row is the cell slot's propThe column declares row on its cell slot. The table gives it at the outlet with from:row, and the page's <template slot="cell"> reads it as $row, beside the page's own names such as $products. It follows the same rules as any scoped slot prop.
Common mistakes- Writing
of for the component's own slot. of names a child whose slot the outlet places. A table's own caption outlet is <slot name="caption">. - Reading
$column.cell to get the template. An entry holds props, so $column.cell is an error. Render the slot with <slot of="$column" name="cell">.
When you need <slot of>
<slot of> is for a component that places a slot belonging to one of its children, the children it reads through as. A table that places each column's cell once per row is the case it exists for. A component's own slots use <slot name>.
Reading children as a list: accepts and as
Which children. The columns are the table's direct children, assigned to its default slot by the usual rule. Structural templates are transparent: the x-columns that a consumer's $if, $each, or $match renders count, and the list follows them as they change. Whitespace and comments are ignored. Children with a slot attribute go to their named slot.
accepts limits what a slot takes. accepts is optional. Its value is one or more component tags, separated by spaces like other HTML token lists: accepts="x-column", or accepts="x-column x-column-group".
- A child matches when it is an instance of one of those components by lineage, as the selector
[component~="x-column"] would match. A preset whose root delegates to x-column counts, and its entry holds the x-column props the preset resolves. - Text and any other child are an error, reported at that element in the page: a build error, or a warning in the live runtime and in server rendering, with the child left out.
accepts checks a slot's content; the content's slot attribute routes it.accepts types the list. Tools check $column.field against the accepted components' props and each <slot of> outlet against their slots. With several tags, an entry holds the props its own component declares, and a prop another accepted component declares reads as absent.- A slot declaration has either
accepts or <prop> children: with accepts, its content is component instances, and with props, it is templates. - A declaration that omits
accepts takes any content, as an ordinary slot does, and has no as.
as names the list. as="columns" names a read-only list with one entry per child element, in order. An as list holds the slot's children wherever it is read: implementations collect the slot's children, with each preset's props resolved, before rendering the template, so $columns is complete in every expression that reads it, in every target. An entry is a stable read-only object for that instance's lifetime, holding its resolved props reactively. So $key="$column" keys by identity, and a keyed reorder moves the entries and the cells rendered for them.
as works on the default slot and on named slots: <slot name="actions" as="actions">.as requires accepts, so every entry is an accepted component instance, holding the props its component declares.- An
as name joins the component's flat declaration namespace, so as="rows" beside <prop name="rows"> is an error.
Columns render where they are written
Each column's root <th> is placed by the table's default outlet, in the header row. A column is therefore a real, connected component instance, with its own events, controller, :host styles, an aria-sort binding when it declares one, and pass-through attributes such as class. Its cell content is the one part the table places elsewhere.
A child's slot, placed by its parent: <slot of>
<slot of="$column" name="cell" from:row="$row">fallback</slot> is the column's own cell slot, placed by the parent.
of is an attribute that <slot> defines as taking an expression, as <computed> defines from. It names whose slot the outlet is. from:row gives a slot prop, as on any outlet.name is a literal identifier naming a slot the child declares.of and name are reserved for the outlet itself, so a slot's props take other names.- An absent or
null of renders the fallback. of takes an entry of one of this component's own as lists; any other value is a build error. Passing entries to other components, and using of there, is a follow-up.- Each outlet renders the child's content for that slot afresh, in the consumer's scope plus the outlet's slot props.
- Plain content for a slot without props keeps its identity, as plain projected content does, so it renders at the first outlet that places it, and later outlets show their fallback. Content in a
<template slot> renders at every outlet.
Ownership. Content placed by <slot of> keeps its logical owner:
- Context for the cell content runs from the cell content to the
x-column, then to the x-datatable. - Requests follow the DOM event path, from the cell up through the table.
- Styles reach the cell content from the page, which wrote it, and from the table, which owns its
<td>. A column's own styles and its pass-through class reach its <th>. A column that styles its cells declares a prop that the table binds on the <td>, such as class:end="$column.align == 'end'".
In a controller. The table's host.slots.default lists the columns' roots in list order, and host.state.columns is the read-only list of entries. A column's host.slots.cell lists every rendered instance of its cell content, in document order. See the JavaScript layer.
Errors
| Mistake | Message | Reported at |
|---|
<x-colum>, a <p>, or text among the columns | x-datatable's default slot accepts x-column; this <x-colum> is left out. | That child in the page. A build error; a warning live and in server rendering. |
<slot of="$column" name="body"> | x-column declares the slots header and cell; body is unknown. | The outlet in x-datatable.html. A build error. |
<slot of="$row" name="cell"> | of takes an entry of this component's as list, such as $column from $columns. | The outlet in x-datatable.html. A build error. |
<span slot="cell"> in an <x-column> | cell has props, so its content is a <template slot="cell">. | That element in the page. A build error; a warning live. |
<slot as="columns"> without accepts | as lists accepted components; add accepts="x-column". | The slot declaration. A build error. |
as="rows" beside <prop name="rows"> | rows is already declared; an as name shares the declaration namespace. | The slot declaration. A build error. |
With several accepted tags, the first message lists them all: accepts x-column and x-column-group.
Why this design
- A parent reads its children of one kind. A
<select>'s options is a live collection of its <option> elements that looks through <optgroup>, as as looks through structural templates.15 - The column is the header cell. Each column is a real
<th> in the table's header row, so it stays in the DOM with native semantics, as <option>, <source>, and <track> stay in theirs.15 - Placing a child's content once per row is new to HTML.
<slot of> adds exactly that, and reuses slot props, fallback, and on-demand rendering as they are.
Server output and hydration
A range that a <slot of> outlet renders carries the entry's index at serialization, <?start slot="cell" of="1" scoped=""?>, which marks it as a column's slot within the table's markup (see Rendered form). Each column's carrier lives inside its own <th> root:
Rendered form
<table component="x-datatable">
<thead><tr><?start slot=""?><th scope="col" component="x-column"><?start slot="header" fallback=""?>Name<?end?></th><th scope="col" class="numeric" component="x-column"><?start slot="header"?><abbr slot="header" title="Price in US dollars">Price</abbr><?end?><template carrier="x-column"><template slot="cell">…</template></template></th><?end?></tr></thead>
<tbody>
<tr><td><?start slot="cell" of="0" fallback="" scoped=""?>Desk lamp<?end?></td><td><?start slot="cell" of="1" scoped=""?>$49.00<?end?></td></tr>
<tr><td><?start slot="cell" of="0" fallback="" scoped=""?>Notebook<?end?></td><td><?start slot="cell" of="1" scoped=""?>$6.50<?end?></td></tr>
</tbody>
</table>
The of index connects placed content to the instance that owns it, a link the DOM records only through that index. That is the same need as the open question of context through a server-rendered portal.
Framework targets
Columns are real component instances with real roots, so a generated column mounts like any component and registers with its table through the target's context. The generated table orders its entries in the consumer's content order, and renders the same DOM, under target equivalence. Converters and server renderers collect the columns from the invocation's content before rendering, so $columns is complete wherever the table reads it.
Accessibility
The output is a real <table> with <th scope="col"> header cells and a <tr> per row, so row and column navigation and header association come from HTML. A column's cell content fills one <td> that the table owns, so the table's structure stays the table's.
Follow-ups
- Typing a column's
row from the table's rows type, so $row.price is checked. - A
let:-style rename of a slot prop for the consumer. - Passing entries to other components, and using
of there.
Composition
<template src>: import a component or partial
Native <template> has no src, so HTML Next defines it: <template src="…"> loads an external component definition or partial. It is the import mechanism and the hook for lazy, code-split components. With no runtime it degrades to an empty inert template, safe.
<template src="./card.html"></template> <!-- register x-card -->
<template src="./chart.html" defer></template> <!-- lazy: load on first use -->
Choosing a component at runtime
When data decides which component renders, list each candidate as an arm of a $match. The first arm whose $when is truthy renders, and when the data changes, the newly chosen arm replaces it. Every component the markup can render is named in the source, so its definition resolves the same way as any other invocation.
<template $match>
<x-callout $when="$block.type = 'callout'" from:data="$block"></x-callout>
<x-figure $when="$block.type = 'figure'" from:data="$block"></x-figure>
<p $else>{$block.text}</p>
</template>
A component that chooses its own native root uses the same pattern at its root; see Native lowering and explicit root branches.
<portal to>: render elsewhere
Overlays (dialogs, tooltips, toasts) render outside their DOM position while staying logically owned by the component. <portal to="selector"> moves its children to the target (a CSS selector or an element id) while preserving reactive bindings and event wiring. The term is React createPortal; Vue Teleport was originally named <portal>, and Angular CDK ships a Portal too.8
<portal to="body">
<dialog open><slot></slot></dialog>
</portal>
Registration & loading
An <x-button> invocation has to resolve to a definition. There are three ways to make one known, in ascending scope:
- an inline
<template component> in the document; <template src="./x-button.html">, the inline import above;- a document-level
<link rel="component"> whose href is either a live URL or a package specifier.
For live loading, the loader must fetch the HTML resource referenced by <link rel="component"> and parse its contents as an inert HTML fragment in a <template> context. Build-time imports use the same parsing contract. A resource may contain sibling component carriers and dependency links, together with the build metadata allowed below; it does not need a document-level <html>, <head>, or <body> wrapper.
What a component resource contains
A component resource is a file of component carriers and the dependency links they need. It may also hold build metadata for the file as a whole, such as which of its components is a page:
products.html
<meta name="app:page" content="page-products">
<link rel="component" href="./product-summary.html">
<template component="page-products">
<defs>
<title>Products · Admin</title>
<meta name="app:layout" content="admin">
</defs>
<section><h1>Products</h1><product-summary></product-summary></section>
</template>
Everything a component adds to the document head belongs to that component, in its carrier's <defs>, as head declarations. Only build metadata stays at resource scope, because it describes the file rather than any one component. A resource must contain at least one component carrier. After HTML fragment parsing, its resource-level nodes are handled as follows:
| Resource-level node | Component loader behavior |
|---|
<template component="…"> | Parse and register the component definition. |
<link rel="component" href="…"> | Resolve the component dependency. |
<meta name content> whose name contains a colon, such as app:page | Build metadata. The loader ignores it; it is never added to the document. |
| Comments and whitespace-only text | Ignore. |
Anything else, including <title>, any other <meta>, any other <link>, <style>, any <script>, <base>, HTML Imports, ordinary body elements, plain non-component templates, and non-whitespace text | Reject as invalid resource content. |
A carrier's direct children are its <defs>, its markup root, and its <style>; a head element placed directly in the carrier, outside <defs>, is invalid. A namespaced name such as app:page belongs to the application build that defines it: in this example it selects the page component in a file that holds several. Its spelling and meaning are the build's, and the component runtime gives it none. See Security for the imported-resource boundary.
The application names the concrete component files it directly uses. A same-origin URL-like href (./ or /) stays live project source: the runtime fetches that definition and follows its declared component and controller edges. A bare href is a package subpath: an install/build resolves that exact exported HTML file through package metadata, while a no-build live application resolves it through its own import map. Every path selects the same definition and preserves the same dependency graph:
<!-- First-party source: an ordinary URL, fetched at runtime. -->
<link rel="component" href="./components/dashboard.html">
<!-- Installed package: a concrete exported package subpath. -->
<link rel="component" href="@acme/ui/dashboard.html">
A package exposes concrete component files with the ordinary package.json exports field. The exports field is the whole package contract:
node_modules/@acme/ui/package.json
{
"name": "@acme/ui",
"exports": {
"./*.html": "./components/*.html"
}
}
The build resolves @acme/ui/dashboard.html to that file, then statically walks its declared edges. A dashboard definition might link ./chart.html; that definition's carrier might declare controller="./chart.js"; the controller's static imports complete the statically visible JavaScript side. No step executes code to discover dependencies, and no generated manifest hides them. Development may serve those files directly; production may inline, copy, rewrite, or bundle them while preserving their meaning.
Definitions declare; applications resolveA component definition contains only its reusable dependency facts. It never contains an approval flag, consumer allowlist, deployment URL, or consumer-specific hash. Relative references travel with the definition. Bare references are resolved by the consuming application's ordinary import map or package build. Installing or directly importing a packaged root is the application's trust decision; the definition is identical whether it is consumed from a package, a build output, or a live URL.
The separate live trust pathA no-build page may map one package prefix to a versioned CDN directory through the ordinary import map imports table6. That application-owned prefix is both the resolver and the boundary for declarative definition edges: relative component links and carrier controller references may stay within it, while an edge outside it must use another bare specifier the application maps. Imported definitions cannot contribute import maps or widen a prefix. Once a trusted controller runs, its own imports are ordinary ESM governed by CSP rather than a directory sandbox; Security defines that boundary precisely.
<script type="importmap">
{
"imports": {
"@acme/ui/": "https://cdn.example/@acme/ui@4/components/"
}
}
</script>
<link rel="component" href="@acme/ui/dashboard.html" crossorigin>
For live loading, <link rel="component"> retains the normal fetch vocabulary: integrity, crossorigin, referrerpolicy, type, and fetchpriority. Packaged output normally relies on the package lock and same-origin content-hashed assets. For a stricter live deployment, tooling can crawl the same static graph and generate the application's URL-keyed integrity metadata; deployment hashes belong to the application.
Resolution
All of these forms feed one document-level registry keyed by tag; when the parser meets <x-button> it looks the tag up there. A tag must have exactly one definition in a document. Declaring the same tag more than once, inline or by pointer, is a conformance error, so resolution stays deterministic.
A definition that composes other components should carry its own <link rel="component"> edges, normally as relative URLs inside one live location or package. If it needs imperative behavior, its carrier declares one controller specifier. The complete HTML-to-HTML-to-JavaScript graph is therefore discoverable by a static walk of the definitions.
The definition's <style> may also declare CSS @import edges. A static graph walk includes their transitive stylesheet dependencies, using the shared CSS resolution and scoping contract. Resolving these stylesheets is part of the static walk.
Imported definitions are inert
<link rel="import"> (HTML Imports)7 defined an imported Document graph together with its own parser-blocking, script-ordering, style-ordering, deduplication, currentScript, and custom-element processing rules. The later HTML Modules proposal explicitly identified global-object pollution and parse blocking among the problems and attempted to move the graph into ES modules. HTML Next keeps component definitions declarative and gives their one optional imperative edge to the existing ES-module loader.
A component definition contains no executable code: code is referenced, never embedded. Its only children are an optional <defs> region, one markup root, and an optional <style>, in any order. A root that is a table part, such as <tr> or <th>, comes first, because the <template> parses its content in the context of its first element and keeps a table part only there. It must not contain an inline executable <script>, inline event handlers, or anything requiring eval(), and bindings use a restricted pure expression language. Code reaches a component only through references the definition makes visible: the carrier's controller module and the src of each declared script. The platform loads both under the page's CSP, and only when an instance connects. So importing a definition is a pure fetch, parse, and register: no code executes, no globals are shared, nothing changes in the document, and there is no lifecycle to order. Registration is deduplicated by tag: registering the same definition again changes nothing, and a different definition replaces the registered one. Neither runs a script, because a definition has no script side effects to run twice.
Definitions stay declarativeThe definition contains no executable code, and its reactivity lowers and compiles. Imperative behavior lives in code the definition references by URL: its controller in the ES module system, and any declared scripts. Both are the only parts with a real lifecycle, and both start when an instance connects. Code lives in referenced files while the component definition remains data.
The upstream surface stays smallThe component-loading proposal needs rel="component", external <template src>, and application of the existing module-specifier resolution algorithm to bare component references. Everything else reuses existing import maps and package metadata.
Roots: native or delegated
A component has exactly one significant root. That single root is what gives it one native element and one place for its provenance stamp, so the rule earns its keep. The root may take either shape, and neither introduces a wrapper:
- a native element, the common case: the component lowers straight to it, and
nativeElement is that tag; a polymorphic definition uses explicit conditional branches whose selected arm provides the native root; - another component invocation (delegation): the component lowers to whatever the delegated component lowers to, so a preset such as
x-primary is built as an x-button with a fixed variant.
Delegation still resolves to a single native element, transitively through the chain and with a cycle a conformance error, and it loses no provenance because component is a token list, outermost first:
<!-- delegation: a preset built from another component -->
<template component="x-primary">
<x-button variant="solid"><slot></slot></x-button>
</template>
<x-primary>Save</x-primary>
<!-- lowers to a single native button, with the lineage preserved -->
<button component="x-primary x-button">Save</button>
A delegating component follows the same attribute forwarding rules: its declared props are applied through the bindings in its markup, and unconsumed invocation attributes apply first on the delegated root unless noforward selects explicit placement. Template-owned attributes (here variant="solid") therefore take precedence over automatic forwarding. Its nativeElement and native attribute surface are whatever the chain ultimately resolves to, so its generated types inherit that surface.
The Level 1 single-root boundaryA component that emits sibling <li>, <tr>, or <option> elements would have no single native element for invocation attributes, the provenance stamp, :host, controller host.root, or element events and methods. The source syntax also fails in a key case: when HTML parses an <x-rows> invocation inside <tbody>, it moves the custom element outside the table while keeping the <tr> elements inside. A browser runtime cannot reliably recover that invocation-to-output relationship from the parsed DOM. A <template> carrier survives there, but would be a different invocation form, not a fragment variant of the current component contract.
Frameworks support sibling output by making separate tradeoffs, not by acquiring an invisible DOM element: Vue requires explicit attribute fallthrough for multiple roots; React provides a distinct FragmentInstance for fragment refs; Svelte compiles sibling nodes without a wrapper, scopes styles on the emitted elements, and returns component exports rather than a root element. Svelte also parses its own HTML++ source, so the browser never has to parse an <x-rows> invocation in <tbody>. A hostless component would need its own parsing, attribute, styling, controller, and hydration contract. Level 1 keeps the one-element-root invariant; structural <template> directives may still emit siblings inside an element-root component. Keeping <x-rows> as the invocation syntax in table contexts would require an HTML parser change.
Element reference
<template component> component definition
- Attributes
component: the invocation tag (hyphenated, collision-safe) · optional controller: one relative, URL-like, or bare ES-module specifier · optional boolean noforward: disables automatic attribute forwarding · optional status, summary.- Children
- an optional
<defs> region, one markup root, optional <style>. - Semantics
- Inert native template (as
<template shadowrootmode> was before native adoption); parsed to a fragment; renders nothing without a runtime. - Level
- L1
<defs> non-rendered declarations
- Contains
<prop>, <event>, <request>, <slot> declarations, <state>, <computed>, <data>, <handler>, and <context>, and the head declarations <title>, <meta>, <link>, and <script>, as flat siblings — everything that renders nothing in place.- Semantics
- Separates behavior/data/interface from visible markup; borrowed from SVG
<defs>. Survives the template fragment parser where <head>/<body> do not. - Level
- L1
<prop> component interface declaration
- Attributes
name · type (base value type or constructor) · pattern? · default? · required?- Content
- the prop description (element text)
- Placement
- a flat child of
<defs> (no <props> wrapper); the public interface is the set of <prop> elements there. A <prop> inside a <slot> declaration in <defs> declares one of that slot's props instead - Inferred
- target from the first
from:attribute/.property binding. A prop may be bound in more places; those only render it. - Level
- L1
<request> declared question a consumer answers
- Attributes
name, type?, returns? · <prop> children for the detail · <returns type> for the answer- Placement
- a flat child of
<defs>, beside <prop> and <event> - Asked with
host.request(name, detail, { signal, progress }), resolving to the answer or null (see the JavaScript Layer); host.hasResponder(name) tells whether a known responder answers- Answered with
- a
RequestEvent listener calling respondWith, RequestEvent.respond(), or a framework function prop; the nearest responder answers - Level
- L1
<title> · <meta> · <link> · <script type="application/ld+json"> head declarations
- Placement
- flat children of
<defs> - Effect
- added to
document.head while an instance is connected, marked data-component-head; competing keys by precedence, shared entries reference-counted (see Head management) - Bindings
- inline text in
<title> · from:content · from:href · from:value on a JSON-LD block - Authority
canonical, alternate, icon, manifest, robots, referrer, viewport, color-scheme, theme-color, og:url, and component-src are page-wide head entries, which apply only from application components- Level
- L1 (proposed)
<script name src loading> declared script
- Declaration attributes
name? · loading: eager (default), lazy, manual · on:load, on:error- Standard attributes
src · type? (module) · integrity · crossorigin · referrerpolicy · fetchpriority, copied onto the inserted element- Exposes
.pending, .ok, .error · host.scripts.name with ready and load() · on:load or on:error once per connection- Semantics
- one ordinary
<script> element per URL and type in document.head, inserted once and never removed - Level
- L1
component provenance stamp (output)
- Value
- space-separated token list, outermost invocation first (
x-primary x-button); a single token for a native-root component - Emitted by
- the implementation, on every lowered native root;
component by default, with an explicit data-component legacy output mode for polyfills - Resolves via
- the component registry, the loaded
<template component> definitions keyed by tag (like customElements) - Level
- L1
<slot> content projection
- Attributes
name? (named slot) · of? (proposed: whose slot) · any other plain attribute (a static slot prop, converted by its declared type) · from:prop bindings (bound slot props)- Outlets
<slot name="caption"> places this component's own caption slot · <slot of="$column" name="cell"> places the cell slot of the child that the entry $column stands for: the child's own slot, placed by the parent- Placement
- in the markup, an outlet; inside
<defs>, a declaration that describes the slot: name?, the default slot when omitted, and its props as <prop> children · proposed on a declaration: accepts="tag …" limits the slot's children to one or more components, and as="name" gives them to the template as a list (Columns as markup) - Children
- fallback content used when nothing is projected
- Parser-safe form
<template $slot> for the default slot, <template $slot="name"> for a named one: the same outlet, with the same props, fallback as its content, and the same rendered form; the parser keeps it in table parts, where it moves a <slot> out (see Outlets in tables)- Level
- L1 · default, named, fallback, and scoped slots
<link rel="component"> component entry import
- Attributes
href: URL-like live entry or bare package specifier · fetch vocabulary: integrity, crossorigin, referrerpolicy, type, fetchpriority- Semantics
- import a component resource as an inert HTML fragment and follow its statically declared dependency closure; package specifiers resolve through ordinary package/import-map resolution
- Level
- L1
<template src> · <portal to> composition
- Semantics
- import/lazy-load a definition · relocate children while preserving bindings
- Level
- L1
Sources
- JSON Schema Validation, the
enum keyword. - W3C XML Schema, the
default attribute on element declarations. - WHATWG HTML, boolean attributes (e.g.
required) and the <option> element (text content as label). - WHATWG HTML, Declarative Shadow DOM (
<template shadowrootmode>, inert to native). - SVG 2, the
<defs> element. - WHATWG HTML, import maps (unrecognized top-level keys are ignored).
- W3C (retired), HTML Imports (
<link rel="import">). - Render-elsewhere, borrowed directly: React createPortal (the term) and Vue Teleport (originally named
<portal>), plus Angular CDK Portal. - W3C, Content Security Policy Level 3: the fetch directives, the source list grammar and matching a URL to a source list.
- W3C, Permissions Policy: the allowlist of origins a feature is enabled for.
- WHATWG DOM, assigning slottables and
assignedNodes(); CSS Scoping, slots and fallback content in the flat tree: a slot with no assigned nodes renders its own children, and switches as nodes are assigned or removed. - Vue, scoped slots: attributes on
<slot> are passed to the slot, with name reserved; defineSlots() types each slot's props. - WHATWG HTML, foster parenting in the in table insertion mode: an element that is not table content is inserted before the table, while a
<template> start tag is processed by the in head rules and inserted where it is written. - PrimeVue, DataTable: each
Column child supplies a header and a body template that the table renders once per row. - WHATWG HTML:
select.options, the list of options, which includes <option> children of <optgroup>; the source element; and the track element.