Field Notes

Web Components

Shadow DOM CSS Isolation: How to Embed a Widget Without Style Conflicts

A React wallet SDK embedded in Bootstrap, WordPress, and Tailwind sites cannot rely on ordinary CSS scoping. Shadow DOM solves the style boundary, but Radix UI portals need extra handling. This note covers the working setup.

Topic
Web Components
Year
Read
8 min

The problem

Crypto Wallet SDK npm packagesystem cut
Published on npm at @qubitronlabs/crypto-wallet
Proof from @qubitronlabs/crypto-wallet SDKOpen system

Attempt 1

CSS Modules and :global() escapes were not enough. Bootstrap's .btn { ... } could still match the widget's .btn, because the cascade and specificity rules do not care which build tool produced the CSS.

Attempt 2

Bundling Tailwind CSS into JavaScript and injecting a <style> tag did not change that relationship. The widget was still part of the host cascade, so a rule like .text-center { color: red !important } could win.

The breakthrough

An open Shadow DOM gives the widget a real style boundary. Bootstrap, Tailwind, and the rest of the host page no longer reach into it, and the widget's own styles stay inside it.

Implementation

tsup includes the Tailwind output for the utilities in use in the JavaScript bundle. When the widget mounts, it creates this.shadowRoot = this.attachShadow({ mode: 'open' }), places that CSS in a <style> tag inside the shadow root, and renders the React app with createRoot(shadowRoot).render(<App />).

The Radix UI portal problem

Radix Dialog, Tooltip, Select, and Popover normally send their portals to document.body. That is outside the shadow root, so those portals lose the widget's Tailwind CSS. I instead point each Portal prop to a container in the shadow root: <Dialog.Portal forceMount container={shadowRoot.getElementById('radix-portal')} />. A ShadowPortalProvider sets that rule once for the widget.

Runtime config injection (no build-time env vars)

The host app passes API keys, chain configuration, and feature flags at runtime through window.__CRYPTO_WALLET_CONFIG__. That lets the same npm bundle run in development, staging, and production without another build.

Fuzzy WebSocket matching for race conditions

A live backend update can arrive before the REST API has returned the first block hash. The widget matches WebSocket events to pending operations using transaction-hash fragments, chain ID, and a timestamp window, which makes out-of-order delivery manageable.

Invisible synchronizer components

WebSocket state lives in Zustand instead of React Context, so each incoming message does not re-render the whole tree. Small synchronizer components render no JSX. Their useEffect hooks listen for wagmi network changes and Socket.IO messages, then write those events into Zustand.

Result

Continue conversation

Frontend problem worth thinking through?

Bring the context, including the hard parts. I am happy to talk them through.