You embed a third-party widget on your site and an hour later find that buttons have turned red and scripts conflict with jQuery. Sound familiar? We offer a solution: custom HTML elements with Shadow DOM permanently eliminate this headache. The client inserts three lines of code and gets a working widget, isolated from the host site's environment. A third-party widget on someone else's site means someone else's DOM, someone else's styles, potentially conflicting library versions. Custom Elements v1 solve this problem radically: no CSS conflicts, no broken scripts. The load time for such a widget is 0.5–1.5s (LCP), whereas an iframe alternative takes 2–3s. This is a web component implementation that provides seamless integration and complete isolation.
How Custom HTML Elements Solve Website Conflicts
- Style conflicts: The widget's internal styles may accidentally override the site's styles (e.g.,
button { background: red }). Shadow DOM guarantees isolation in both directions. - Version management: The widget is connected via a single script with an SRI hash. The client will not accidentally update the widget — only by intentionally changing the version in the URL.
- Cross-browser support: Shadow DOM works in all modern browsers. For IE11 we include a polyfill. We test on real devices.
- SEO: Unlike iframes, content in Custom Elements is indexed by search engines. This is critical for review, rating, and catalog widgets.
What Shadow DOM Isolation Provides
Shadow DOM is the only reliable solution. We create a <template> with encapsulated styles and structure. Here is a minimal example:
<review-widget
data-product-id="SKU-12345"
data-theme="light"
data-locale="en"
></review-widget>
The element loads data via API and renders reviews. Styles are isolated — no * { all: unset } from the host page can break the widget.
Why Custom Elements Are Better Than iframes for Widgets
| Criterion | Custom Elements | iframe |
|---|---|---|
| Load time (LCP) | < 1.5s | ~3s |
| SEO indexing | Yes | No |
| Responsiveness | Automatic | Requires wrapper |
| Script size | ~15 KB (minified) | > 50 KB |
| Style isolation | Full (Shadow DOM) | Full (separate document) |
| Integration complexity | 3 lines of code | Size setup and postMessage |
| Bundle size after tree-shaking | ~8 KB | ~30 KB |
| Traffic savings (per month) | up to $2,000 | $0 |
Custom Elements are 2–3 times faster than iframes in load time and do not hide content from search engines. Traffic savings up to 70% (up to $2,000 per month).
| Browser | Custom Elements Support | Polyfill |
|---|---|---|
| Chrome 54+ | Full | No |
| Firefox 63+ | Full | No |
| Safari 10.1+ | Full | No |
| Edge 79+ | Full | No |
| IE11 | None | Required |
How the Widget Works Internally
The architecture includes three layers: Custom Element (registers the tag, manages lifecycle), Shadow DOM (isolated styles and markup), and API client (loads data with pagination). Below is the full code for ReviewWidget:
// 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">Show 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 = '/api/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}`, window.location.origin);
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);
The loader script is included once and handles existing instances in the DOM:
// src/loader.ts
import { ReviewWidget } from './ReviewWidget';
if (!customElements.get('review-widget')) {
customElements.define('review-widget', ReviewWidget);
}
if (!window.customElements) {
console.warn('[review-widget] Custom Elements not supported');
}
Publishing via CDN with an SRI hash ensures resource integrity. The client will not update the widget accidentally — only by intentionally changing the version in the URL.
When iframe is unavoidable
For IE11 and browsers older than 2017, Custom Elements require a polyfill. If your site's audience includes such users (share >5%), we include a polyfill or offer a hybrid scheme: for old browsers – iframe with postMessage, for modern – Custom Elements. Development time increases by 1–2 weeks.How Development and Integration Work
- Analysis: We study the client's environment: browsers, libraries, SEO requirements. We measure current LCP and CLS.
- Design: Define API attributes, events, data structure. Design Shadow DOM.
- Implementation: Write Custom Element, styles, API client. Configure build (Rollup + Terser) and CDN.
- Testing: Test on real host sites: Chrome 54+, Firefox 63+, Safari 10.1+, Edge 79+. Measure LCP (< 1.5s), CLS (< 0.1), INP (< 200ms).
- Deployment: Upload to CDN with SRI hash, provide client with script and documentation.
What's Included
- Development of a custom HTML element with Shadow DOM isolation.
- Integration documentation (script, attributes, events).
- Testing in target browsers.
- CDN hosting with SRI hash and versioning.
- One month of post-launch support (bug fixes, consultations).
Estimated Timeline
For a single widget of moderate complexity: 2 to 4 weeks. Complexity increases if iframe fallback is required: add up to 2 weeks for postMessage communication. Cost is calculated individually based on your stack and requirements. Request a preliminary audit — the audit fee is deducted from the development cost. We will analyze the environment and propose the optimal solution.
According to MDN Custom Elements, elements must be registered via customElements.define.
We have over 5 years of web development experience and have completed more than 30 widget embedding projects, delivering consistent LCP under 1.5 seconds and traffic savings of up to 70% (over $2,000 per month). Contact us for a project evaluation — we will analyze the environment and propose the optimal solution. Get a consultation or order widget development today.







