W3C Component Structure
A W3C custom element is a JavaScript class derived from HTMLElement,
registered with a hyphenated tag name via customElements.define().
The browser creates an instance each time that tag is parsed.
Five parts make up the standard structure:
- observedAttributes - static getter listing attribute names
whose changes trigger attributeChangedCallback.
- constructor - calls super(), attaches a shadow
root, and populates it with a template of CSS and HTML.
- shadow DOM template - inlined CSS scoped to :host
plus the element's HTML structure, with <slot> elements for
light-DOM content projection.
- lifecycle callbacks - connectedCallback,
disconnectedCallback, and attributeChangedCallback
let the element react to DOM insertion, removal, and attribute changes.
- customElements.define() - binds the hyphenated tag name
to the class so the browser recognizes and upgrades matching elements.
SpacerComponent wraps two such classes (HSpace and VSpace)
inside factory functions and an IIFE, then registers each under two tag names.
IIFE and FOUC Guard
The entire component is wrapped in an immediately-invoked function expression (IIFE)
so none of its helpers, classes, or constants leak into the global scope.
The first thing the IIFE calls is injectPreUpgradeGuard(), which inserts
a <style> element as the first child of <head>.
The rule uses :not(:defined) selectors to set font-size:0 and
color:transparent on all four spacer tags until they are registered.
This prevents flash of unstyled content (FOUC) — raw size strings like
1.5rem are never visible on screen.
The guard is idempotent: it checks for its own id before injecting and
returns early if already present, so loading the script twice is safe.
Helper Functions
asCssLength(val, fallback) normalizes a size value. Unitless numbers
(e.g., 2) are converted to 2rem. Values with a CSS unit or
function are returned as-is. Null or empty values return the fallback.
pickSize(el, kind) implements a four-level priority chain for resolving
a spacer's gap: the size attribute first, then inner text content, then a
CSS custom property (--h-space-size or --v-space-size), then
the default 1rem. All active sources pass through asCssLength.
pickThickness(el) follows the same two-level chain (attribute, then CSS
var) for the optional visual height of horizontal spacers, defaulting to 0
so horizontal spacers never disturb baseline alignment.
HSpace Shadow DOM
Each instance attaches an open shadow root whose template inlines the full CSS plus
a single <slot>. The slot allows inner text to be read as a size
value by JavaScript, but slot { display: none !important; } ensures the
raw string is never rendered in the document flow.
Width and height are driven by two shadow-private custom properties:
--_h-size and --_h-thickness. The #update()
method writes these via this.shadowRoot.host.style.setProperty(),
so sizing changes never touch the shadow stylesheet itself.
color, background-color, and font are declared
inherit on :host, so the spacer adopts surrounding styles
without any explicit declarations from the author.
Lifecycle and MutationObserver
observedAttributes lists size, block, and
thickness for HSpace. Both attributeChangedCallback
and the MutationObserver delegate directly to #update().
A private MutationObserver field #mo watches
characterData, childList, and subtree so that
reassigning inner text after the element mounts also triggers #update().
connectedCallback starts observing and calls #update() once.
disconnectedCallback calls #mo.disconnect() to prevent leaks
when elements are removed or relocated in the DOM.
The constructor sets aria-hidden="true" and
role="presentation" so screen readers skip spacer elements entirely.
VSpace and Tag Registration
VSpace mirrors HSpace with two differences: it manages
height via --_v-size instead of width, and its display toggle switches
between block (default) and inline-block via the
inline attribute rather than block.
Both classes are defined inside factory functions (defineHSpace and
defineVSpace) that guard against double registration by checking
customElements.get(tagName) before defining.
Each factory is called twice — once for the canonical name (h-space,
v-space) and once for the short alias (h-s, v-s).
Both names register separate CustomElementRegistry entries backed by
the same class body.
// W3C Custom Element — five-part skeleton
class MyElement extends HTMLElement {
// 1. Attributes that trigger attributeChangedCallback
static get observedAttributes() {
return ['attr1', 'attr2'];
}
constructor() {
super();
// 2. Attach shadow root
this.attachShadow({ mode: 'open' });
// 3. Build shadow tree (inlined CSS + HTML structure)
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
slot { display: none !important; }
</style>
<slot></slot>
`;
}
// 4. Lifecycle callbacks
connectedCallback() { /* element added to DOM */ }
disconnectedCallback() { /* element removed from DOM */ }
attributeChangedCallback(name, oldVal, newVal) {
/* react to attribute change */
}
}
// 5. Register the custom tag name
customElements.define('my-element', MyElement);
16 (() => {
// Inject FOUC guard — hides inner text until elements are registered.
19 (function injectPreUpgradeGuard(){
20 const id = 'hv-space-pre-upgrade-guard';
21 if (document.getElementById(id)) return;
22 const style = document.createElement('style');
23 style.id = id;
24 style.textContent = [
25 'h-s:not(:defined),h-space:not(:defined),',
26 'v-s:not(:defined),v-space:not(:defined){',
27 'font-size:0!important;color:transparent!important;',
28 'line-height:0!important;}'
29 ].join('');
30 const head = document.head || document.documentElement;
31 if (head.firstChild) head.insertBefore(style, head.firstChild);
32 else head.appendChild(style);
33 })();
33 const numberRE = new RegExp('^\s*\d+(?:\.\d+)?\s*$');
35 function asCssLength(val, fallback = '1rem') {
36 if (val == null) return fallback;
37 const s = String(val).trim();
38 if (s === '') return fallback;
39 if (numberRE.test(s)) return `${s}rem`;
40 return s;
41 }
43 function pickSize(el, kind /* 'h'|'v' */) {
45 const attr = el.getAttribute('size');
46 if (attr && attr.trim() !== '') return asCssLength(attr);
48 const inline = (el.textContent || '').trim();
49 if (inline) return asCssLength(inline);
51 const varName = (kind === 'h') ? '--h-space-size' : '--v-space-size';
52 const cssVar = el.style.getPropertyValue(varName)
53 || getComputedStyle(el).getPropertyValue(varName);
54 if (cssVar && cssVar.trim() !== '') return asCssLength(cssVar);
55 return '1rem';
56 }
58 function pickThickness(el) {
60 const attr = el.getAttribute('thickness');
61 if (attr && attr.trim() !== '') return asCssLength(attr, '0');
62 const cssVar = el.style.getPropertyValue('--h-thickness')
63 || getComputedStyle(el).getPropertyValue('--h-thickness');
64 if (cssVar && cssVar.trim() !== '') return asCssLength(cssVar, '0');
65 return '0';
66 }
67 function defineHSpace(tagName) {
68 if (customElements.get(tagName)) return;
69 class HSpace extends HTMLElement {
70 static get observedAttributes() { return ['size', 'block', 'thickness']; }
71 #mo;
72 constructor() {
73 super();
74 this.attachShadow({ mode: 'open' });
76 this.shadowRoot.innerHTML = `
77 <style>
78 :host {
79 display: inline-block;
80 width: var(--_h-size, 1rem);
81 height: var(--_h-thickness, 0);
82 line-height: 0;
83 color: inherit;
84 background-color: inherit;
85 font: inherit;
86 }
87 slot { display: none !important; }
88 </style>
90 <slot></slot>
91 `;
92 this.setAttribute('aria-hidden', 'true');
93 this.setAttribute('role', 'presentation');
94 this.#mo = new MutationObserver(() => this.#update());
95 }
96 connectedCallback() {
97 this.#mo.observe(this,
98 { characterData: true, childList: true, subtree: true });
99 this.#update();
100 }
101 disconnectedCallback() { this.#mo.disconnect(); }
102 attributeChangedCallback() { this.#update(); }
103 #update() {
104 this.style.display =
105 this.hasAttribute('block') ? 'block' : 'inline-block';
106 const size = pickSize(this, 'h');
107 const thick = pickThickness(this);
108 this.shadowRoot.host.style.setProperty('--_h-size', size);
109 this.shadowRoot.host.style.setProperty('--_h-thickness', thick);
110 }
112 get size() { return this.getAttribute('size'); }
113 set size(v) { v == null ? this.removeAttribute('size')
114 : this.setAttribute('size', v); }
115 get thickness() { return this.getAttribute('thickness'); }
116 set thickness(v){ v == null ? this.removeAttribute('thickness')
117 : this.setAttribute('thickness', v); }
118 }
119 customElements.define(tagName, HSpace);
120 }
122 function defineVSpace(tagName) {
123 if (customElements.get(tagName)) return;
124 class VSpace extends HTMLElement {
125 static get observedAttributes() { return ['size', 'inline']; }
126 #mo;
.... // constructor mirrors HSpace; :host uses height: var(--_v-size, 1rem)
153 #update() {
154 this.style.display =
155 this.hasAttribute('inline') ? 'inline-block' : 'block';
157 const size = pickSize(this, 'v');
158 this.shadowRoot.host.style.setProperty('--_v-size', size);
159 }
161 get size() { return this.getAttribute('size'); }
162 set size(v) { v == null ? this.removeAttribute('size')
163 : this.setAttribute('size', v); }
164 }
165 customElements.define(tagName, VSpace);
166 }
168 // Define primary tags and short aliases
169 defineHSpace('h-space');
170 defineHSpace('h-s');
171 defineVSpace('v-space');
172 defineVSpace('v-s');
173 })();