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.
| Problem | What goes wrong | The fix |
|---|---|---|
| CSS collisions | Host styles leak into your widget and yours leak out | Render inside a Shadow DOM with inlined styles |
| Global scope | Two Reacts, clashing variables, polluted window | One self-contained bundle, no shared globals |
| Mounting | No guaranteed div to render into | Create your own container at runtime |
| Bundle size | A heavy script slows the host page | Code-split, lazy-load, and keep the loader tiny |
| Configuration | Every site needs different keys and options | Read 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.
<script
src="https://cdn.yoursite.com/widget.js"
data-widget-key="pk_live_123"
data-accent="#10312a"
defer
></script>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.
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 point3. 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.
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
| Area | Question to answer |
|---|---|
| Install | Is it truly one script tag with sensible defaults? |
| Isolation | Does it render in a Shadow DOM with inlined CSS? |
| Footprint | Is the initial payload small, with heavy UI lazy-loaded? |
| Safety | Does it avoid host globals and validate every message? |
| Config | Can a site theme and key it without a code change? |
| Cleanup | Does 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.