Skip to content

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.

Terminal window
pnpm add mountly mountly-react react react-dom

React is a peer dependency. Install whichever version you ship.

signup-card.ts
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: true for 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:

SignupCard.tsx
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.

import "./SignupCard.css"; // build emits dist/index.css

Works 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 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 shared Button/Card primitives.
  • tests/fixtures/shadcn-light-dom-tokens.css: stand-in for app/globals.css with :root tokens 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:

  1. :root tokens don’t apply. bg-background resolves to hsl(var(--background)); --background is set on :root (the document <html>). Inside a shadow root, :root matches nothing, so every shadcn token comes out empty. Compile Tailwind targeting :host instead.
  2. 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 a Portal container from 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.

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.

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.

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.

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.

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.

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.”

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.

Three usual causes:

  1. Wrong source. If you’re passing styles, it must be a CSS string, not a path. Vite needs ?inline, esbuild needs text loader, etc.
  2. Missing sibling .css. If you’re relying on moduleUrl auto-derivation, check that your build actually emits dist/index.css next to dist/index.js. Vite library mode does this whenever a component imports a stylesheet.
  3. Dev server returning JS instead of CSS. Vite serves .css as an HMR-wrapped JS module unless Accept: text/css is 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.

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.

  • createWidget — the adapter API.
  • createOnDemandFeature — the lifecycle wrapper.
  • examples/signup-card, examples/payment-breakdown, examples/image-lightbox in the repo are React widgets.
import { createWidget } from "mountly-react";
import "./Component.css"; // build emits dist/index.css
import Component from "./Component.tsx";
export default createWidget(Component);

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 });