Skip to content

Styling

mountly’s styling model has one design goal: make the right thing the easy thing. The default path is zero-config and works the same way for React, Vue, and Svelte. Overrides are available when you need them.

The default: light DOM + auto-fetched companion CSS

Section titled “The default: light DOM + auto-fetched companion CSS”

When you wire a feature with moduleUrl, the adapter:

  1. Fetches the sibling stylesheet (/widget.js/widget.css).
  2. Sends Accept: text/css so dev servers (Vite, etc.) return raw CSS rather than the JS module they emit by default for HMR.
  3. Injects the stylesheet once as a <style data-mountly-fallback> in <head>, deduplicated by CSS string.
  4. Renders the component directly into the container.
// host page
import { createOnDemandFeature } from "mountly";
const card = createOnDemandFeature({
moduleId: "card",
moduleUrl: "/widgets/card/dist/index.js",
});
card.attach({ trigger: btn, activateOn: "click" });
// widget bundle (built with Vite library mode, emits index.js + index.css)
import { createWidget } from "mountly-svelte"; // or -vue / -react
import Card from "./Card.svelte";
export default createWidget(Card);

That’s the entire pipeline. The host doesn’t write a render function. The widget doesn’t manually load CSS. Vite’s library build emits the sibling .css automatically. The widget renders inline so the host’s design system reaches in naturally.

When the adapter is asked to mount, it picks one stylesheet source in this order:

PrioritySourceWhere it’s set
1cssUrl in mount propsmod.mount(c, { cssUrl: "/foo.css" })
2cssUrl in createWidget optionscreateWidget(C, { cssUrl: "/foo.css" })
3moduleUrl in mount props → derived .cssmod.mount(c, { moduleUrl: "/foo.js" })
4moduleUrl in createWidget optionscreateWidget(C, { moduleUrl: "/foo.js" })
5styles literal stringcreateWidget(C, { styles: ".x { color: red }" })

If a fetch fails (404, network error, CORS), the mount still succeeds: the widget renders unstyled rather than throwing. Failures are logged silently; if you want hard failures, validate the asset at build time.

Without a shadow root there is no real isolation, only convention. Two strategies for the styles payload:

  • Default (styleMode: "global"): appends a <style data-mountly-fallback> to <head> once per unique CSS string. Subsequent mounts of the same widget reuse it.
  • styleMode: "isolated": writes a <style data-mountly-inline="true"> next to the mount node. Each container gets its own copy, useful when you want the styles to live with the markup rather than at the document level.

For real isolation, see the next section.

When the widget will be embedded in unknown hosts (CMS pages, third-party sites, legacy apps with conflicting CSS) opt into shadow DOM:

createWidget(Card, { shadow: true, cssUrl: "/widgets/card.css" });

With shadow: true the adapter:

  1. Calls attachShadow() on the container with mode: shadowMode ?? "open".
  2. Adopts the stylesheet into the shadow root via adoptedStyleSheets (a single CSSStyleSheet instance is shared across all roots that need the same CSS).
  3. Renders the component inside the shadow tree.

The host’s CSS does not reach in (modulo CSS custom properties, which inherit through shadow roots intentionally). Whatever class names your build emits stay scoped inside the widget.

If the container is a void element like <img> or <input>, attachShadow throws. The adapter falls back to light DOM in that case and logs a warning.

CSS Modules pair naturally with both modes. The build emits hashed class names; the JSX/template references them; mountly does the wiring.

Card.tsx
import styles from "./Card.module.css";
export default function Card({ msg }: { msg: string }) {
return <button className={styles.button}>{msg}</button>;
}
Card.module.css
.button { color: rgb(7, 7, 7); background: rgb(255, 200, 100); }

In light DOM the hashed class names already prevent host conflicts in most cases. Add shadow: true if you also need the host’s CSS not to reach in. The repo has a Playwright test (react-css-modules.html) that proves the shadow case with a deliberate decoy rule.

The same trick works for Svelte’s component styles (already scoped via Svelte’s hash) and Vue’s <style scoped> (already scoped via data-v-… attributes). Shadow-DOM scoping is additive: you still get whatever your framework gives you, plus a hard boundary.

Vite’s dev server returns .css files as JS modules by default. The module exports a string of CSS and registers itself with HMR. If you fetch("/widget.css"), you get JavaScript:

import { createHotContext as __vite__createHotContext } from "/@vite/client";
// ...
export default ".button { color: red }";

Pass that to replaceSync() and you get an empty stylesheet. The fix, and the reason mountly’s adapter implementations all do it, is to send Accept: text/css:

fetch(url, { headers: { Accept: "text/css" } })

Vite’s content-negotiation returns raw CSS for that header, so the stylesheet parses correctly. You only ever need to think about this if you’re putting a custom proxy or service worker between the page and Vite.

When JavaScript is disabled, no adapter runs. There is no shadow root, no adoptedStyleSheets, no injected <style>. The browser simply renders whatever HTML and <link> tags the server sent.

For an island to remain styled with JS off, you need a document-level stylesheet, typically a <link rel="stylesheet"> in the <head>:

<head>
<link rel="stylesheet" href="/widgets/card/dist/index.css" />
</head>
<body>
<div data-mountly-island='{
"schemaVersion": 1,
"id": "card",
"moduleId": "card",
"trigger": "click",
"moduleUrl": "/widgets/card/dist/index.js"
}'>
<span class="styled-widget">server-rendered content</span>
</div>
<script type="module" src="/packages/mountly/dist/host-entry.js"
data-mountly-host
data-mountly-loaders='{"card":"/widgets/card/dist/index.js"}'></script>
</body>

What you get:

  • JS off: the <link> styles the server-rendered span. The script never runs. The user sees correctly styled content.
  • JS on: the host bootstraps. On click, the island hydrates. By default it mounts in light DOM and the document <link> continues to apply. If the island also passes shadow: true, the adapter adopts the same CSS into the shadow root, where it scopes correctly; the document-level <link> is then harmless duplication.

The repo has a test (tests/island-nojs-styling.spec.ts) that runs the same fixture with javaScriptEnabled: false and true and verifies the same final color in both cases.

Why mountly can’t do this for you automatically

Section titled “Why mountly can’t do this for you automatically”

Adding a <link> requires JavaScript. If JS is off, no script (including mountly’s host entry) ever runs. The only thing that works without JS is server-rendered HTML. So the noscript pattern is a content-author decision: ship the SSR markup and the <link> alongside it.

Two layers, both keyed for deduplication:

  1. loadCssText cache (in-memory Map<url, string>). Keyed by URL. Fetching the same URL twice in the same page-load returns the cached string, no network. Test code can call __clearCssTextCache() to reset between fixtures.
  2. CSS-string deduplication in shadow.ts:
    • Light DOM: an injectedCss Set tracks every unique CSS string that has been added to <head>. The hundredth widget mounted with the same CSS reuses the existing <style> tag.
    • Shadow DOM: a sheetCache keeps one CSSStyleSheet per unique CSS string. 100 widgets that share the same stylesheet share one parsed sheet, no duplicate CSSOM allocations.

Combined: a widget mounted in 50 places fetches CSS once, parses once, holds one stylesheet in memory.

You want…Use
Zero-config, host design system reaches inmoduleUrl only (the default path)
Inline CSS at build time (small components, no fetch)styles: "..."
Manual CSS path (asset hosted elsewhere)cssUrl: "https://cdn.example.com/..."
Tailwind / utility-first global classes inside widgetdefault light DOM + global <link>
Hard isolation from host CSS (CMS, third-party hosts)shadow: true + moduleUrl or cssUrl
Tailwind layer inside scoped widgetshadow: true and compile Tailwind into the widget’s dist/index.css
CSS-in-JS (styled-components, emotion)Default light DOM, or configure the library to inject into the shadow root via context when shadow: true
Server-rendered island readable when JS is offSSR markup + <link> in <head> + island payload moduleUrl