Building an Embeddable React Widget That Works on Any Site

Building an Embeddable React Widget That Works on Any Site

An embeddable React widget has to load from one script tag, mount its own DOM, isolate its styles from the host, stay small, and talk to the page without ever assuming control of it. Get those five things right and your widget runs cleanly on any site.

Why Embedding React Is Harder Than It Looks

In your own app, you control the HTML, the CSS reset, the bundler, and the global scope. A widget gives all of that up. The host page may already run React, jQuery, Tailwind, or a CSS framework that fights yours. Their reset may restyle your buttons. Their build has no idea yours exists.

The real problems of a third-party widget
ProblemWhat goes wrongThe fix
CSS collisionsHost styles leak into your widget and yours leak outRender inside a Shadow DOM with inlined styles
Global scopeTwo Reacts, clashing variables, polluted windowOne self-contained bundle, no shared globals
MountingNo guaranteed div to render intoCreate your own container at runtime
Bundle sizeA heavy script slows the host pageCode-split, lazy-load, and keep the loader tiny
ConfigurationEvery site needs different keys and optionsRead data- attributes from the script tag

1. Ship One Script, Mount Your Own Root

The install should be a single line the merchant pastes once. Everything else happens at runtime: the script finds itself, creates a container, and renders React into it. Never assume the page has a div waiting for you.

install snippet (host site)
<script
  src="https://cdn.yoursite.com/widget.js"
  data-widget-key="pk_live_123"
  data-accent="#10312a"
  defer
></script>
widget.js — entry + mount
import { createRoot } from 'react-dom/client';
import Widget from './Widget';

function mount() {
  const script =
    document.currentScript ||
    document.querySelector('script[data-widget-key]');

  // create our own host element, never trust the page to provide one
  const host = document.createElement('div');
  host.id = 'dahlia-widget-host';
  document.body.appendChild(host);

  const shadow = host.attachShadow({ mode: 'open' });
  const mountPoint = document.createElement('div');
  shadow.appendChild(mountPoint);

  createRoot(mountPoint).render(<Widget config={script.dataset} />);
}

if (document.readyState !== 'loading') mount();
else document.addEventListener('DOMContentLoaded', mount);

2. Isolate Your CSS From the Host With Shadow DOM

Shadow DOM is the single most important decision for a widget. Styles inside a shadow root do not leak out, and the host page styles do not leak in. That means your widget looks the same on a Tailwind store, a WordPress theme, or a hand-rolled CSS site.

The catch: your CSS must live inside the shadow root, so a normal stylesheet link will not reach it. Inline the CSS as a string and inject it into the shadow tree. With Vite you can import the compiled CSS as text.

inject inlined CSS into the shadow root
import css from './widget.css?inline';

function injectStyles(shadow) {
  const style = document.createElement('style');
  style.textContent = css;
  shadow.appendChild(style);
}

// call injectStyles(shadow) right after attachShadow,
// before rendering React into the mount point

3. Keep the Bundle Small and Self-Contained

A widget that adds 400 KB to someone else’s checkout will get removed. Treat bundle size as a feature, not an afterthought.

  • Build to a single IIFE/UMD file so there is nothing for the host to configure.
  • Lazy-load the heavy UI only after the launcher button is clicked, so the initial script stays tiny.
  • Avoid large dependencies; date and icon libraries add up fast inside a widget.
  • Bundle React with the widget rather than expecting the host to provide it — version conflicts are not worth the saved kilobytes.
  • Serve from a CDN with long cache headers and a versioned filename.

4. Talk to the Host Page Safely

Your widget will need to react to the host (cart updated, route changed) and tell the host things (lead captured, conversation opened). Do it through a narrow, explicit interface — custom events or postMessage — never by reaching into the host’s variables.

  • Emit namespaced CustomEvents on window so the host can listen without coupling to your internals.
  • For cross-origin iframes, use postMessage with a strict origin check on both sides.
  • Expose a tiny public API on a single global, like window.Dahlia.open(), instead of many loose functions.
  • Validate every message you receive; treat the host as untrusted input.

5. Configure Each Install With data- Attributes

The same bundle ships to every customer, so configuration has to ride along with the install. Reading data- attributes off the script tag keeps setup to one line and means no separate config file to get out of sync.

read config from the script dataset
function Widget({ config }) {
  const accent = config.accent || '#10312a';
  const apiKey = config.widgetKey;

  // accent and apiKey came straight from data- attributes,
  // so one bundle can theme itself per site
  return (
    <Launcher accent={accent} apiKey={apiKey} />
  );
}

An Embeddable Widget Checklist

Before you ship
AreaQuestion to answer
InstallIs it truly one script tag with sensible defaults?
IsolationDoes it render in a Shadow DOM with inlined CSS?
FootprintIs the initial payload small, with heavy UI lazy-loaded?
SafetyDoes it avoid host globals and validate every message?
ConfigCan a site theme and key it without a code change?
CleanupDoes it unmount and remove its host node on teardown?

Final Take

A good embeddable widget feels invisible to the developer who installs it: one line, no conflicts, no layout surprises. That polish comes from treating the host page as an environment you respect — isolated styles, a small footprint, an explicit API, and clean teardown.

Once that foundation is in place, the React part is the easy part. The architecture is what makes a widget something people are willing to put on their own site.

Back to Articles