Custom elements let a browser upgrade a hyphenated HTML tag into a JavaScript-backed component. They fit small reusable interface pieces that should work in plain HTML, especially when the behavior needs to travel without a framework-specific runtime.
An autonomous custom element extends HTMLElement and is registered with customElements.define(). The custom tag name must start with a lowercase letter, contain a hyphen, and avoid uppercase characters, so names such as status-badge work while plain names such as badge do not.
A small <status-badge> tag reads label and status attributes, renders its own shadow DOM, and updates when the status attribute changes. Keeping setup in connectedCallback() and rendering from observedAttributes lets static markup upgrade after parsing while later attribute changes redraw the component.
<section class="service-panel"> <h2>Service status</h2> <status-badge label="Production API" status="ready"></status-badge> </section> <script src="status-badge.js" defer></script>
Use a hyphenated name for custom tags. The browser treats <status-badge> as an autonomous custom element after registration.
Related: How to load JavaScript with defer
status-badge:not(:defined) { visibility: hidden; }
The :defined pseudo-class matches built-in elements and custom elements that already have a registered definition.
const STATUS_LABELS = { ready: "Ready", busy: "Busy", offline: "Offline", }; class StatusBadge extends HTMLElement { static observedAttributes = ["label", "status"]; constructor() { super(); this.attachShadow({ mode: "open" }); } connectedCallback() { if (!this.hasAttribute("role")) { this.setAttribute("role", "status"); } this.render(); } attributeChangedCallback() { this.render(); } render() { const label = this.getAttribute("label") || "Service"; const status = this.getAttribute("status") || "offline"; const normalizedStatus = Object.prototype.hasOwnProperty.call( STATUS_LABELS, status, ) ? status : "offline"; this.shadowRoot.innerHTML = ` <style> :host { display: inline-block; font: 600 1rem system-ui, sans-serif; } .badge { display: inline-flex; align-items: center; gap: 0.55rem; padding: 0.7rem 0.9rem; border-radius: 999px; background: #f8fafc; color: #1f2937; border: 1px solid #d8dee8; } .badge__dot { width: 0.75rem; height: 0.75rem; border-radius: 999px; background: #64748b; } .badge--ready .badge__dot { background: #138a43; } .badge--busy .badge__dot { background: #c77700; } .badge--offline .badge__dot { background: #9b1c31; } </style> <span class="badge badge--${normalizedStatus}"> <span class="badge__dot" aria-hidden="true"></span> <span class="badge__text"></span> </span> `; this.shadowRoot.querySelector(".badge__text").textContent = `${label}: ${STATUS_LABELS[normalizedStatus]}`; } } customElements.define("status-badge", StatusBadge);
Call super() before using this in the constructor. Keep attribute reads and rendering in lifecycle callbacks so parsed attributes and page markup are available.
> const badge = document.querySelector("status-badge")
undefined
> customElements.get("status-badge").name
"StatusBadge"
> badge.matches(":defined")
true
> badge.shadowRoot.querySelector(".badge__text").textContent
"Production API: Ready"
> badge.setAttribute("status", "busy")
undefined
> badge.shadowRoot.querySelector(".badge__text").textContent
"Production API: Busy"