Write Your Components With Custom Elements API

When I wanted an accordion component for my blog, my first instinct was to build a markdown-it plugin.

I write hexo renderer plugin which allow Hexo to use markdown exit[1] to convert Markdown into HTML, so extending the parser felt like the natural path.

But that approach comes with 2 problems:

  1. Extending Syntax
  2. Iteration is Slow

All that effort just kills the joy of experimenting with design.

Custom Elements (Web Components)Ā are the better answer. They are native browser features, work in any HTML context, and require no build steps during development.

What Are Custom Elements?

Custom elements let you define your own HTML tags with associated behavior. Register once with customElements.define(), and the element is available everywhere.

The browser parses your custom tags, and your JavaScript class provides the behavior.

How to Define a Custom Element

Custom elements come in two flavors:

  • Autonomous custom elements — standalone elements that extend HTMLElement directly.
  • Customized built-in elements — extend existing HTML elements like HTMLParagraphElement or HTMLUListElement.

Most of the time, you want autonomous. Here’s the pattern:

class MyAccordion extends HTMLElement {  constructor() {    super(); // Must call super() first  }  connectedCallback() {    // Runs when element is added to DOM    this.innerHTML = `<div class="accordion">${this.innerHTML}</div>`;  }}customElements.define('my-accordion', MyAccordion);

Note that the name(my-accordion) of the element must start with a lowercase letter, contain a hyphen, and satisfy certain other rules listed in the specification’sĀ definition of a valid name.

Lifecycle Callbacks

CallbackWhen it runs
constructor()Element created — call super() first
connectedCallback()Added to DOM
disconnectedCallback()Removed from DOM
attributeChangedCallback(name, oldValue, newValue)Attribute changed — requires static observedAttributes
adoptedCallback()Moved to new document

For simple components, you’ll use connectedCallback for setup and disconnectedCallback for cleanup (remove event listeners, cancel timers).

Now that you understand the pattern, here’s the real question: where do those styles live?

Use Shadow DOM or not

The choice depends on what your component contains.

Shadow DOM

For self-contained components with fixed internal styling—like a carousel or card stack—Shadow DOM provides true encapsulation.

With Shadow DOM:

  • CSS is fully isolated: Global styles don’t break the carousel, and .showcase-card doesn’t affect other elements.
  • CSS variables penetrate: var(--red) and var(--font-serif) still work, so theming is possible from outside

Here’s a simplified example from my blog’s device-carousel.js:

class DeviceCarousel extends HTMLElement {  constructor() {    super();    this.attachShadow({ mode: 'open' }); // Key: create Shadow DOM  }  connectedCallback() {    this.render();  }  render() {    const style = `      :host { display: block; --card-width: 280px; }      .track {        display: flex;        animation: scroll var(--duration, 40s) linear infinite;      }      .card {        width: var(--card-width);        background: var(--bg, #1e1e2e);        border-radius: 12px;      }      @keyframes scroll {        0% { transform: translateX(0); }        100% { transform: translateX(-50%); }      }    `;    const devices = this.getDevices();    const cards = [...devices, ...devices] // Duplicate for seamless scroll      .map(d => `<div class="card">${d.name}</div>`).join('');    this.shadowRoot.innerHTML = `      <style>${style}</style>      <div class="track">${cards}</div>      <slot></slot>    `;  }  getDevices() {    // Get custom content from slot, or use default data    const slot = this.querySelector('device-card');    if (slot) {      return [{ name: slot.getAttribute('name'), image: slot.getAttribute('image') }];    }    return [{ name: 'Default Device', image: '/default.png' }];  }}customElements.define('device-carousel', DeviceCarousel);

Key points:

  • this.attachShadow({ mode: 'open' }) creates Shadow DOM
  • All styles live inside Shadow DOM, isolated from the outside
  • :host selector styles the component’s root element
  • CSS variables (--card-width, --duration) allow external customization

Using Slot to Insert External Content

<slot> is Shadow DOM’s most powerful feature—it lets external content pass through the Shadow DOM barrier into the component’s interior:

// Define slot inside componentthis.shadowRoot.innerHTML = `  <div class="header"><slot name="header">Default Title</slot></div>  <div class="body"><slot></slot></div>`;
<!-- Usage: --><my-component>  <span slot="header">Custom Title</span>  <p>This content goes into the default slot</p></my-component>

Two slot types:

TypeComponent UsageConsumer UsagePurpose
Named slot<slot name="header"><div slot="header">Specific position
Default slot<slot>Put children directlyCatch-all content

In device-carousel, <slot></slot> lets users override the JavaScript-generated cards with custom <device-card> elements directly in HTML.

Tip

The slot="..." attribute tells the browser where to place content. Elements without a slot attribute go into the default (unnamed) slot.

Live Example of Shadow DOM Component


Non-Shadow DOM (Global CSS)

Accordions, tabs, and other ā€œcontainerā€ components hold arbitrary content—code blocks, lists, images. That content should inherit your site’s typography and spacing. So you don’t need to duplicate styles for these components.

Here’s a real example from text-image-section.js (my blog’s text-image component):

let styleSheetInjected = false;class TextImageSection extends HTMLElement {  connectedCallback() {    this.injectStyles();    this.render();  }  injectStyles() {    if (styleSheetInjected) return;  // Prevent duplicate injection    const style = `      text-image-section { display: block; }      .ti-text { line-height: 1.8; }      .ti-image img { border-radius: 8px; }    `;    const styleEl = document.createElement('style');    styleEl.textContent = style;    document.head.appendChild(styleEl);    styleSheetInjected = true;  }}customElements.define('text-image-section', TextImageSection);

Key differences from Shadow DOM:

  • No attachShadow() — content lives directly in the light DOM
  • Styles injected to document.head — using a module-level flag to avoid duplicates
  • User content (text, images) inherits site styles — paragraphs, links, lists all use your blog’s typography

The trade-off: global styles might accidentally affect your component, so use specific class names (like .ti-text) to minimize conflicts.

Live Example of Non-Shadow DOM Component

Hermann Karl Hesse (2 July 1877 – 9 August 1962) was a German-Swiss poet and novelist, and winner of the 1946 Nobel Prize in Literature. His interest in Eastern religious, spiritual, and philosophical traditions, combined with his involvement with Jungian analysis, helped to shape his literary work. His best-known novels include Demian, Steppenwolf, Siddhartha, Narcissus and Goldmund, and The Glass Bead Game, each of which explores an individual’s search for authenticity, self-knowledge, and spirituality.

Hesse was a widely read author in German-speaking countries during his lifetime, but his more enduring international fame did not come until a few years after his death, when, in the mid-1960s, his works became enormously popular with post-World War II generation readers in the United States, Europe, and elsewhere.

A Decision Framework

Quick Rule

Does the component contain user content that should match the rest of the page? Use global CSS. Is it a self-contained widget with fixed structure? Use Shadow DOM.

Decision Matrix

Component TypeStyle StrategyWhy
Accordion, TabsGlobal CSSContent varies; needs external styles
Theme SwitcherShadow DOMFixed UI; benefits from encapsulation
CarouselShadow DOMSelf-contained; complex internal CSS
Text-Image SectionGlobal CSSTypography should match page

You could reach for React, Vue, or a web component library. But for a static blog, custom elements offer something frameworks don’t:

  • No build step during development — edit JS, refresh the page
  • No framework overhead — the browser handles everything
  • Works anywhere — drop a script tag into any HTML page

The browser’s native APIs are more capable than most developers realize. Custom elements, combined with Shadow DOM when appropriate, cover most interactive component needs without dependencies.

Further Reading


  1. Markdown-exit is a modern, TypeScript-based Markdown parser and renderer that extends markdown-it with asynchronous rendering, plugin support, and full TypeScript type safety. ā†©ļøŽ

Write Your Components With Custom Elements API

https://vluv.space/custom_elements/

Author

GnixAij

Posted on

2026-02-20

Updated on

2026-04-18

Licensed under