WebDev Bites: Spacer Component Design

W3C web component

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:
  1. observedAttributes - static getter listing attribute names whose changes trigger attributeChangedCallback.
  2. constructor - calls super(), attaches a shadow root, and populates it with a template of CSS and HTML.
  3. shadow DOM template - inlined CSS scoped to :host plus the element's HTML structure, with <slot> elements for light-DOM content projection.
  4. lifecycle callbacks - connectedCallback, disconnectedCallback, and attributeChangedCallback let the element react to DOM insertion, removal, and attribute changes.
  5. 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 })();