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:
- Fetches the sibling stylesheet (
/widget.js→/widget.css). - Sends
Accept: text/cssso dev servers (Vite, etc.) return raw CSS rather than the JS module they emit by default for HMR. - Injects the stylesheet once as a
<style data-mountly-fallback>in<head>, deduplicated by CSS string. - Renders the component directly into the container.
// host pageimport { 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 / -reactimport 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.
CSS source resolution
Section titled “CSS source resolution”When the adapter is asked to mount, it picks one stylesheet source in this order:
| Priority | Source | Where it’s set |
|---|---|---|
| 1 | cssUrl in mount props | mod.mount(c, { cssUrl: "/foo.css" }) |
| 2 | cssUrl in createWidget options | createWidget(C, { cssUrl: "/foo.css" }) |
| 3 | moduleUrl in mount props → derived .css | mod.mount(c, { moduleUrl: "/foo.js" }) |
| 4 | moduleUrl in createWidget options | createWidget(C, { moduleUrl: "/foo.js" }) |
| 5 | styles literal string | createWidget(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.
Light DOM scoping options
Section titled “Light DOM scoping options”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.
Shadow DOM (shadow: true)
Section titled “Shadow DOM (shadow: true)”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:
- Calls
attachShadow()on the container withmode: shadowMode ?? "open". - Adopts the stylesheet into the shadow root via
adoptedStyleSheets(a singleCSSStyleSheetinstance is shared across all roots that need the same CSS). - 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 across React, Vue, and Svelte
Section titled “CSS Modules across React, Vue, and Svelte”CSS Modules pair naturally with both modes. The build emits hashed class names; the JSX/template references them; mountly does the wiring.
import styles from "./Card.module.css";
export default function Card({ msg }: { msg: string }) { return <button className={styles.button}>{msg}</button>;}.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 dev server gotcha
Section titled “Vite dev server gotcha”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.
Islands and noscript
Section titled “Islands and noscript”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 passesshadow: 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.
Caching
Section titled “Caching”Two layers, both keyed for deduplication:
loadCssTextcache (in-memoryMap<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.- CSS-string deduplication in
shadow.ts:- Light DOM: an
injectedCssSet 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
sheetCachekeeps oneCSSStyleSheetper unique CSS string. 100 widgets that share the same stylesheet share one parsed sheet, no duplicate CSSOM allocations.
- Light DOM: an
Combined: a widget mounted in 50 places fetches CSS once, parses once, holds one stylesheet in memory.
Picking a strategy
Section titled “Picking a strategy”| You want… | Use |
|---|---|
| Zero-config, host design system reaches in | moduleUrl 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 widget | default light DOM + global <link> |
| Hard isolation from host CSS (CMS, third-party hosts) | shadow: true + moduleUrl or cssUrl |
| Tailwind layer inside scoped widget | shadow: 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 off | SSR markup + <link> in <head> + island payload moduleUrl |
Related
Section titled “Related”createWidgetfor the full options reference.createOnDemandFeaturefor the zero-configmoduleUrlshortcut.- Distribution for how the
dist/index.js+dist/index.csspair fits the multi-widget story.