Module · Level 1 core → Level 2 composition

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.

Definition & native root: Level 1 · slots, as, 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 every non-rendered declaration), 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="outline | solid | destructive | ghost"
          default="outline">Visual treatment.</prop>

    <state name="pending" :value="false">
    <handler name="press"><dispatch event="press"></handler>
  </defs>

  <!-- the visible markup: one native root, referencing the defs by name -->
  <button :data-variant="variant" on:click="press"><slot></slot></button>
  <!-- styles are automatically scoped to this component (see Style scoping) -->
  <style>button { 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 :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.

Type grammar, borrowed not invented

Scalar keywords (string, number, boolean) mirror the CSS Values & Units data types <number>/<string>; the enum bar outline | solid | destructive | ghost is that spec's value-definition-syntax “exactly one of” combinator1; 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. Everything that produces no output, the <prop> interface declarations, reactive <state> and <computed>, <data> sources, and <handler> blocks, lives inside a single <defs> region as flat siblings. Everything after it is the visible markup: the one native root and its <slot>s. 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

Why not <head>/<body>

The obvious move, reusing <head> and <body>, does not survive the parser: inside a <template> the HTML fragment parser discards both wrappers and hoists their children out (verified against the reference implementation's parse5-based build). <defs> is an ordinary element in HTML content, so it round-trips intact through outerHTML, and it already carries the right meaning.

Native lowering & the as prop

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.

Design systems need one component to render as different native elements. Because the native root is normally inferred from the markup, a polymorphic component states its choices explicitly: the root's as attribute lists the allowed native roots with the same | bar the prop enums use, and the consumer picks one:

<!-- the markup root declares the allowed native roots -->
<button as="button | a"><slot></slot></button>

<x-button>Save</x-button>                       <!-- → <button> -->
<x-button as="a" href="/save">Save</x-button>   <!-- → <a href> -->
Conformance

An as value must be one of the root's declared as options. Each root carries its own native attribute surface, so the generated framework types narrow accordingly.

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 :data-variant="variant"><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" data-variant="solid">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. 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.

Alongside data-component, every serializable prop is reflected on the root as data-<name> carrying its effective value (passed or default). The two together make the invocation fully reconstructable from the DOM: data-component names which component, the data-* attributes carry what it was invoked with, so replacing <x-button> with a native <button> loses no information. The attribute string plus the prop's declared type round-trips losslessly, so no separate value channel is needed; structured (object/array) props are bound by reference and carried as JSON in the payload instead of reflected per-attribute. Reflection is uniform even when a prop also maps to a native attribute, so data-* is always the complete record. See Types.

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 supports one default slot; Level 2 adds named, fallback, and scoped slots.

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" :item="row" :index="loop.index"></slot>

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

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" :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 registry in the page's import map, plus <link rel="component"> for head registration and preloading.

The import map's components map

Rather than invent a registry, HTML Next reuses the platform's own name-to-URL map. A components field maps each invocation tag to its definition URL:

<script type="importmap">
{
  "imports": { "lodash": "/vendor/lodash.js" },
  "components": {
    "x-button": "./components/x-button.html",
    "x-card":   "./components/x-card.html"
  }
}
</script>

That map is read by HTML Next's resolver at parse and lower time. It is not a JavaScript module map: resolving <x-button> needs no import, no module graph, and stays CSP-clean, which is why it is a sibling of imports rather than an entry inside it. The browser's own import-map machinery ignores the components key: per the import maps spec an unrecognized top-level key is ignored6, so the imports and scopes a page already relies on keep resolving, though a user agent should log a console warning for the extra key today.

<link rel="component">

For a single definition, or to let the preload scanner fetch a source early and in parallel, register it from the head. This revives the shape of the retired <link rel="import"> with tag-based resolution, and inherits the full fetch vocabulary a <template> cannot express: integrity, crossorigin, type, fetchpriority.

<!-- register + let the preload scanner fetch a definition early -->
<link rel="component" href="./components/x-button.html"
      integrity="sha384-…" crossorigin>

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 registrations for them, its own <link rel="component"> or components entries, so it resolves its dependencies wherever it is imported rather than relying on whatever the consuming document happens to have declared. That is the same discipline an ES module follows by importing what it needs instead of assuming globals.

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 accepts that one module graph should carry executable behavior, but does not make declarative definitions depend on JavaScript: component imports remain data, and controller requests require separate application authorization.

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.

JavaScript is the exception, not the substrate

The broader reflex was to answer every gap in HTML by reaching for JavaScript: component distribution became a module-loading problem, reactivity became a runtime. HTML Next inverts the default. 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; the component does not.

Two upstream proposals

Both pieces are shaped to fold into the platform. HTML Next proposes (1) a standardized components section in import maps, which retires the console warning, and (2) external import maps via a src attribute on <script type="importmap">, which the platform does not support today (import maps are inline-only), so an entire registry, components included, can live in one cacheable, integrity-checked file. Until both land, the map is inline and <link rel="component"> covers the external, preloadable case.

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 (or a declared as 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" data-variant="solid">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.

Fragments are a later level

A component that must emit several siblings (list items, table rows, <option>s inside a <select>) needs a fragment root: an explicit declaration that produces multiple nodes and lowers to each target's fragment form. It relaxes the one-root rule in a controlled way and is deferred to a later Level rather than allowed ad hoc, so Level 1 keeps the clean single-root, single-stamp model.

Element reference

<template component> component definition

Attributes
component: the invocation tag (hyphenated, collision-safe) · 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> 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 (scalar keyword or a | b | c enum) · 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 :attribute/.property binding; not restated on the prop
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
default L1 · named/scoped L2

import map <code>components</code> definition registry

Shape
a components sibling of imports: invocation tag → definition URL
Read by
the HTML Next resolver at parse/lower time; ignored by the JS module loader (console warning until standardized)
Level
L1 · upstream standardize the key + src (external import maps)

<link rel="component"> head registration

Attributes
href · fetch vocabulary: integrity, crossorigin, type, fetchpriority
Semantics
register a single definition from the head; preload-scannable. Revives rel="import" with tag-based 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. CSS Values and Units Level 4, value definition syntax (scalar keywords and the | “exactly one of” bar).
  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.