Implementing Custom HTML Elements for Widget Embedding
A third-party widget on someone else's website is always foreign DOM, foreign styles, potentially conflicting library versions. Custom HTML elements (Custom Elements v1) provide a clean public API: one tag, attributes, events. The client inserts three lines of code and gets a working widget.
Architecture of an Embeddable Widget
The task differs from internal components. There's no control over the host page: it could have jQuery 1.x, Bootstrap 3, random CSS resets, or * { all: unset }. The widget must work in any environment.
Shadow DOM here is not optional, it's a necessity. It guarantees style isolation both ways: our styles don't leak to the host, and the host's styles don't break us.
<!-- What the client gets -->
<script src="https://cdn.example.com/widget.js" async></script>
<review-widget
data-product-id="SKU-12345"
data-theme="light"
data-locale="en"
></review-widget>
Custom Element Implementation
// src/ReviewWidget.ts
const TEMPLATE = document.createElement('template');
TEMPLATE.innerHTML = `
<style>
:host {
display: block;
contain: content;
font-family: var(--rw-font, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
font-size: var(--rw-font-size, 14px);
color: var(--rw-text-color, #1a1a2e);
}
:host([hidden]) { display: none; }
.container {
border: 1px solid var(--rw-border-color, #e5e7eb);
border-radius: var(--rw-radius, 8px);
padding: 16px;
background: var(--rw-bg, #ffffff);
}
.rating {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 12px;
}
.star { color: #f59e0b; font-size: 18px; }
.star.empty { color: #d1d5db; }
.reviews-list { list-style: none; margin: 0; padding: 0; }
.review-item {
padding: 10px 0;
border-top: 1px solid #f3f4f6;
}
.review-author { font-weight: 600; font-size: 13px; }
.review-text { margin-top: 4px; line-height: 1.5; }
.load-more {
margin-top: 12px;
width: 100%;
padding: 8px;
background: var(--rw-accent, #3b82f6);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.load-more:hover { opacity: .9; }
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
height: 14px;
margin: 6px 0;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
<div class="container">
<div class="rating" aria-label="Product rating"></div>
<ul class="reviews-list" role="list"></ul>
<button class="load-more" style="display:none">Load more</button>
</div>
`;
interface Review {
id: string;
author: string;
rating: number;
text: string;
date: string;
}
export class ReviewWidget extends HTMLElement {
static get observedAttributes() {
return ['data-product-id', 'data-theme', 'data-locale'];
}
private shadow: ShadowRoot;
private page = 1;
private allLoaded = false;
private apiBase = 'https://api.example.com/reviews';
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
this.shadow.appendChild(TEMPLATE.content.cloneNode(true));
}
connectedCallback() {
this.applyTheme();
this.fetchReviews(true);
this.shadow.querySelector('.load-more')
?.addEventListener('click', () => this.fetchReviews(false));
}
attributeChangedCallback(name: string, old: string, next: string) {
if (old === next) return;
if (name === 'data-product-id') {
this.page = 1;
this.allLoaded = false;
this.fetchReviews(true);
}
if (name === 'data-theme') {
this.applyTheme();
}
}
private applyTheme() {
const theme = this.dataset.theme ?? 'light';
if (theme === 'dark') {
const container = this.shadow.querySelector<HTMLElement>('.container');
if (container) {
container.style.setProperty('--rw-bg', '#1f2937');
container.style.setProperty('--rw-text-color', '#f9fafb');
container.style.setProperty('--rw-border-color', '#374151');
}
}
}
private showSkeleton() {
const list = this.shadow.querySelector('.reviews-list')!;
list.innerHTML = Array(3).fill(
'<li><div class="skeleton"></div><div class="skeleton" style="width:70%"></div></li>'
).join('');
}
private async fetchReviews(reset: boolean) {
const productId = this.dataset.productId;
if (!productId) return;
if (reset) {
this.page = 1;
this.showSkeleton();
}
try {
const url = new URL(`${this.apiBase}/${productId}`);
url.searchParams.set('page', String(this.page));
url.searchParams.set('per_page', '5');
url.searchParams.set('locale', this.dataset.locale ?? 'en');
const res = await fetch(url.toString(), {
headers: { 'X-Widget-Version': '2.1.0' },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: { reviews: Review[]; total: number; avg_rating: number } =
await res.json();
this.renderRating(data.avg_rating, data.total);
this.renderReviews(data.reviews, reset);
this.allLoaded = data.reviews.length < 5;
const btn = this.shadow.querySelector<HTMLElement>('.load-more');
if (btn) btn.style.display = this.allLoaded ? 'none' : 'block';
this.page++;
// inform the host page
this.dispatchEvent(new CustomEvent('reviews:loaded', {
detail: { total: data.total, avgRating: data.avg_rating },
bubbles: true,
composed: true,
}));
} catch (err) {
this.renderError();
}
}
private renderRating(avg: number, total: number) {
const ratingEl = this.shadow.querySelector('.rating')!;
const stars = Array.from({ length: 5 }, (_, i) =>
`<span class="star ${i < Math.round(avg) ? '' : 'empty'}">★</span>`
).join('');
ratingEl.innerHTML = `${stars} <span>${avg.toFixed(1)} (${total} reviews)</span>`;
ratingEl.setAttribute('aria-label', `Rating ${avg.toFixed(1)} out of 5, ${total} reviews`);
}
private renderReviews(reviews: Review[], reset: boolean) {
const list = this.shadow.querySelector('.reviews-list')!;
if (reset) list.innerHTML = '';
const locale = this.dataset.locale ?? 'en';
const dateFormatter = new Intl.DateTimeFormat(locale, {
year: 'numeric', month: 'long', day: 'numeric',
});
reviews.forEach(r => {
const li = document.createElement('li');
li.className = 'review-item';
li.innerHTML = `
<div class="review-author">${r.author}
<time datetime="${r.date}" style="font-weight:400;color:#6b7280;margin-left:8px">
${dateFormatter.format(new Date(r.date))}
</time>
</div>
<div class="review-text">${r.text}</div>
`;
list.appendChild(li);
});
}
private renderError() {
this.shadow.querySelector('.reviews-list')!.innerHTML =
'<li style="color:#ef4444;padding:8px 0">Failed to load reviews</li>';
}
}
customElements.define('review-widget', ReviewWidget);
Loader Script and Hydration
One file that the client includes once. It registers the element itself and processes already existing DOM instances:
// src/loader.ts
import { ReviewWidget } from './ReviewWidget';
// Protection against double inclusion
if (!customElements.get('review-widget')) {
customElements.define('review-widget', ReviewWidget);
}
// For old browsers without customElements — minimal polyfill
if (!window.customElements) {
console.warn('[review-widget] Custom Elements not supported');
}
CDN Publishing and Resource Integrity
<script
src="https://cdn.example.com/[email protected]/widget.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzavkYAlNwh38S"
crossorigin="anonymous"
async
></script>
The SRI hash is generated at build time and specified in the documentation. The client won't update the widget accidentally, only when intentionally changing the version in the URL.
Timeline
For one embeddable widget of medium complexity, approximately three weeks: one week for API design and isolation architecture, one week for development and testing (including cross-browser Shadow DOM verification), one week for integration testing with real client host sites.
Complexity increases significantly if the widget must support iframe mode as a fallback for very old browsers or iframe sandboxes. In that case, add up to two weeks for postMessage communication between the iframe and the host.







