React
mountly-react is the React adapter. It turns any React component into a mountly widget — an object with mount(container, props) and unmount(container), framework-agnostic at the boundary.
Install
Section titled “Install”pnpm add mountly mountly-react react react-domReact is a peer dependency. Install whichever version you ship.
Basic usage
Section titled “Basic usage”import { createWidget } from "mountly-react";import SignupCard from "./SignupCard.tsx";
export default createWidget(SignupCard);That’s it for the widget side. When the host wires moduleUrl, the adapter fetches the sibling dist/index.css (whatever your build emits: plain CSS, CSS Modules, Tailwind output) and applies it before React renders. No FOUC. By default the widget mounts in light DOM so the host’s design system applies; pass shadow: true for full isolation.
The string-based path still works if you’d rather inline:
import styles from "./SignupCard.css?inline";export default createWidget(SignupCard, { styles });Scoped styles in React (the framework that doesn’t have them)
Section titled “Scoped styles in React (the framework that doesn’t have them)”React itself has no native style scoping. Two paths work well with mountly:
- Default light DOM + CSS Modules. The build emits hashed class names that practically never collide with host CSS. The host’s design system continues to apply, which is usually what you want for product code.
shadow: truefor hard isolation. Whatever class names your build emits only apply inside the widget’s shadow tree. A global.button { color: red }rule on the host page cannot reach in. Conversely, your component’s CSS cannot leak out. Reach for this when the widget is embedded in unknown hosts.
You can use any styling approach React supports with either mode:
CSS Modules
Section titled “CSS Modules”import styles from "./SignupCard.module.css";
export default function SignupCard({ msg }: { msg: string }) { return ( <button className={styles.button}> <span className={styles.label}>{msg}</span> </button> );}The build emits hashed selectors like .button_h_4f9c1 in dist/index.css and { button: "button_h_4f9c1" } in the JS bundle. In light DOM the hashed names already prevent host conflicts in practice; with shadow: true Mountly’s repo includes a Playwright test (react-css-modules.html) that proves a colliding global .button { color: red !important } rule does not pierce the shadow root.
Plain CSS files
Section titled “Plain CSS files”import "./SignupCard.css"; // build emits dist/index.cssWorks the same way: the adapter fetches the sibling stylesheet and applies it before render.
CSS-in-JS (styled-components, emotion, etc.)
Section titled “CSS-in-JS (styled-components, emotion, etc.)”These libraries inject <style> tags at runtime targeting document.head. In light DOM (the default) they work as designed. With shadow: true the shadow root can’t see those tags, so configure the library’s shadow-root mode or stylesheet manager and pass the shadow root via React Context. For most cases, CSS Modules or plain CSS is simpler than CSS-in-JS inside a shadow root.
shadcn / Tailwind
Section titled “shadcn / Tailwind”shadcn pairs Tailwind tokens with Radix UI primitives, and it works out of the box on the default light-DOM mount. Ship one global Tailwind stylesheet through a <link> in the host’s <head> and let Radix portal content to document.body the way it was designed.
// Default light DOM — nothing extra to configure.export const hero = createWidget(Hero);export const pricing = createWidget(Pricing);export const newsletter = createWidget(Newsletter);<head> <link rel="stylesheet" href="/app/dist/globals.css" /> <!-- Tailwind output --></head>The repo demonstrates this end-to-end:
tests/fixtures/shadcn-light-dom-bundle.js: three React widgets composing sharedButton/Cardprimitives.tests/fixtures/shadcn-light-dom-tokens.css: stand-in forapp/globals.csswith:roottokens and utility classes.tests/multi-widget-bundles.spec.ts: proves all three widgets pick up the same primary color from the global stylesheet.
If you genuinely want shadow DOM with shadcn, expect two friction points and plan for them:
:roottokens don’t apply.bg-backgroundresolves tohsl(var(--background));--backgroundis set on:root(the document<html>). Inside a shadow root,:rootmatches nothing, so every shadcn token comes out empty. Compile Tailwind targeting:hostinstead.- Radix portals target
document.body. Dialog, Popover, DropdownMenu, etc. portal their content outside the component tree. From inside a shadow root, that portal lands outside the shadow boundary. Wrap each Radix-using component to pass aPortal containerfrom inside the shadow root via context.
For most teams, the default light-DOM path is the right answer. For more on the choice, see Multi-widget bundles.
External npm packages
Section titled “External npm packages”Components are free to import any npm package: @tanstack/react-table, date-fns, lucide-react, whatever. Two configurations:
Bundled (default for Vite library mode): the build inlines the package. One JS file. No host setup needed.
Externalised: mark the package external in your build config so the host import map provides it. Lets multiple widgets share one copy.
<script type="importmap"> { "imports": { "react": "https://esm.sh/react@19.2.5", "react-dom/client": "https://esm.sh/react-dom@19.2.5/client?deps=react@19.2.5", "@tanstack/react-table": "https://esm.sh/@tanstack/react-table@8?deps=react@19.2.5" } }</script>The ?deps=react@19.2.5 query is what tells esm.sh to align every package on one React copy. Without it you’ll see “Invalid hook call” errors from React seeing two different runtime instances. The repo has a tanstack-table fixture and test that uses this pattern with React 19 and proves table rendering works through the mountly pipeline.
Mounting
Section titled “Mounting”The widget exposes mount(container, props). The props are passed through to your component:
import widget from "./signup-card.js";
widget.mount(document.querySelector("#cta-mount"), { headline: "Try the API", plan: "pro",});Each call to mount() unmounts any existing root in the same container first, so calling it twice is safe.
On-demand lifecycle (zero-config)
Section titled “On-demand lifecycle (zero-config)”import { createOnDemandFeature } from "mountly";
const signup = createOnDemandFeature({ moduleId: "signup-card", moduleUrl: "/widgets/signup-card/dist/index.js",});
signup.attach({ trigger: document.querySelector("#cta")!, preloadOn: "hover", activateOn: "click",});mountly threads moduleUrl through to the adapter, which fetches dist/index.css and applies it before React renders.
Live updates without remounting
Section titled “Live updates without remounting”The React adapter exposes update() automatically — it routes through React’s reconciler so internal state survives:
await signup.update(container, { plan: "enterprise" });// Component re-renders with the new prop, hooks state preserved.The custom element <mountly-feature> calls this for you when its props attribute changes.
Two builds, one source
Section titled “Two builds, one source”mountly init configures tsup to emit:
dist/index.js— bundles React + ReactDOM. Drop into any host.dist/peer.js— externalises React. Pairs with the host’s import map; one React for many widgets.
You don’t choose at build time — both are produced. The host’s import map decides which to load. See Distribution.
Server components
Section titled “Server components”The React adapter mounts on the client. It assumes a Component that renders client-side. If you want React Server Components, you’re outside mountly’s design — the adapter calls createRoot() and render(), which only work in the browser.
You can still mount a client component that fetches server-rendered HTML and shows it; the framing is “client widget that consumes server data.”
Common pitfalls
Section titled “Common pitfalls”Two React instances
Section titled “Two React instances”If you ship two widgets, both with dist/index.js, you have two copies of React. Hooks in the two trees can’t share context. Always use the peer build for multi-widget pages. See Distribution.
Styles not appearing
Section titled “Styles not appearing”Three usual causes:
- Wrong source. If you’re passing
styles, it must be a CSS string, not a path. Vite needs?inline, esbuild needstextloader, etc. - Missing sibling .css. If you’re relying on
moduleUrlauto-derivation, check that your build actually emitsdist/index.cssnext todist/index.js. Vite library mode does this whenever a component imports a stylesheet. - Dev server returning JS instead of CSS. Vite serves
.cssas an HMR-wrapped JS module unlessAccept: text/cssis sent. The adapter sends that header. If you’ve put a custom proxy or service worker in front, make sure it doesn’t strip it.
Hydration error in dev
Section titled “Hydration error in dev”The adapter mounts a fresh tree, not a hydrated one. If you mount into a <div> that already contains DOM produced server-side, React will complain. Mount into an empty element.
Reference
Section titled “Reference”createWidget— the adapter API.createOnDemandFeature— the lifecycle wrapper.examples/signup-card,examples/payment-breakdown,examples/image-lightboxin the repo are React widgets.
Tailwind and non-Tailwind defaults
Section titled “Tailwind and non-Tailwind defaults”No Tailwind
Section titled “No Tailwind”import { createWidget } from "mountly-react";import "./Component.css"; // build emits dist/index.cssimport Component from "./Component.tsx";
export default createWidget(Component);With Tailwind
Section titled “With Tailwind”Tailwind compiles into the same dist/index.css and the same zero-config flow works. If you’d rather inline:
import { createWidget } from "mountly-react";import Component from "./Component.tsx";import styles from "./styles.css?inline";
export default createWidget(Component, { styles });