§5 · Declarative HTML Components Level 1 · external composition in Level 2

Components & Composition

A component is typed markup that lowers to a real native element: no class, no registry, no lifecycle ceremony. 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, and slots: Level 1 · external composition: Level 2

Definition: <template component>

A component is defined by a native <template component="tag">. There is no custom-element-shaped wrapper. 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, not the end state. template[component] is also a cheap selector for the polyfill. Its direct children are an optional <defs> region (the interface and behavior declarations), one markup root, an optional <style>, and optional application-tooling metadata that the component runtime accepts and ignores. See Resource metadata for application tooling.

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, not a data island. 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 without registering a custom element. A compiler lowers all of this to a normalized JSON contract as build output (CSP-safe, no eval(), inspectable by docs and tooling), but JSON is the compiled artifact, never the authoring form.

Prop declarations follow platform precedents

values 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> interface declarations, reactive <state> and <computed>, read and write <data> resources, and <handler> blocks live inside a single <defs> region as flat siblings. The visible markup is the one native root and its <slot>s. Application-tooling metadata may appear directly inside the carrier as siblings of these regions; it does not render or enter the component's declarations or binding scope. 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).

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; owned template attributes and prop targets take precedence.

A polymorphic component declares as as a keyword prop constrained to its supported roots and selects between explicit native roots with $match. The prop does not retag an element: the definition contains the actual <button> and <a> branches that it may render. 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> -->
Conformance

The 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.

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. The invocation tag does not survive: <x-button> becomes a real <button>, never <x-button><button>…</button></x-button>. Children land where the <slot> was, declared props map to their targets, and undeclared attributes pass through.

<!-- 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 data-component="x-button">Save</button>
Determinism: one DOM, either path

Lowering 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, data-component, injected by the implementation rather than authored. 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 (data-component="x-button"), and one whose root is another component carries the whole lineage (data-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; no separate provenance format exists or is needed. 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, not the component's, so it keeps whatever provenance it already had and is never re-stamped as the enclosing component.

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 is not automatically exposed as data-<name>. The reference runtime may carry prop data for hydration, but its storage format is not an authored or observable component contract. 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; it does not require a visible data-<name> attribute on the root.

The stamp is also what makes hydration an adopt-in-place, not a rebuild. An implementation lowers where it finds an <x-button> invocation, and adopts where it finds an already-lowered [data-component] root: it binds reactivity and events onto the existing node instead of recreating it. An SSR'd tree therefore hydrates with no replacement and no 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.

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>

Scoped slots

A slot may expose data to the content projected into it. The definition binds slot props on the <slot>; the consumer supplies a <template slot="name"> whose scope is those exposed props: no new prefix, consistent with $with-style scoping.

<!-- definition: a list that owns iteration, slots each row out -->
<slot $each="row of rows" $key="row.id" name="row" from:item="row" from:index="loop.index"></slot>

<!-- use: the template's scope is { item, index } -->
<x-list from:rows="people">
  <template slot="row"><td>{$item.name}</td></template>
</x-list>

Component ancestry for shared state

Each nested component has a logical parent: the component whose markup or slot outlet renders it. 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 rather than receiving the slot owner's data-component stamp. 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.

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 -->

<component is>: dynamic component

When the component to render is decided at runtime, <component is="expr"> resolves the tag from an expression, the name and syntax taken verbatim from Vue <component :is> (Svelte <svelte:component> and Angular NgComponentOutlet are the same idea).8 Props and children pass through as with a literal invocation.

<component is="block.type" from:data="block"></component>

<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.9

<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:

  1. an inline <template component> in the document;
  2. <template src="./x-button.html">, the inline import above;
  3. 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 metadata allowed below; it does not need a document-level <html>, <head>, or <body> wrapper.

Resource metadata for application tooling

A component carrier may carry application metadata for that component as direct children beside its declarations, markup root, and style. This lets a build use ordinary HTML for a page title, description, or application-specific configuration while keeping the same component usable through the regular runtime. When a resource contains several components, placing metadata inside its owning carrier makes that association explicit.

products.html
<meta name="example:page" content="page-products">

<template component="page-products">
  <meta name="example:layout" content="admin">
  <meta name="description" content="Manage your products.">
  <meta property="og:title" content="Product administration">
  <title>Products · Admin</title>

  <section><h1>Products</h1><product-summary></product-summary></section>
</template>

<template component="product-summary">
  <title>Summary metadata belongs to this component</title>
  <p>Your product summary.</p>
</template>

A carrier may contain <meta> elements without http-equiv, <title>, and ordinary metadata <link> elements as direct children. These elements must not count as markup roots, enter the normalized component definition, or be rendered by lowering. A conforming runtime or compiler must accept and ignore them: it must not change the consuming document's title or head, load linked styles or other resources, evaluate metadata bindings, or interpret application-specific names. They are not interface or behavior declarations in <defs>, and this allowance does not apply inside the rendered root. The carrier still must declare exactly one markup root; metadata alone is not a component body.

The same bounded metadata allowance applies at resource scope, outside every carrier, for build-system conventions associated with the file. Such metadata has no implicit component owner. In the example, example:page illustrates a file-level entry selector; its spelling and selection behavior belong to the build system. The component runtime ignores it. Component dependency links remain distinct: a resource-level <link rel="component"> still declares a component graph edge and is not inert metadata. Neither component dependency links nor HTML Imports are carrier metadata.

A resource must contain at least one component carrier. After HTML fragment parsing, its resource-level nodes are handled as follows:

Resource-level nodeComponent loader behavior
<template component="…">Parse and register the component definition, ignoring its direct application metadata.
<link rel="component" href="…">Resolve the component dependency.
<meta> without http-equiv, <title>, and other metadata <link> elementsAccept and ignore; do not evaluate bindings or activate the nodes.
Comments and whitespace-only textIgnore.
<style>, any <script> (including import maps and data scripts), <base>, <meta http-equiv>, HTML Imports, ordinary body elements, plain non-component templates, and non-whitespace textReject as invalid resource content.

Executable event-handler attributes remain invalid in metadata at either scope. Scripts, <base>, policy metadata, and HTML Imports also remain invalid inside a carrier. A metadata <link rel="stylesheet"> is ignored at resource scope or as a direct carrier child; the optional <style> inside a carrier still supplies its scoped component CSS. Imperative behavior still uses the carrier's declared controller module.

Application build systems may consume file-level and component-owned metadata under their own documented conventions. For example, a build may select one component as a page, interpret that component's namespaced metadata as a layout choice, and collect its title and description into the generated document's head. This proposal defines neither page selection nor layout selection, head merging, precedence, binding scope, or navigation updates. An application build owns those behaviors; they are not component registration or lowering effects. Metadata from another component or an imported dependency does not acquire application authority merely because rendering or the component graph reaches it.

Metadata is ignored by component processing, not generally inert HTML: when an author places those same elements directly in an active application document, their ordinary HTML behavior still applies. See Security for the imported-resource boundary.

One import form, live or packaged

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. It needs no HTML Next manifest and no registration script:

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 resolve

A 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 path

A 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; component authors do not calculate or embed deployment hashes.

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 rather than last-wins, 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, without relying on the consuming document to reconstruct it or executing a package entry point.

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 is declarative and script-free. Its only children are an optional <defs> region, one markup root, and an optional <style>; it must not contain an executable <script>, inline event handlers, or anything requiring eval(), and bindings use a restricted pure expression language rather than ambient JavaScript. So importing a definition is a pure fetch, parse, and register: no code executes, no globals are shared, and there is no lifecycle to order. Registration is idempotent, deduplicated by tag, with a duplicate tag a conformance error, precisely because there are no script side effects to double-run.

Definitions stay declarative

The definition is declarative and script-free, its reactivity lowers and compiles, and the ES module system is reserved for genuine imperative behavior at a later level, the only part with a real lifecycle. Code lives in the module graph while the component definition remains data.

The upstream surface stays small

The component-loading proposal needs rel="component", external <template src>, and application of the existing module-specifier resolution algorithm to bare component references. It does not require a new import-map section, a global tag manifest, or a second package metadata format.

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:

  1. 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;
  2. another component invocation (delegation): the component has no native element of its own and 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 data-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 data-component="x-primary x-button">Save</button>

A delegating component forwards like any other lowering: its own declared props are applied through the bindings in its markup, template-owned attributes (here variant="solid") take precedence, and any undeclared invocation attributes pass through to the delegated root and continue down the chain. 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 boundary

A 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 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>, <state>, <computed>, <data>, <handler>, and <context> as flat siblings — everything that renders nothing.
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
Inferred
target from the first from:attribute/.property binding; not restated on the prop. A prop may be bound in more places; those only render it.
Level
L1

data-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; not authored
Resolves via
the component registry, the loaded <template component> definitions keyed by tag (like customElements)
Level
L1

<slot> content projection

Attributes
name? (named slot) · :prop bindings (scoped-slot data)
Children
fallback content used when nothing is projected
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> · <component is> · <portal to> composition

Semantics
import/lazy-load a definition · render a runtime-chosen component · relocate children while preserving bindings
Level
L2

Sources

  1. JSON Schema Validation, the enum keyword.
  2. W3C XML Schema, the default attribute on element declarations.
  3. WHATWG HTML, boolean attributes (e.g. required) and the <option> element (text content as label).
  4. WHATWG HTML, Declarative Shadow DOM (<template shadowrootmode>, inert to native).
  5. SVG 2, the <defs> element.
  6. WHATWG HTML, import maps (unrecognized top-level keys are ignored).
  7. W3C (retired), HTML Imports (<link rel="import">).
  8. Dynamic component, borrowed directly: Vue <component :is> (name and syntax verbatim), Svelte <svelte:component>, and Angular NgComponentOutlet.
  9. Render-elsewhere, borrowed directly: React createPortal (the term) and Vue Teleport (originally named <portal>), plus Angular CDK Portal.

Declarative HTML Components Level 1: Components & Composition. Unofficial Editor's Draft · Stage 0. Published in the HTML Next collection by the Next Web Working Group; not a W3C or WHATWG deliverable. Version history.