Custom Elements Without the Framework Overhead
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.
Custom elements come in two flavors:
- Autonomous custom elements — standalone elements that extend
HTMLElement. - Customized built-in elements — extend existing elements such as
HTMLParagraphElementorHTMLUListElement.
For most components on a blog, an autonomous custom element is exactly what you want.
Write a Custom Element
This section walks through building a custom element from scratch. We’ll use the <text-image-section> component from the example above—a responsive component that displays text alongside an image, switching to a stacked layout on mobile devices.
Step 1: Create the Class
Every custom element starts with a JavaScript class that extends HTMLElement. This gives your component access to the DOM APIs you’ll need.class TextImageSection extends HTMLElement { constructor() { super(); }}
The constructor() runs when the element is first created. Use it to initialize state, but avoid manipulating the DOM here—the element isn’t in the document yet.
Step 2: Add Lifecycle Callbacks
Custom elements have several lifecycle callbacks that fire at different stages:
connectedCallback()— fires when the element is added to the DOMdisconnectedCallback()— fires when the element is removedattributeChangedCallback()— fires when an attribute changesadoptedCallback()— fires when the element moves to a new document
For most components, you’ll start with connectedCallback():class TextImageSection extends HTMLElement { constructor() { super(); this._rendered = false; } connectedCallback() { this.render(); }}
The _rendered flag prevents double-rendering—we’ll use it to check if we’ve already processed this element.
Step 3: Read Attributes and Content
Now let’s extract the data we need from the element. Custom elements can read attributes via getAttribute() and access their child content via this.childNodes.render() { if (this._rendered) return; this._rendered = true; // Read attributes const image = this.getAttribute("image"); const alt = this.getAttribute("alt") || ""; const imageWidth = this.getAttribute("image-width") || "300px"; const reverse = this.hasAttribute("reverse"); // Extract text content (filter out the element itself) const contentNodes = Array.from(this.childNodes).filter((node) => { return node.nodeType !== Node.ELEMENT_NODE || node.tagName.toLowerCase() !== "text-image-section"; }); const content = contentNodes .map((node) => { return node.nodeType === Node.TEXT_NODE ? node.textContent : node.outerHTML; }) .join("") .trim();}
This is a common pattern: we grab any content the user put inside the element tags and store it as a string for re-insertion.
Step 4: Render Your Markup
Now construct the HTML. We’ll use template literals to build the inner HTML inside the render() method:class TextImageSection extends HTMLElement { // ... (previous code from Steps 2-3) render() { if (this._rendered) return; this._rendered = true; const containerClass = reverse ? "ti-container reverse" : "ti-container"; const figureHtml = alt ? `<figure> <img src="${image}" alt="${alt}" loading="lazy"> <figcaption>${alt}</figcaption> </figure>` : `<img src="${image}" alt="${alt}" loading="lazy">`; this.innerHTML = ` <div class="${containerClass}" style="--ti-image-width: ${imageWidth};"> <div class="ti-text">${content}</div> <div class="ti-image"> ${figureHtml} </div> </div> `; }}
The logic handles both cases: with and without the alt text (which becomes a caption).
Step 5: Add Styles
Styles can live inside the JavaScript file. For simplicity, inject them once into the document head:let styleSheetInjected = false;class TextImageSection extends HTMLElement { // ... injectStyles() { if (styleSheetInjected) return; const style = ` text-image-section { display: block; margin: 1em 0; } .ti-container { display: flex; flex-wrap: wrap; gap: 24px; align-items: flex-start; } .ti-container.reverse { flex-direction: row-reverse; } .ti-text { flex: 1; min-width: 280px; line-height: 1.8; } .ti-image { flex: 0 0 var(--ti-image-width, 300px); display: flex; justify-content: center; align-items: flex-start; } .ti-image img { max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } @media (max-width: 640px) { .ti-container, .ti-container.reverse { flex-direction: column; } .ti-image { flex: none; width: 100%; --ti-image-width: 100%; } } `; const styleEl = document.createElement("style"); styleEl.textContent = style; document.head.appendChild(styleEl); styleSheetInjected = true; } connectedCallback() { this.injectStyles(); this.render(); }}
Note the media query at the bottom—this handles the responsive behavior, switching from side-by-side to stacked on screens narrower than 640px.
Step 6: React to Attribute Changes
To make your component reactive, observe the attributes you care about:class TextImageSection extends HTMLElement { // ... (previous code) static get observedAttributes() { return ["image", "alt", "image-width", "reverse", "breakpoint"]; } attributeChangedCallback(name, oldValue, newValue) { if (oldValue !== newValue && this._rendered) { this._rendered = false; this.render(); } }}
When any of these attributes change, we reset _rendered to false and call render() again. This updates the component dynamically.
Step 7: Register the Element
Finally, register your class with the browser:customElements.define("text-image-section", TextImageSection);
The tag name must contain a hyphen (-). This is a browser requirement—it distinguishes custom elements from built-in HTML tags. Names like <my-element> or <text-image-section> are valid; <foo> or <div> are not.
Use It in Markdown
Once the JavaScript is loaded, using the component is straightforward:<script type="module" src="/js/components/text-image-section.HMH2MWVZ.js"></script><text-image-section image="https://www.myinterestingfacts.com/wp-content/uploads/2014/03/Hermann-Hesse-Image.jpg" alt="Hermann Hesse" image-width="200px"><font style="font-family: var(--font-serif); font-weight: 600;">hermann karl hesse</font> (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.</text-image-section>
And It Looks Like This
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.
How It Works
1. User writes: <text-image-section image="...">Content</text-image-section> ↓2. Browser parses HTML, encounters custom element ↓3. JavaScript loads, runs customElements.define() ↓4. Browser instantiates TextImageSection class ↓5. constructor() runs → _rendered = false ↓6. Element inserted into DOM → connectedCallback() fires ↓7. injectStyles() → adds <style> to head (once) ↓8. render() → builds HTML, sets innerHTML ↓9. User sees: styled text + image layoutThis approach gives you reusable components without any build pipeline. Edit the JavaScript, refresh the browser, and see the changes immediately.
Use Shadow DOM
Your styling strategy depends on what the component contains.
If the component is a self‑contained widget with a predictable internal structure (e.g., it only contains an
imgand aspan), use the Shadow DOM[2] to gain style encapsulation and avoid global CSS interference.If the component is a container for arbitrary user‑generated content (e.g., in the
text-image-sectionexample we mentioned above, the text part may contain all kinds of content, such as code blocks, lists, and more), keep it in the regular DOM and rely on global CSS for styling.
A Shadow DOM Example
Further Reading
- 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. ↩︎
An important aspect of custom elements is encapsulation, because a custom element, by definition, is a piece of reusable functionality: it might be dropped into any web page and be expected to work. So it’s important that code running in the page should not be able to accidentally break a custom element by modifying its internal implementation. Shadow DOM enables you to attach a DOM tree to an element, and have the internals of this tree hidden from JavaScript and CSS running in the page. Using shadow DOM - Web APIs | MDN ↩︎
Custom Elements Without the Framework Overhead