On this page
script customelement blockDeclarative Shadow DOMTag name derivationLight DOM outputShadow DOM outputimportMap()CollectCustomElementsScriptHandlerCollectorRenderWithCollectorWriteScriptsNewCustomElementCollectorNewScriptFSServerCollector methodsCLI behaviorCustom 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.
| Attribute | Shadow root mode |
|---|---|
shadowdom | open |
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:
- Split the path on
/to get path segments. - Strip the
.vueextension from the last segment. - Convert each segment from PascalCase/camelCase to kebab-case.
- Join all segments with
-and lowercase the result.
| File path (relative to ComponentDir) | Derived tag |
|---|---|
Button.vue | button ⚠ (no hyphen) |
ui/Button.vue | ui-button |
widgets/ShapeCanvas.vue | widgets-shape-canvas |
nav/TopBar.vue | nav-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
.jsfiles (e.g./scripts/a1b2c3d4.js) — served from the in-memoryfs.FSwithCache-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.
| Command | Custom element behavior |
|---|---|
htmlc build | Writes 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 :addr | Serves 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/.