Engineering··6 min read

Why We Chose Web Components Over React for Our Embeddable Widget

We needed a changelog widget that works everywhere — React apps, Vue dashboards, static sites, WordPress, Shopify, vanilla HTML. Here is why Web Components were the only serious option, and what we learned building one from scratch.

The problem: one widget, every environment

Changelog.dev lets teams publish product updates. But a changelog nobody sees is worthless. We needed an embeddable widget — a small bell icon that sits on your site, shows a popover of recent updates, and tracks unread entries. Two constraints made this harder than it sounds:

  • 1.The widget must work on any website, regardless of framework, build tool, or CSS methodology.
  • 2.The widget must never break the host page — and the host page must never break the widget.

That second constraint is the killer. If you have ever embedded a third-party script that mangled your layout or inherited your font sizes, you know exactly what we mean.

Why React widgets are problematic

Our main app is built with Next.js. The obvious first instinct was to ship the widget as a React component. We prototyped it. Within a day we had a list of problems that would never go away:

  • +Bundle size — Even with react and react-dom marked as peer dependencies, we were looking at 40KB+ minified just for the runtime. If the host page does not use React, they are downloading an entire framework for a bell icon.
  • +Version conflicts — Host page runs React 17. Our widget needs React 18. Or worse, the host uses Preact with the compat layer. Multiple React instances on a single page cause subtle, maddening bugs with event delegation and state.
  • +Style collisions — React has no built-in style encapsulation. CSS Modules help inside your own app. They do nothing when your component is injected into a stranger's page with a global * {margin: 0} reset or aggressive Tailwind utilities.
  • +Framework lock-in — A React widget is useless to Vue, Svelte, Angular, Astro, and plain HTML users without a wrapper. That is most of the web.

The same problems apply to Vue, Svelte, or any framework-based widget. You are shipping framework opinions into someone else's codebase. The math does not work.

Why Web Components won

Web Components are a set of browser-native APIs: Custom Elements, Shadow DOM, and HTML Templates. They have been stable across all major browsers since 2020. In 2026, they are boring technology — which is exactly what you want for infrastructure that must work everywhere.

Here is what made the decision obvious:

  • +Shadow DOM isolation — The widget's DOM tree and styles are completely encapsulated. The host page's CSS cannot reach in. The widget's CSS cannot leak out. This is not a convention or a build tool trick — it is enforced by the browser engine.
  • +Zero dependencies — No React, no Vue, no runtime. The browser is the framework. Our final bundle is 7.2KB gzipped.
  • +Framework-agnostic by default <changelog-widget> is just an HTML element. It works in React JSX, Vue templates, Svelte markup, Angular templates, PHP includes, WordPress shortcodes, and raw HTML files. No wrappers, no adapters.
  • +Native lifecycle connectedCallback, disconnectedCallback, and attributeChangedCallback give us everything we need to mount, unmount, and react to configuration changes without a virtual DOM diffing layer.
  • +Long-term stability — Web Components are a W3C standard implemented in every browser. React 18 APIs are not the same as React 17 APIs. Web Component APIs from 2020 still work identically in 2026 and will in 2030.

Technical deep-dive: how the widget works

The entire widget is a single Custom Element registered with the browser. Here is the core structure, simplified:

// Register the custom element
class ChangelogWidget extends HTMLElement {
// Declare which attributes trigger re-renders
static get observedAttributes() {
return ['project-id', 'theme', 'position', 'accent-color'];
}
constructor() {
super();
// Attach Shadow DOM -- this is the key to isolation
this.shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
// Called when the element is added to the DOM
this.render();
this.fetchEntries();
}
attributeChangedCallback(name, oldVal, newVal) {
// Re-render when attributes change at runtime
if (oldVal !== newVal) this.render();
}
disconnectedCallback() {
// Clean up event listeners, abort pending fetches
this.controller?.abort();
}
}
// Register as <changelog-widget>
customElements.define('changelog-widget', ChangelogWidget);

Shadow DOM: the real differentiator

Shadow DOM deserves its own section because it is the single feature that makes embeddable widgets viable. Without it, you are playing whack-a-mole with CSS specificity forever.

When we call this.attachShadow({ mode: 'open' }), the browser creates an isolated DOM subtree. The widget's styles are defined inside a <style> tag within the shadow root:

render() {
this.shadow.innerHTML = `
<style>
/* These styles ONLY apply inside the shadow root */
:host { position: relative; display: inline-block; }
.trigger { all: initial; cursor: pointer; ... }
.popover { position: absolute; z-index: 999999; ... }
.entry { padding: 12px 16px; border-bottom: 1px solid ... }
</style>
<button class="trigger" aria-label="View updates">
${ this.bellIcon }
${ this.unreadCount > 0 ? this.badgeHTML : '' }
</button>
<div class="popover" role="dialog" hidden>
${ this.renderEntries() }
</div>
`;
}

The :host selector targets the custom element itself. The all: initial on the trigger button resets every inherited property — this prevents the host page's global styles from affecting our button's appearance. The host page's .trigger class is a completely different thing. No collision. No specificity wars.

We tested this on pages with Tailwind's preflight reset, Bootstrap 5, older Foundation grids, and several WordPress themes with aggressive global selectors. The widget rendered identically on every single one.

Observed attributes: reactive without a framework

The observedAttributes static getter and attributeChangedCallback give us a reactive system that would feel familiar to anyone who has used React props or Vue props — except it is built into the browser:

// Change the theme at runtime -- works from any framework
document.querySelector('changelog-widget')
.setAttribute('theme', 'light');
// Or in React JSX:
<changelog-widget
project-id="my-app"
theme={darkMode ? 'dark' : 'light'}
/>
// Or in Vue:
<changelog-widget
project-id="my-app"
:theme="darkMode ? 'dark' : 'light'"
/>

When the attribute changes, the browser calls attributeChangedCallback automatically. We re-render. No state management library, no subscription system, no virtual DOM diff. The browser handles the observation for us.

The results

After shipping the Web Component version and deprecating the React prototype, here is where we landed:

  • +7.2KB gzipped — down from 43KB with the React version. That is a 6x reduction. On slow 3G, this is the difference between imperceptible and annoying.
  • +Zero CSS conflicts — we have not received a single bug report about style collisions since switching to Shadow DOM. With the React version, we had a new one every week.
  • +Works everywhere — confirmed working in React 17/18/19, Vue 3, Svelte 4/5, Angular 17, Astro, WordPress, Shopify Liquid templates, Hugo, Jekyll, and plain HTML.
  • +No version conflicts — there is no runtime to conflict with. If the browser supports Custom Elements (every browser since 2020), it works.
  • +Simpler maintenance — one file, no transpilation required, no JSX, no build step for the widget itself. We use a single esbuild invocation to minify and that is it.

When you should still use React

Web Components are not a replacement for React. They solve different problems. Here is when React is still the better choice:

  • +Complex application UIs — if you are building a dashboard with dozens of interactive views, data tables, forms with validation, drag-and-drop, and real-time updates, React's component model, hooks, and ecosystem save enormous amounts of time.
  • +Server-side rendering — Web Components do not render on the server. If SEO or initial page load performance of the component content matters, frameworks with SSR support (Next.js, Nuxt, SvelteKit) are better.
  • +Rich state management — once your widget needs to manage complex interdependent state, you are essentially rebuilding a framework inside your Custom Element. At that point, ship a framework.
  • +Team velocity — if your entire team knows React and nobody has touched Web Components, the learning curve is a real cost. For internal tools, use whatever your team is fastest with.

Our rule of thumb: if the component lives inside your app, use your app's framework. If it lives on other people's apps, use Web Components.

Try it yourself

The changelog widget is open source, MIT licensed, and free to use. Add it to your site in two lines:

<script src="https://unpkg.com/changelogdev-widget"></script>
<changelog-widget project-id="your-project" />

Or install from npm:

npm install changelogdev-widget