On this pagescript customelement blockDeclarative Shadow DOMTag name derivationLight DOM outputShadow DOM outputimportMap()CollectCustomElementsScriptHandlerCollectorRenderWithCollectorWriteScriptsNewCustomElementCollectorNewScriptFSServerCollector methodsCLI behavior

Custom Elements

Reference for Web Component support in htmlc. A .vue component opts in by including a <script customelement> block. The engine derives a kebab-case tag name from the file path, wraps the rendered template in that tag, and collects the JavaScript for serving or writing to disk.

The <script customelement> block

Place a <script customelement> block inside any .vue file alongside the <template> and optional <style> blocks. The block contains a plain Web Component class and a customElements.define() call.

<!-- components/ui/Counter.vue -->
<template>
  <button class="counter-demo">Count: <span>{{ initial }}</span></button>
</template>

<script customelement>
class UiCounter extends HTMLElement {
  connectedCallback() {
    const span = this.querySelector('span')
    let n = parseInt(span.textContent, 10)
    this.addEventListener('click', () => { span.textContent = ++n })
  }
}
customElements.define('ui-counter', UiCounter)
</script>

The rendered HTML output for <UiCounter :initial="0"></UiCounter> is:

<ui-counter><button class="counter-demo">Count: <span>0</span></button></ui-counter>

Live demo

Click the button to increment the counter:

Constraint:<script customelement> cannot coexist with <script> or <script setup> blocks. Combining them causes a parse error.

Declarative Shadow DOM

Add the shadowdom attribute to request Declarative Shadow DOM. The renderer wraps the template output in a <template shadowrootmode="..."> element inside the custom element tag.

AttributeShadow root mode
shadowdomopen
shadowdom="closed"closed
<!-- open shadow root -->
<script customelement shadowdom>
class MyWidget extends HTMLElement { /* … */ }
customElements.define('my-widget', MyWidget);
</script>

<!-- closed shadow root -->
<script customelement shadowdom="closed">
class MyWidget extends HTMLElement { /* … */ }
customElements.define('my-widget', MyWidget);
</script>

Tag name derivation

htmlc derives the custom element tag name automatically from the component's file path relative to ComponentDir. No manual registration is required.

Algorithm:

  1. Split the path on / to get path segments.
  2. Strip the .vue extension from the last segment.
  3. Convert each segment from PascalCase/camelCase to kebab-case.
  4. Join all segments with - and lowercase the result.
File path (relative to ComponentDir)Derived tag
Button.vuebutton ⚠ (no hyphen)
ui/Button.vueui-button
widgets/ShapeCanvas.vuewidgets-shape-canvas
nav/TopBar.vuenav-top-bar

Warning: If the derived tag contains no hyphen, htmlc emits a warning at parse time. The Custom Elements specification requires at least one hyphen in the tag name. Place components in a subdirectory (e.g. ui/Button.vue) to ensure a valid tag.

Rendered output — light DOM

At render time the component's template output is wrapped in its derived custom element tag. No shadow root is added.

<!-- widgets/ShapeCanvas.vue template -->
<canvas :width="width" :height="height" :data-src="src"></canvas>

Renders as:

<widgets-shape-canvas><canvas width="400" height="300" data-src="/api/stream"></canvas></widgets-shape-canvas>

Rendered output — Declarative Shadow DOM

With the shadowdom attribute the inner HTML is wrapped in a <template shadowrootmode="..."> element, enabling streaming SSR for shadow roots.

<!-- open shadow root -->
<widgets-shape-canvas><template shadowrootmode="open"><canvas width="400" height="300"></canvas></template></widgets-shape-canvas>

<!-- closed shadow root -->
<widgets-shape-canvas><template shadowrootmode="closed"><canvas width="400" height="300"></canvas></template></widgets-shape-canvas>

The importMap() template function

importMap(urlPrefix) is a template function automatically available in every component scope. It returns the JSON string produced by collector.ImportMapJSON(urlPrefix), suitable for embedding inside a <script type="importmap"> element.

<!-- In your page layout <head> -->
<script type="importmap">{{ importMap("/scripts/") }}</script>

The import map JSON maps each custom element tag name to its hashed script URL:

{
  "imports": {
    "ui-date-picker":       "/scripts/a1b2c3d4e5f6a7b8.js",
    "widgets-shape-canvas": "/scripts/ff00112233445566.js"
  }
}

Note:importMap() is a no-op when no custom element components are present — it returns an empty import map JSON object. Place it unconditionally in your layout's <head>; pages without custom elements produce no observable overhead.

The function is available after RenderPage, RenderFragment, RenderWithCollector, or any render path that goes through the engine.

engine.CollectCustomElements

func (e *Engine) CollectCustomElements() (*CustomElementCollector, error)

Renders all registered pages and collects their custom element scripts without producing any HTML output. Returns a fully-populated *CustomElementCollector.

Useful for pre-warming an import map or script bundle at startup, and for testing that the expected scripts are registered. Returns an error when the engine has no registered components.

collector, err := engine.CollectCustomElements()
if err != nil {
    log.Fatal(err)
}
log.Printf("collected %d custom element scripts", collector.Len())

engine.ScriptHandler

func (e *Engine) ScriptHandler() http.Handler

Returns an http.Handler that serves the engine's collected custom element scripts. Mount it under a path prefix using http.StripPrefix:

http.Handle("/scripts/", http.StripPrefix("/scripts/", engine.ScriptHandler()))

The handler serves two kinds of responses:

  • Hashed .js files (e.g. /scripts/a1b2c3d4.js) — served from the in-memory fs.FS with Cache-Control: immutable.
  • index.js — an ES module entry point that imports all collected scripts using relative paths. Served without a long-lived cache header so it stays fresh after rebuilds.

engine.RenderWithCollector

func (e *Engine) RenderWithCollector(ctx context.Context, w io.Writer, name string, props map[string]any, collector *CustomElementCollector) error

Renders the named component to w and populates the given collector with every custom element script encountered during the render. Most callers should use RenderPage or RenderFragment instead; those methods manage the collector lifecycle automatically. RenderWithCollector is intended for advanced use cases where the caller controls the collector lifecycle — for example, when rendering multiple fragments into a single response and accumulating scripts from all of them.

collector := htmlc.NewCustomElementCollector()
if err := engine.RenderWithCollector(r.Context(), w, "HomePage", props, collector); err != nil {
    http.Error(w, err.Error(), 500)
    return
}

RenderWithCollectorString

func (e *Engine) RenderWithCollectorString(ctx context.Context, name string, props map[string]any, collector *CustomElementCollector) (string, error)

Convenience wrapper around RenderWithCollector that returns the rendered HTML as a string instead of writing to an io.Writer.

collector := htmlc.NewCustomElementCollector()
html, err := engine.RenderWithCollectorString(r.Context(), "HomePage", props, collector)
if err != nil {
    http.Error(w, err.Error(), 500)
    return
}
fmt.Fprint(w, html)

engine.Collector

func (e *Engine) Collector() *CustomElementCollector

Returns the engine's internal *CustomElementCollector. Use this to access the collector that is automatically populated during RenderPage and RenderFragment calls.

engine.WriteScripts

func (e *Engine) WriteScripts(dir string) error

Writes all collected custom element scripts to dir as content-hashed .js files. Creates dir if it does not exist. This is the static-build equivalent of ScriptHandler. A no-op when no custom element scripts have been collected.

if err := engine.WriteScripts("dist/scripts/"); err != nil {
    log.Fatal(err)
}

htmlc.NewCustomElementCollector

func NewCustomElementCollector() *CustomElementCollector

Creates a new, empty CustomElementCollector. For advanced use cases where you manage the collector lifecycle yourself. Most callers do not need this — the engine creates and manages a collector automatically.

htmlc.NewScriptFSServer

func NewScriptFSServer(collector *CustomElementCollector) http.Handler

Like engine.ScriptHandler(), but for a manually-created collector. Serves hashed .js files from collector.ScriptsFS() and responds to index.js requests with collector.IndexJS() (relative imports).

http.Handle("/scripts/", http.StripPrefix("/scripts/",
    htmlc.NewScriptFSServer(collector)))

Collector methods

Add

func (c *CustomElementCollector) Add(tag, script string)

Registers a custom element script under the given tag name. The script is deduplicated by content hash — adding the same source under multiple tags produces a single file. This is called automatically by the engine during rendering; manual use is only needed for advanced scenarios.

ScriptsFS

func (c *CustomElementCollector) ScriptsFS() fs.FS

Returns an in-memory fs.FS containing one content-hashed .js file per unique script collected. File names are the first 16 hex characters of the SHA-256 hash of the script source.

IndexJS

func (c *CustomElementCollector) IndexJS() string

Returns an ES-module entry point that imports all collected scripts using relative paths (import "./<hash>.js"). Duplicate hashes (same content registered under different tags) are emitted only once. Returns an empty string when no scripts have been collected.

ImportMapJSON

func (c *CustomElementCollector) ImportMapJSON(urlPrefix string) string

Returns a JSON string suitable for embedding in a <script type="importmap"> element. Each entry maps the custom element tag name to urlPrefix + "<hash>.js".

json := collector.ImportMapJSON("/scripts/")
// {"imports":{"ui-date-picker":"/scripts/a1b2c3d4e5f6a7b8.js"}}

Len

func (c *CustomElementCollector) Len() int

Returns the number of unique scripts collected (deduplicated by content hash).

CLI behavior

The htmlc CLI handles custom element scripts automatically — no extra flags required.

CommandCustom element behavior
htmlc buildWrites collected scripts to <out>/scripts/ after all pages are rendered. The directory is only created when at least one custom element component is present.
htmlc build -dev :addrServes scripts from memory at /scripts/, rebuilding automatically when source files change.

For all CLI flags and options, see the CLI reference.

Output directory structure when custom elements are present:

out/
  index.html
  about.html
  scripts/
    a1b2c3d4e5f6a7b8.js   # ui-date-picker
    ff00112233445566.js   # widgets-shape-canvas

Note: Script files are deduplicated by content hash. The same <script customelement> source used in multiple components produces exactly one file in scripts/.