Write Your Own HTML Elements (And When to Use Shadow DOM)
When I needed an accordion component for my blog, my first instinct was to write a markdown-it plugin.
I write hexo renderer plugin which allow Hexo to use markdown exit[1] to render markdown file to html. Having built several plugins for it already—such as integrating Shiki for code blocks—I’m very comfortable defining how Markdown should be transformed into HTML. But for components not supported by standard Markdown, there is a better choice.
Two Problems with Markdown-Exit Plugins
Syntax Design Is Overkill
There are only two hard things in Computer Science: cache invalidation and naming things.
— Phil Karlton
Defining custom Markdown syntax falls squarely into that second category. For a tiny UI component, you end up over-engineering the way text is written in your editor. Every new feature requires extending the parsing logic. Instead of focusing on the actual UI and interaction, you spend your time worrying about how the parser recognizes your custom tags.
Iteration Friction
To tweak even a minor detail in a plugin, the workflow is exhausting:
- Modify the plugin source code (written in TypeScript).
- Rebuild the plugin.
- Refresh the browser.
- Reinstall it in the blog project (skippable with
bun link) - Run
hexo clean && hexo sto clear the cache and restart the server.
All that effort just kills the joy of experimenting with design. It turns a creative spark into a tedious chore.
Custom Elements (Web Components) are the better answer. They are native browser features, work in any HTML context, and—most importantly—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.
Instead of:<div class="accordion"> <div class="accordion-item">...</div></div>
You write:<my-accordion> <accordion-item title="Section 1">Content here</accordion-item></my-accordion>
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);
The
define()method takes the following arguments:
name
The name of the element. This must start with a lowercase letter, contain a hyphen, and satisfy certain other rules listed in the specification’s definition of a valid name.
constructor
The custom element’s constructor function.
options
Only included for customized built-in elements, this is an object containing a single propertyextends, which is a string naming the built-in element to extend.
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?
The Key Decision: Shadow DOM vs. Global CSS
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:
- External CSS doesn’t affect internals — your global
divstyles won’t break the carousel’s layout - Internal CSS doesn’t leak — the carousel’s
.showcase-cardwon’t accidentally style other elements - CSS variables penetrate —
--crustand--surface0still 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
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
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 Own HTML Elements (And When to Use Shadow DOM)