How it works
mountly has three layers and one state machine.
Three layers
Section titled “Three layers”1. Components — your code
Section titled “1. Components — your code”Plain framework components. No special APIs, no decorators, no new model. You write the same code you write today.
import { useState } from "react";
export default function SignupCard({ headline }: { headline: string }) { const [email, setEmail] = useState(""); return ( <form> <h2>{headline}</h2> <input value={email} onChange={(e) => setEmail(e.target.value)} /> <button type="submit">Subscribe</button> </form> );}2. Widgets — framework-agnostic mounting units
Section titled “2. Widgets — framework-agnostic mounting units”A framework adapter wraps the component into a widget: an object with mount(container, props) and unmount(container). The widget knows nothing about when it loads, only how to render.
import { createWidget } from "mountly-react";import SignupCard from "./SignupCard.tsx";import styles from "./SignupCard.css?inline";
export default createWidget(SignupCard, { styles });Each adapter (React, Vue, Svelte) exposes the same createWidget(Component, { styles }) signature. By default the widget mounts in light DOM so the host’s design system applies. Pass shadow: true when you need a hard style boundary (CMS embeds, third-party hosts).
3. Features — the on-demand lifecycle
Section titled “3. Features — the on-demand lifecycle”A feature wraps a widget with a trigger, a module loader, optional data fetching, dual caching, and a state machine. You use this layer in production.
import { createOnDemandFeature } from "mountly";
export const signupFeature = createOnDemandFeature({ moduleId: "signup-card", loadModule: () => import("./signup-card.js"), loadData: async () => fetch("/api/copy").then((r) => r.json()), render: ({ mod, data, container, props }) => { mod.mount(container, { ...props, ...data }); },});The lifecycle
Section titled “The lifecycle”Every feature flows through the same five states:
idle → preload → activate → mount → unmount- idle: nothing loaded. Waiting for a trigger.
- preload: mountly fetches the module. A data fetch can begin in parallel.
- activate: a commitment signal arrives (e.g. click). mountly finalises the data fetch and prepares to mount.
- mount: mountly renders the widget into the container (light DOM by default; shadow root if
shadow: true). - unmount: mountly removes the DOM. It retains the module and data caches, so re-mounting is free.
You can hook any phase, abort in flight (feature.abort()), and inspect the current state (feature.getState()). See Lifecycle for the full state diagram.
Triggers
Section titled “Triggers”A trigger decides when a feature moves between states. Seven are built in:
| Trigger | Description | Best for |
|---|---|---|
hover |
Preload on mouseenter, mount on click. |
Discoverable controls. |
click |
Mount on click without preload. | Committed actions. |
focus |
Preload on focus, mount on commit. |
Keyboard-accessible counterparts to hover. |
viewport |
Mount when element enters viewport (configurable threshold). | Below-the-fold content. |
idle |
Use requestIdleCallback to preload during quiet moments. |
Predictive prefetch. |
media |
Mount when a CSS media query matches. | Responsive-only UI. |
url-change |
Mount on popstate/hashchange/pushState/replaceState. |
Route-driven content. |
You can also register trigger plugins, such as swipe, long-press, or keyboard chord, without touching the runtime.
Dual caching
Section titled “Dual caching”mountly keeps two cooperating caches:
Module cache
Section titled “Module cache”Stores downloaded JavaScript bundles, keyed by moduleId. It deduplicates in-flight requests: if 12 hovers fire before the first import resolves, you get one network request, not twelve.
Data cache
Section titled “Data cache”Stores loadData responses, keyed by a stable serialisation of the feature context (excludes the DOM element and event fields by default). Override getCacheKey for coarser caching.
Both caches survive across re-mounts, so the second open of the same widget is instant.
Bundle strategy
Section titled “Bundle strategy”When you build a widget, the CLI emits two ESM entries:
dist/index.js — self-contained
Section titled “dist/index.js — self-contained”Includes the component, the adapter, and the framework. Drop it into any page and it works without host setup. A representative React widget weighs about 148 KB gz.
dist/peer.js — peer build
Section titled “dist/peer.js — peer build”Excludes the framework. The host provides react, react-dom, react-dom/client, and react/jsx-runtime via an import map. Each widget weighs about 5 KB gz, plus one 45 KB gz copy of React shared across all of them.
The host picks which one to load via the import map. The widget source is identical either way. See Distribution for when each makes sense and installRuntime for the helper that wires the React import map for you.
Custom element
Section titled “Custom element”Declarative usage uses a custom element:
<mountly-feature module-id="signup-card" trigger="viewport" props='{"plan":"pro"}' />Register the factory once and the element handles attach/detach automatically. See Custom element.
What you write vs what mountly provides
Section titled “What you write vs what mountly provides”| You write | mountly provides |
|---|---|
| Components (React/Vue/Svelte) | Adapters that wrap them as widgets |
createOnDemandFeature(...) config |
Lifecycle, abort, dual caching, in-flight dedup |
Trigger choice (hover, viewport, …) |
Trigger setup, cleanup, cancellation |
loadModule / loadData |
Module cache, data cache, error wrapping |
<mountly-feature> markup |
Custom element wiring |
- Triggers: each trigger type in depth.
- Lifecycle: the state diagram and abort behaviour.
- Caching: cache keys, invalidation, in-flight dedup.
- Distribution: self-contained vs shared React.