Skip to content

Single-File Component Authoring

When emitting a self-contained custom element in one HTML file (a <template> plus a <script>, no build step), a handful of authoring rules keep the result readable, declarative, and free of collisions with the host page. These are the defaults to reach for, not a style to apply only on request.

When to use

Use these rules whenever you hand-author a custom element as a single drop-in file: a prototype component, a reviewable HTML artifact, or any element a human pastes into a page without a bundler.

The rules

Use a module script, not an IIFE

Wrap the logic in <script type="module">. Module scope already isolates everything, so the IIFE wrapper and its top-level return guard are dead weight. The define collapses to:

html
<script type="module">
  if (!customElements.get("comment-layer")) {
    customElements.define("comment-layer", CommentLayer);
  }
</script>

Note that document.currentScript is null in a module, so grab the template by name (below) rather than walking from the running script.

Grab the template by its name

Give the template a name and select it by that attribute. No id is needed, so there is nothing to collide with the host page:

javascript
const template = document.querySelector(`template[name="comment-layer"]`);

Select template[name=...] (the template element itself), not the descendant form template [name=...] with a space. A template's content is an inert fragment, so the descendant selector cannot match into it and returns null.

Build UI from <template>, never string concatenation

Do not assemble DOM with HTML string concatenation or innerHTML. Declare each repeated piece as its own nested <template> and clone it, filling slots with textContent and dataset:

html
<template name="comment-item">
  <div class="item">
    <q></q>
    <div class="note"></div>
  </div>
</template>
javascript
function cloneTemplate(name) {
  return shadow
    .querySelector(`template[name="${name}"]`)
    .content.cloneNode(true);
}

const row = cloneTemplate("comment-item");
row.querySelector(".note").textContent = comment.note;

This deletes the hand-rolled esc() escaper and makes the result XSS-safe by construction. Text set through textContent is never parsed as HTML, so an <img onerror> payload stays on the page as inert literal text.

Keep the scoped <style> inside the template

For a shadow-DOM component, the stylesheet must live in the shadow root to be scoped, and the only declarative single-file way to get it there is to clone it in with the rest of the template. So the <style> belongs inside the <template>, cloned alongside the markup. The alternative, an adopted constructable stylesheet, means handing CSS to CSSStyleSheet.replaceSync() as a JavaScript string — CSS-in-JS. Keep styles in the stylesheet and feed runtime values in as custom properties or attributes rather than authoring CSS in JavaScript. That rule is general, not specific to single-file components; see Dynamic Styles for the full custom-property vs CSS-in-JS treatment.

Keep it readable, not minified

Ship readable source: one statement per line, one CSS property per line, real names over one- and two-character abbreviations. Run the project's formatter and linter rather than reflowing by hand, so the behavior is provably unchanged. See Code Style.

See also