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:
- Extending Syntax
- 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
HTMLElementdirectly. - Customized built-in elements ā extend existing HTML elements like
HTMLParagraphElementorHTMLUListElement.
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
| Callback | When 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-carddoesnāt affect other elements. - CSS variables penetrate:
var(--red)andvar(--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
:hostselector 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:
| Type | Component Usage | Consumer Usage | Purpose |
|---|---|---|---|
| Named slot | <slot name="header"> | <div slot="header"> | Specific position |
| Default slot | <slot> | Put children directly | Catch-all content |
In device-carousel, <slot></slot> lets users override the JavaScript-generated cards with custom <device-card> elements directly in HTML.
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
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 Type | Style Strategy | Why |
|---|---|---|
| Accordion, Tabs | Global CSS | Content varies; needs external styles |
| Theme Switcher | Shadow DOM | Fixed UI; benefits from encapsulation |
| Carousel | Shadow DOM | Self-contained; complex internal CSS |
| Text-Image Section | Global CSS | Typography 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
- Building optimistic UI in Rails (and learn custom elements) ā Practical introduction
- MDN: Using custom elements ā Reference documentation
- MDN: Using shadow DOM ā Deep dive on encapsulation
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